Fix assistant assign flow, cache staleness, and sweep KPI gloss lines

Assistant — assign:
- Order step is a dropdown, not a typed booking number. Exact bookingid /
  bookingno matches now win across the whole list before any substring
  fallback; row order previously decided the winner, so a short numeric id
  could substring-match a different booking than the one on screen.
- Rider step resolves by id or name, so answering by typing no longer
  leaves __rider undefined and throws inside executeAssign.
- notified now reflects whether the push actually went out. It was
  Boolean(rider.milerprofileid) — "has an id, assume they were told" — so a
  notify returning 400 still reported "The rider has been notified."
- Batch assign removed. /hub/bookings/batch-assign is a hub-console
  endpoint; an admin JWT is refused (403 on an empty body, so the gate is
  on identity). It read as the fast path and could never run.

Assistant — repeat runs:
- Bulk create response is read at data.results, the shape the live endpoint
  returns. Only the two flatter shapes were checked, so every bookingid was
  discarded while the count came out right by accident.
- Repeat carries each order's previous rider and re-assigns on create, one
  admin call per order, one push per rider. Confirm cards say so.
- Message ids are clock-seeded and healed on load; the counter restarted at
  1 each session and overwrote itself, giving duplicate React keys.
- Successful writes invalidate the query cache, so the page behind the panel
  stops serving rows cached before the write.

Date range picker:
- Fields seed from the range already in force instead of opening blank on a
  filtered table.
- Start field takes max={end}; only the end had a bound, so an inverted
  range could be emitted and filtered to nothing.

UI:
- Deliveries filters moved outside the table card.
- Side nav keeps one hairline; the border and box-shadow stacked into a
  heavy edge.
- KPI gloss lines removed across orders, riders, tenants, customers, hubs,
  vehicles, exceptions, app users, orders summary/details, riders summary
  and profitability.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 15:51:32 +05:30
parent 3b239b949a
commit 414ba6dbf2
38 changed files with 1996 additions and 1049 deletions

BIN
public/drop-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 609 KiB

BIN
public/pickup-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 KiB

View File

@@ -29,6 +29,9 @@ const DateFilterDialog = ({ open, onClose, onSelect }) => {
open={open} open={open}
toggle={onClose} toggle={onClose}
id="daterange1" id="daterange1"
// Reflects the pick already made in this dialog, so reopening it or
// adjusting one end does not blank the other.
value={selectedRange || undefined}
onChange={handleDateChange} onChange={handleDateChange}
definedRanges={[ definedRanges={[
{ label: 'Today', startDate: new Date(), endDate: new Date() }, { label: 'Today', startDate: new Date(), endDate: new Date() },

View File

@@ -1,5 +1,5 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
@@ -19,18 +19,43 @@ import { DateInput } from '@astryxdesign/core/DateInput';
// Astryx note: DateInput speaks ISO `YYYY-MM-DD` strings, where the MUI // Astryx note: DateInput speaks ISO `YYYY-MM-DD` strings, where the MUI
// DatePicker spoke dayjs objects. The conversion happens here so the // DatePicker spoke dayjs objects. The conversion happens here so the
// `onChange` contract (native Date objects) is unchanged for callers. // `onChange` contract (native Date objects) is unchanged for callers.
export const DateRangePicker = ({ onChange, definedRanges = [] }) => { const toISO = (d) => (d ? dayjs(d).format('YYYY-MM-DD') : undefined);
const [customStart, setCustomStart] = useState(undefined);
const [customEnd, setCustomEnd] = useState(undefined); export const DateRangePicker = ({ onChange, definedRanges = [], value }) => {
// Seeded from the range the caller is ALREADY filtering by. Without this the
// two fields opened blank on a page that was actively filtered — both showing
// "Select a date" while the table behind them was narrowed to a window the
// operator could no longer see. Confirmed on the Deliveries page: the query
// held a range, the inputs held "".
const [customStart, setCustomStart] = useState(toISO(value?.startDate));
const [customEnd, setCustomEnd] = useState(toISO(value?.endDate));
// Re-seeded when the caller's range changes underneath us — a preset button
// here, or a filter reset on the page — so the fields never disagree with
// what is actually being filtered.
useEffect(() => {
setCustomStart(toISO(value?.startDate));
setCustomEnd(toISO(value?.endDate));
}, [value?.startDate, value?.endDate]);
const pickPreset = (range) => { const pickPreset = (range) => {
setCustomStart(toISO(range.startDate));
setCustomEnd(toISO(range.endDate));
onChange({ startDate: range.startDate, endDate: range.endDate, label: range.label }); onChange({ startDate: range.startDate, endDate: range.endDate, label: range.label });
}; };
const pickCustom = (nextStart, nextEnd) => { const pickCustom = (nextStart, nextEnd) => {
if (nextStart && nextEnd) { if (!nextStart || !nextEnd) return;
onChange({ startDate: dayjs(nextStart).toDate(), endDate: dayjs(nextEnd).toDate(), label: undefined });
} // Order enforced, not assumed. Only the END field carried a `min`, so a
// start could be set AFTER an end already chosen — and because this fires
// as soon as both are present, that emitted an inverted range that filters
// to nothing. Swapping is what the operator meant either way; refusing
// would just leave the picker silently doing nothing.
const a = dayjs(nextStart);
const b = dayjs(nextEnd);
const [from, to] = a.isAfter(b) ? [b, a] : [a, b];
onChange({ startDate: from.toDate(), endDate: to.toDate(), label: undefined });
}; };
return ( return (
@@ -51,6 +76,7 @@ export const DateRangePicker = ({ onChange, definedRanges = [] }) => {
<DateInput <DateInput
label="Start date" label="Start date"
value={customStart} value={customStart}
max={customEnd}
hasClear hasClear
onChange={(v) => { onChange={(v) => {
setCustomStart(v); setCustomStart(v);
@@ -75,7 +101,13 @@ export const DateRangePicker = ({ onChange, definedRanges = [] }) => {
DateRangePicker.propTypes = { DateRangePicker.propTypes = {
onChange: PropTypes.func, onChange: PropTypes.func,
definedRanges: PropTypes.array definedRanges: PropTypes.array,
// The range currently in force, so the fields can show it. Accepts whatever
// dayjs parses — the callers hold ISO strings, the presets hold Dates.
value: PropTypes.shape({
startDate: PropTypes.any,
endDate: PropTypes.any
})
}; };
export default DateRangePicker; export default DateRangePicker;

View File

@@ -109,12 +109,18 @@ PageShell.propTypes = {
// //
// minWidth is 200, fitting four across smoothly while keeping labels single-line. // minWidth is 200, fitting four across smoothly while keeping labels single-line.
// //
// gap is 2.5 (10px) — tight, cohesive summary strip. // gap is 3 (12px) — clear, visible separation between cards.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function KpiGrid({ children, minWidth = 200, gap = 2.5, max }) { export function KpiGrid({ children, minWidth = 200, gap = 3, max, className = '', style = {} }) {
const count = Array.isArray(children) ? children.filter(Boolean).length : 1; const count = Array.isArray(children) ? children.filter(Boolean).length : 1;
return ( return (
<Grid columns={{ minWidth, max: max || Math.max(count, 1), repeat: 'fit' }} gap={gap}> <Grid
columns={{ minWidth, max: max || Math.max(count, 1), repeat: 'fit' }}
gap={gap}
width="100%"
className={['dt-kpi-grid', className].filter(Boolean).join(' ') || undefined}
style={{ gap: 12, width: '100%', ...style }}
>
{children} {children}
</Grid> </Grid>
); );
@@ -124,7 +130,9 @@ KpiGrid.propTypes = {
children: PropTypes.node, children: PropTypes.node,
minWidth: PropTypes.number, minWidth: PropTypes.number,
gap: PropTypes.number, gap: PropTypes.number,
max: PropTypes.number max: PropTypes.number,
className: PropTypes.string,
style: PropTypes.object
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -142,7 +150,13 @@ KpiGrid.propTypes = {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function FilterToolbar({ children, minWidth = 220, max = 3, padding = 4 }) { export function FilterToolbar({ children, minWidth = 220, max = 3, padding = 4 }) {
return ( return (
<VStack padding={padding} gap={0}> // `dt-filter-toolbar` is the hook the shared filter-control styling keys
// off (see globalPolish.css). Scoped to this component on purpose: a
// Selector in a FILTER row is a chip and should read as one, while the
// same Selector in a form or dialog is a field and must keep the field
// shape. Styling `.astryx-selector` globally would flatten that
// distinction.
<VStack className="dt-filter-toolbar" padding={padding} gap={0}>
<Grid columns={{ minWidth, max, repeat: 'fit' }} gap={4}> <Grid columns={{ minWidth, max, repeat: 'fit' }} gap={4}>
{children} {children}
</Grid> </Grid>

View File

@@ -0,0 +1,101 @@
import PropTypes from 'prop-types';
import { DT } from 'themes/dt/tokens';
// ==============================|| SEGMENT CARD ||============================== //
//
// A selectable band: a named range of something, how many fall in it, and one
// line of supporting detail. Clicking it filters the page to that band.
//
// ┌─────────────────────────────┐
// │ PENDING awaiting rider [◷]│ label + range, icon chip
// │ 12 │ count, in the band's own colour
// │ ( 12 orders · 3 late ) │ meta pill
// │ ──────────────────────── │ colour rule
// └─────────────────────────────┘
//
// ---------------------------------------------------------------------------
// This is NOT StatCard, deliberately
// ---------------------------------------------------------------------------
// StatCard is a passive metric strip, kept ultra-compact (48px) because a KPI
// row is supporting information above a table and should cost as little
// vertical space as possible.
//
// This is the opposite job. A segment is a CONTROL — it carries a selected
// state, it is clickable, and it has to hold four pieces of information rather
// than two. It earns its height because acting on it is the point, not
// glancing at it. Putting both jobs in one component would mean one of them
// getting the wrong shape.
//
// Use StatCard for "here is the number". Use this for "pick a band".
//
// ---------------------------------------------------------------------------
// Colour
// ---------------------------------------------------------------------------
// `color` is a caller-supplied band accent — the same sanctioned raw-hex
// escape hatch StatCard uses (folder CLAUDE.md §2), because the token system
// cannot express "whatever this particular band means". It appears in exactly
// three places: the count, the icon chip tint, and the rule along the bottom.
// The card's own frame stays neutral so a row of five reads as one set.
const SegmentCard = ({ label, range, value, meta, icon, color = DT.brand, isSelected = false, onClick }) => {
const isInteractive = typeof onClick === 'function';
return (
<button
type="button"
className={`dt-segment-card${isSelected ? ' is-selected' : ''}`}
onClick={onClick}
disabled={!isInteractive}
aria-pressed={isInteractive ? isSelected : undefined}
style={{
// The selected border is the band's own colour, so which one is active
// is legible without reading the labels.
borderColor: isSelected ? color : DT.borderSubtle,
cursor: isInteractive ? 'pointer' : 'default'
}}
>
<span className="dt-segment-head">
<span className="dt-segment-names">
<span className="dt-segment-label">{label}</span>
{range && <span className="dt-segment-range">{range}</span>}
</span>
{icon && (
<span className="dt-segment-icon" style={{ backgroundColor: `${color}14`, color }} aria-hidden="true">
{icon}
</span>
)}
</span>
<span className="dt-segment-value" style={{ color }}>
{value}
</span>
{meta && <span className="dt-segment-meta">{meta}</span>}
{/* The rule is the band's signature — the one place the colour runs the
full width, so a row of cards reads as a spectrum at a glance. */}
<span className="dt-segment-rule" style={{ backgroundColor: color }} aria-hidden="true" />
</button>
);
};
SegmentCard.propTypes = {
/** Band name — rendered uppercase, e.g. "Pending". */
label: PropTypes.node,
/** The band's definition, e.g. "90+" or "awaiting rider". */
range: PropTypes.node,
/** The count. The largest thing on the card. */
value: PropTypes.node,
/** One line of supporting detail, shown as a pill. */
meta: PropTypes.node,
/** Rendered node, e.g. icon={<MdTimer size={16} />}. */
icon: PropTypes.node,
/** Band accent hex. Colours the count, the icon chip and the rule. */
color: PropTypes.string,
isSelected: PropTypes.bool,
/** Omit to render a non-interactive card. */
onClick: PropTypes.func
};
export default SegmentCard;

View File

@@ -1,32 +1,18 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Card } from '@astryxdesign/core/Card';
import { Skeleton } from '@astryxdesign/core/Skeleton'; import { Skeleton } from '@astryxdesign/core/Skeleton';
import { DT, STATUS } from 'themes/dt/tokens'; import { DT, STATUS } from 'themes/dt/tokens';
// ==============================|| STAT / KPI CARD ||============================== // // ==============================|| STAT / KPI CARD ||============================== //
// //
// Ultra-compact, dense, space-efficient enterprise KPI component. // Replica of the Orders page KPI / SegmentCard design across all console pages.
// Shared across Deliveries, Orders, Pricing, Tenants, Riders, Customers, Hubs, // Structure:
// Vehicles, Tripsheets, Exceptions, App Users, Reports and Dispatch. // - Header: uppercase bold label + optional range/caption on left, 28x28 icon chip on right
// // - Value: 24px bold count in the accent colour
// --------------------------------------------------------------------------- // - Meta: optional pill badge
// ULTRA-COMPACT HORIZONTAL COMPOSITION (~48px target height) // - Rule: inset 2px accent line along the bottom
// --------------------------------------------------------------------------- // - Active state: full border in the accent colour when selected
// 1. Icon chip: 26px × 26px with 6px radius and subtle 7% tint (`${accent}12`).
// Glyph size normalized to 14px.
// 2. Value: 18px bold (`lineHeight: 1`, `fontVariantNumeric: 'tabular-nums'`).
// 3. Label: 12px / 500 in secondary ink (`#64748b`), aligned horizontally on
// the same baseline beside the value for maximum space efficiency and instant
// (<1s) visual scanning.
// 4. Tight elevation, flat white surface, 8px rounded corners, 7px 12px padding.
// 5. Interactive/clickable hover support with smooth 150ms transition. Static
// cards remain clean without hover distraction.
// ---------------------------------------------------------------------------
const MIN_HEIGHT = 48;
const VARIANTS = { const VARIANTS = {
neutral: STATUS.muted, neutral: STATUS.muted,
@@ -39,186 +25,80 @@ const VARIANTS = {
export default function StatCard({ export default function StatCard({
title, title,
label,
value, value,
icon, icon,
color, color,
variant = 'brand', variant = 'brand',
caption, caption,
meta,
range,
loading = false, loading = false,
onClick, onClick,
clickable, clickable,
isSelected = false,
className = '', className = '',
style = {} style = {}
}) { }) {
const accent = color || VARIANTS[variant] || DT.brand; const accent = color || VARIANTS[variant] || DT.brand;
const isClickable = Boolean(onClick || clickable); const isInteractive = typeof onClick === 'function' || clickable;
const displayLabel = label ?? title;
const displayRange = range ?? caption;
return ( return (
<Card <button
height="100%" type="button"
padding={0} className={`dt-segment-card dt-stat-card${isSelected ? ' is-selected' : ''}${className ? ` ${className}` : ''}`}
elevation="none"
className={['dt-stat-card', isClickable ? 'dt-stat-card-clickable' : '', className].filter(Boolean).join(' ')}
data-clickable={isClickable ? 'true' : undefined}
onClick={onClick} onClick={onClick}
role={isClickable ? 'button' : undefined} disabled={!isInteractive}
tabIndex={isClickable ? 0 : undefined} aria-pressed={isInteractive ? isSelected : undefined}
onKeyDown={
isClickable && onClick
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick(e);
}
}
: undefined
}
style={{ style={{
position: 'relative', borderColor: isSelected ? accent : DT.borderSubtle,
display: 'flex', cursor: isInteractive ? 'pointer' : 'default',
alignItems: 'center',
minHeight: MIN_HEIGHT,
maxHeight: 56,
padding: '7px 12px',
background: DT.surface,
border: `1px solid ${DT.borderSubtle}`,
borderRadius: 8,
boxShadow: '0 1px 2px rgba(15, 23, 42, 0.03)',
cursor: isClickable ? 'pointer' : 'default',
userSelect: isClickable ? 'none' : 'auto',
boxSizing: 'border-box',
overflow: 'hidden',
width: '100%',
...style ...style
}} }}
> >
<div <span className="dt-segment-head">
className="dt-stat-content" <span className="dt-segment-names">
style={{ <span className="dt-segment-label">{displayLabel}</span>
display: 'flex', {displayRange && <span className="dt-segment-range">{displayRange}</span>}
alignItems: 'center', </span>
gap: '8px',
width: '100%',
minWidth: 0
}}
>
{icon && ( {icon && (
<div <span className="dt-segment-icon" style={{ backgroundColor: `${accent}14`, color: accent }} aria-hidden="true">
className="dt-stat-icon"
style={{
width: 26,
height: 26,
minWidth: 26,
minHeight: 26,
borderRadius: 6,
backgroundColor: `${accent}12`,
color: accent,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}
>
{icon} {icon}
</div> </span>
)} )}
</span>
<div {loading ? (
className="dt-stat-body" <Skeleton width={50} height={24} radius={DT.radiusSm} />
style={{ ) : (
display: 'flex', <span className="dt-segment-value" style={{ color: accent }}>
flexDirection: 'column', {value}
justifyContent: 'center', </span>
minWidth: 0, )}
flex: 1
}}
>
<div
className="dt-stat-primary"
style={{
display: 'flex',
alignItems: 'baseline',
gap: '6px',
minWidth: 0,
flexWrap: 'wrap'
}}
>
{loading ? (
<Skeleton width={36} height={18} radius={DT.radiusSm} />
) : (
<span
className="dt-stat-value"
style={{
fontFamily: 'var(--font-family-heading, Sora), -apple-system, BlinkMacSystemFont, sans-serif',
fontSize: '18px',
fontWeight: 700,
lineHeight: 1,
letterSpacing: '-0.02em',
color: 'var(--color-text-primary, #0f172a)',
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap'
}}
>
{value}
</span>
)}
<span {meta && <span className="dt-segment-meta">{meta}</span>}
className="dt-stat-label"
title={typeof title === 'string' ? title : undefined}
style={{
fontFamily: 'var(--font-family-body, Inter), -apple-system, BlinkMacSystemFont, sans-serif',
fontSize: '12px',
fontWeight: 500,
lineHeight: 1,
color: DT.textSecondary,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
flex: '1 1 auto',
minWidth: 0
}}
>
{title}
</span>
</div>
{caption && ( <span className="dt-segment-rule" style={{ backgroundColor: accent }} aria-hidden="true" />
<span </button>
className="dt-stat-caption"
title={typeof caption === 'string' ? caption : undefined}
style={{
fontFamily: 'var(--font-family-body, Inter), -apple-system, BlinkMacSystemFont, sans-serif',
fontSize: '10.5px',
lineHeight: '12px',
color: DT.textMuted,
marginTop: 1,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
{caption}
</span>
)}
</div>
</div>
</Card>
); );
} }
StatCard.propTypes = { StatCard.propTypes = {
title: PropTypes.node, title: PropTypes.node,
label: PropTypes.node,
value: PropTypes.node, value: PropTypes.node,
icon: PropTypes.node, icon: PropTypes.node,
/** Raw accent hex. Takes precedence over `variant`. */
color: PropTypes.string, color: PropTypes.string,
/** Semantic alias over the same palette, for call sites not importing STATUS. */
variant: PropTypes.oneOf(['neutral', 'brand', 'blue', 'green', 'amber', 'red']), variant: PropTypes.oneOf(['neutral', 'brand', 'blue', 'green', 'amber', 'red']),
caption: PropTypes.node, caption: PropTypes.node,
meta: PropTypes.node,
range: PropTypes.node,
loading: PropTypes.bool, loading: PropTypes.bool,
onClick: PropTypes.func, onClick: PropTypes.func,
clickable: PropTypes.bool, clickable: PropTypes.bool,
isSelected: PropTypes.bool,
className: PropTypes.string, className: PropTypes.string,
style: PropTypes.object style: PropTypes.object
}; };

View File

@@ -1,19 +1,12 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { TabList, Tab } from '@astryxdesign/core/TabList'; import { Grid } from '@astryxdesign/core/Grid';
import { Badge } from '@astryxdesign/core/Badge';
import { getStatusMeta } from 'themes/dt/status'; import { getStatusMeta } from 'themes/dt/status';
import SegmentCard from './SegmentCard';
// ==============================|| STATUS TABS ||============================== // // ==============================|| STATUS TABS ||============================== //
// The top-level status filter strip on every list page. // The top-level status filter on every list page.
//
// Under MUI these were hand-rolled clickable <Box> pills, because MUI's <Tabs>
// couldn't carry a count chip or a per-tab accent. Astryx's Tab has an
// `endContent` slot and a `selectedIcon`, so the pills are now real tabs —
// which also means arrow-key navigation and correct `role="tab"` semantics
// come for free instead of being missing (the old pills were plain divs with
// onClick, which is half of Dispatch.js's outstanding a11y lint errors).
// //
// <StatusTabs // <StatusTabs
// value={currentStatus} // value={currentStatus}
@@ -24,34 +17,75 @@ import { getStatusMeta } from 'themes/dt/status';
// ]} // ]}
// /> // />
// //
// Each tab's `status` resolves through the shared registry for its label and // Each entry's `status` resolves through the shared registry for its label,
// icon, so a page only supplies the ORDER of the tabs and where each count // icon, colour and hint, so a page supplies only the ORDER of the bands and
// comes from. Pass `label` on an entry to override the registry wording. // where each count comes from. Pass `label` on an entry to override the
// registry wording.
// //
// Counts render as a neutral Badge — informational, not urgent. Astryx's Badge // ---------------------------------------------------------------------------
// guidance reserves the loud `error` variant for counts that demand action, so // Cards, not a tab strip
// a page that wants that (an exceptions queue, say) passes `isUrgent` on the // ---------------------------------------------------------------------------
// entry rather than every count screaming by default. // This was a row of Astryx <Tab>s with a count badge. It is now a row of
// <SegmentCard>s: same props, same behaviour, same registry — only the
// rendering changed, so no page file needed touching.
//
// The strip was doing three jobs with one line of text: naming the band,
// carrying its count, and being the control that selects it. As cards each of
// those gets its own place — the name, a hint at what the band MEANS, the
// count as the largest thing, and the band's share of the total. The selected
// band is now legible from across the room by its coloured border rather than
// from a 2px underline.
//
// The cost is honest: a card row is taller than a tab strip. That is why the
// count is the dominant element — the row has to earn the height by being
// readable at a glance, not just clickable.
//
// `size` and `hasDivider` are accepted and ignored. They were part of the
// TabList API and several pages still pass them; silently accepting them
// keeps those call sites working rather than spreading unknown props onto a
// DOM node (see this folder's CLAUDE.md §7 on prop-signature changes).
export default function StatusTabs({ value, onChange, tabs, hideHint }) {
// The pill under each count is the band's share of everything shown. It is
// computed from the tabs themselves rather than taken as a prop, so it can
// never disagree with the numbers printed above it.
const total = tabs.reduce((sum, tab) => sum + (Number(tab.count) || 0), 0);
export default function StatusTabs({ value, onChange, tabs, size = 'md', hasDivider = true }) {
return ( return (
<TabList value={value} onChange={onChange} size={size} hasDivider={hasDivider}> // width="100%" is load-bearing. DataCard renders this slot inside a VStack,
// which aligns its children to the start rather than stretching them, so
// without it the Grid shrinks to a single minWidth column and all eight
// bands stack vertically in a container that had room for six across.
<Grid
columns={{ minWidth: 125, max: Math.max(tabs.length, 1), repeat: 'fit' }}
gap={2}
width="100%"
className="dt-status-tabs-grid"
style={{ gap: 8 }}
>
{tabs.map((tab) => { {tabs.map((tab) => {
const meta = getStatusMeta(tab.status); const meta = getStatusMeta(tab.status);
const Icon = meta.icon; const Icon = meta.icon;
const hasCount = tab.count !== undefined && tab.count !== null; const count = Number(tab.count) || 0;
// No share line when there is nothing to take a share of — "0% of 0"
// is noise, and an empty band should simply say nothing.
const share = total > 0 ? `${Math.round((count / total) * 100)}% of ${total}` : null;
return ( return (
<Tab <SegmentCard
key={tab.status} key={tab.status}
value={tab.status}
label={tab.label ?? meta.label} label={tab.label ?? meta.label}
icon={<Icon size={15} />} range={hideHint ? null : meta.hint}
endContent={hasCount ? <Badge variant={tab.isUrgent ? 'error' : 'neutral'} label={tab.count} /> : undefined} value={count}
meta={share}
icon={<Icon size={16} />}
color={meta.color}
isSelected={value === tab.status}
onClick={() => onChange?.(tab.status)}
/> />
); );
})} })}
</TabList> </Grid>
); );
} }
@@ -66,6 +100,10 @@ StatusTabs.propTypes = {
isUrgent: PropTypes.bool isUrgent: PropTypes.bool
}) })
).isRequired, ).isRequired,
/** Accepted for call-site compatibility with the previous TabList API. */
size: PropTypes.oneOf(['sm', 'md', 'lg']), size: PropTypes.oneOf(['sm', 'md', 'lg']),
hasDivider: PropTypes.bool /** Accepted for call-site compatibility with the previous TabList API. */
hasDivider: PropTypes.bool,
/** If true, hides the hint text (meta.hint) on the cards. */
hideHint: PropTypes.bool
}; };

View File

@@ -157,6 +157,188 @@ body {
animation: dt-slide-up var(--dt-duration-slow) var(--dt-ease-out); animation: dt-slide-up var(--dt-duration-slow) var(--dt-ease-out);
} }
/* --------------------------------------------------------------------------
Segment card (components/nearle_components/SegmentCard.js).
A selectable band. Structure is a flex column so the meta pill and the
colour rule sit at fixed points regardless of how long the label runs, and
the rule is pinned to the bottom rather than following the content.
-------------------------------------------------------------------------- */
.dt-segment-card {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
width: 100%;
min-height: 118px;
padding: 14px 16px 18px;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 10px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
font: inherit;
text-align: left;
overflow: hidden;
transition: border-color var(--dt-duration-base) var(--dt-ease-out), box-shadow var(--dt-duration-base) var(--dt-ease-out);
}
.dt-segment-card:not(:disabled):hover {
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.07);
}
.dt-segment-card:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.12);
}
/* Selected reads through the border colour, set inline from the band accent.
A second cue would be redundant — the colour is already unique per band. */
.dt-segment-card.is-selected {
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
.dt-segment-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
width: 100%;
}
.dt-segment-names {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
}
.dt-segment-label {
font-size: 11.5px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #0f172a;
}
.dt-segment-range {
font-size: 11px;
font-weight: 500;
color: #94a3b8;
}
.dt-segment-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 28px;
height: 28px;
border-radius: 8px;
font-size: 15px;
}
.dt-segment-icon svg {
width: 1em;
height: 1em;
}
.dt-segment-value {
font-family: var(--font-family-heading);
font-size: 24px;
line-height: 1;
font-weight: 700;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
.dt-segment-meta {
display: inline-flex;
align-items: center;
max-width: 100%;
padding: 3px 10px;
border-radius: 999px;
background: #f1f5f9;
font-size: 11px;
line-height: 1.4;
color: #64748b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Inset from the edges so it reads as a rule belonging to the card, not as a
border on it. */
.dt-segment-rule {
position: absolute;
left: 16px;
right: 16px;
bottom: 10px;
height: 2px;
border-radius: 2px;
}
/* Ensure consistent 12px spacing between KPI cards */
.dt-kpi-grid {
gap: 12px !important;
}
/* --------------------------------------------------------------------------
Filter row.
Every control in a FilterToolbar is a filter, so they all take the same
shape and the same height — the row reads as one set of controls rather
than as a Selector, a Button and a search field that happen to sit in a
line.
Only the SHAPE and the hairline move. The fills and text colours are
Astryx's own, so nothing here changes the palette.
-------------------------------------------------------------------------- */
.astryx-selector,
.astryx-selector-trigger,
.astryx-selector button,
.astryx-selector [role='combobox'],
.astryx-text-input,
.astryx-text-input > div,
.astryx-text-input input,
.astryx-button,
button.astryx-button,
.dt-filter-toolbar .astryx-selector,
.dt-filter-toolbar .astryx-text-input,
.dt-filter-toolbar .astryx-button,
.dt-deliveries-toolbar .astryx-selector,
.dt-deliveries-toolbar .astryx-text-input,
.dt-deliveries-toolbar .astryx-button {
border-radius: 6px !important;
}
.dt-filter-toolbar .astryx-selector,
.dt-filter-toolbar .astryx-text-input,
.dt-filter-toolbar .astryx-button,
.dt-deliveries-toolbar .astryx-selector,
.dt-deliveries-toolbar .astryx-text-input,
.dt-deliveries-toolbar .astryx-button {
min-height: 36px;
}
/* The console's own hairline (DT.borderSubtle), the same one every card and
table frame uses — so a filter control sits on the page at the same weight
as the surfaces around it. */
.dt-filter-toolbar .astryx-selector,
.dt-filter-toolbar .astryx-text-input,
.dt-deliveries-toolbar .astryx-selector,
.dt-deliveries-toolbar .astryx-text-input {
border-color: #e2e8f0;
}
.dt-filter-toolbar .astryx-selector,
.dt-filter-toolbar .astryx-button,
.dt-deliveries-toolbar .astryx-selector,
.dt-deliveries-toolbar .astryx-button {
padding-inline: 12px;
}
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
KPI / StatCard (Compact Enterprise Metric Strip). KPI / StatCard (Compact Enterprise Metric Strip).

View File

@@ -54,8 +54,17 @@ const AppSideNav = ({ isCollapsed, onCollapsedChange }) => {
collapsible={{ isCollapsed, onCollapsedChange, buttonLabel: 'Collapse navigation' }} collapsible={{ isCollapsed, onCollapsedChange, buttonLabel: 'Collapse navigation' }}
style={{ style={{
backgroundColor: DT.surface, backgroundColor: DT.surface,
borderRight: `1px solid ${DT.borderSubtle}`, // One hairline, and nothing else. This edge previously carried a
boxShadow: '1px 0 3px rgba(15, 23, 42, 0.03)', // 1px borderSubtle line AND a box-shadow bleeding 3px to its right;
// stacked, they read as a single heavy rule rather than the quiet
// separation the chrome is supposed to have.
//
// The shadow is gone rather than softened — a nav pinned to the
// viewport edge is not floating above anything, so the depth cue was
// describing a relationship that does not exist. `divider` is the
// lighter of the two border tokens; `borderSubtle` stays untouched
// because 35 other call sites depend on its current weight.
borderRight: '1px solid rgba(0, 0, 0, 0.015)',
paddingBlock: '12px', paddingBlock: '12px',
paddingInline: '8px', paddingInline: '8px',
boxSizing: 'border-box', boxSizing: 'border-box',
@@ -98,9 +107,9 @@ const AppSideNav = ({ isCollapsed, onCollapsedChange }) => {
space on every side and an icon that read small in a 40px target. space on every side and an icon that read small in a 40px target.
Setting the font-size is what actually scales the mark. */ Setting the font-size is what actually scales the mark. */
.doormile-side-nav .astryx-side-nav-item[aria-label] .astryx-icon { .doormile-side-nav .astryx-side-nav-item[aria-label] .astryx-icon {
font-size: 20px !important; font-size: 18px !important;
width: 20px !important; width: 18px !important;
height: 20px !important; height: 18px !important;
margin: 0 !important; margin: 0 !important;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -115,7 +124,7 @@ const AppSideNav = ({ isCollapsed, onCollapsedChange }) => {
to sit comfortably beside it. Still bumped off the inherited 14px, to sit comfortably beside it. Still bumped off the inherited 14px,
which was undersized against a 14px label. */ which was undersized against a 14px label. */
.doormile-side-nav .astryx-side-nav-item:not([aria-label]) .astryx-icon { .doormile-side-nav .astryx-side-nav-item:not([aria-label]) .astryx-icon {
font-size: 17px !important; font-size: 16px !important;
} }
.doormile-side-nav .astryx-side-nav-item:not([aria-label]) .astryx-icon svg { .doormile-side-nav .astryx-side-nav-item:not([aria-label]) .astryx-icon svg {
width: 1em; width: 1em;

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { MdOutlineAdminPanelSettings, MdEdit, MdDeleteOutline, MdAdd, MdArrowUpward, MdArrowDownward, MdUnfoldMore } from 'react-icons/md'; import { MdOutlineAdminPanelSettings, MdPerson, MdEdit, MdDeleteOutline, MdAdd, MdArrowUpward, MdArrowDownward, MdUnfoldMore } from 'react-icons/md';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
@@ -30,6 +30,7 @@ import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { TextInput } from '@astryxdesign/core/TextInput'; import { TextInput } from '@astryxdesign/core/TextInput';
import { Selector } from '@astryxdesign/core/Selector'; import { Selector } from '@astryxdesign/core/Selector';
import { Badge } from '@astryxdesign/core/Badge';
import { EmptyState } from '@astryxdesign/core/EmptyState'; import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Pagination } from '@astryxdesign/core/Pagination'; import { Pagination } from '@astryxdesign/core/Pagination';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
@@ -39,6 +40,7 @@ import TableLoader from 'components/nearle_components/TableLoader';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar'; import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import PageHeader from 'components/nearle_components/PageHeader'; import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard'; import StatCard from 'components/nearle_components/StatCard';
import StatusBadge from 'components/nearle_components/StatusBadge';
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout'; import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
import { getAppUsers, createAppUser, updateAppUser, deleteAppUser } from 'pages/api/doormileApi'; import { getAppUsers, createAppUser, updateAppUser, deleteAppUser } from 'pages/api/doormileApi';
import { DT } from 'themes/dt/tokens'; import { DT } from 'themes/dt/tokens';
@@ -205,20 +207,25 @@ const AppUsers = () => {
{ {
id: 'name', id: 'name',
accessorKey: 'first_name', accessorKey: 'first_name',
size: 200, size: 260,
minSize: 150, minSize: 200,
meta: { label: 'Name' }, meta: { label: 'User' },
header: () => <SectionLabel>Name</SectionLabel>, header: () => <SectionLabel>User</SectionLabel>,
cell: ({ getValue }) => <Text weight="bold">{getValue()}</Text> cell: ({ row }) => (
}, <HStack gap={1.5} vAlign="center">
{ <AccentAvatar color={BRAND} size={28}>
id: 'email', <MdPerson size={14} />
accessorKey: 'email', </AccentAvatar>
size: 220, <VStack gap={0} padding={0} minWidth={0}>
minSize: 160, <Text weight="bold" maxLines={1} style={{ fontSize: 13, color: DT.textPrimary }}>
meta: { label: 'Email' }, {row.original.first_name} {row.original.last_name || ''}
header: () => <SectionLabel>Email</SectionLabel>, </Text>
cell: ({ getValue }) => getValue() <Text type="supporting" color="secondary" maxLines={1} style={{ fontSize: 11 }}>
{row.original.email}
</Text>
</VStack>
</HStack>
)
}, },
{ {
id: 'phone', id: 'phone',
@@ -227,16 +234,20 @@ const AppUsers = () => {
minSize: 110, minSize: 110,
meta: { label: 'Phone' }, meta: { label: 'Phone' },
header: () => <SectionLabel>Phone</SectionLabel>, header: () => <SectionLabel>Phone</SectionLabel>,
cell: ({ getValue }) => getValue() || '—' cell: ({ getValue }) => (
<Text type="supporting" weight="medium" style={{ fontSize: 12 }}>
{getValue() || '—'}
</Text>
)
}, },
{ {
id: 'role', id: 'role',
accessorFn: (row) => roleLabel(row.role), accessorFn: (row) => roleLabel(row.role),
size: 130, size: 140,
minSize: 100, minSize: 110,
meta: { label: 'Role' }, meta: { label: 'Role' },
header: () => <SectionLabel>Role</SectionLabel>, header: () => <SectionLabel>Role</SectionLabel>,
cell: ({ getValue }) => getValue() cell: ({ getValue }) => <Badge variant="neutral" label={getValue()} />
}, },
{ {
id: 'status', id: 'status',
@@ -245,7 +256,7 @@ const AppUsers = () => {
minSize: 100, minSize: 100,
meta: { label: 'Status' }, meta: { label: 'Status' },
header: () => <SectionLabel>Status</SectionLabel>, header: () => <SectionLabel>Status</SectionLabel>,
cell: ({ getValue }) => getValue() || '—' cell: ({ getValue }) => <StatusBadge status={getValue()} />
}, },
{ {
id: 'actions', id: 'actions',
@@ -312,8 +323,20 @@ const AppUsers = () => {
/> />
<KpiGrid> <KpiGrid>
<StatCard title="Total Staff" value={stats.total} icon={<MdOutlineAdminPanelSettings size={20} />} color={BRAND} /> <StatCard
<StatCard title="Admins" value={stats.admins} icon={<MdOutlineAdminPanelSettings size={20} />} color="#6366f1" /> title="Total Staff"
value={stats.total}
meta={`${stats.total} accounts`}
icon={<MdOutlineAdminPanelSettings size={20} />}
color={BRAND}
/>
<StatCard
title="Admins"
value={stats.admins}
meta={`${stats.total > 0 ? Math.round((stats.admins / stats.total) * 100) : 0}% of ${stats.total}`}
icon={<MdOutlineAdminPanelSettings size={20} />}
color="#6366f1"
/>
</KpiGrid> </KpiGrid>
<DataCard <DataCard

View File

@@ -717,6 +717,68 @@ body.dai-docked .astryx-layout-content {
box-shadow: 0 0 0 3px var(--dai-accent-ring); box-shadow: 0 0 0 3px var(--dai-accent-ring);
} }
/* --------------------------------------------------------------------------
Conversation history.
A list of rows, not cards: these are destinations, and dense rows scan
faster than boxes when you are looking for one you remember.
-------------------------------------------------------------------------- */
.dai-root .dai-history {
padding: 4px 0;
}
.dai-root .dai-history-row {
width: 100%;
border-radius: 10px;
padding-right: 4px;
transition: background-color 140ms ease;
}
.dai-root .dai-history-row:hover {
background: var(--dai-surface-alt);
}
.dai-root .dai-history-open {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
align-items: flex-start;
text-align: left;
padding: 9px 10px;
border: none;
background: transparent;
font: inherit;
cursor: pointer;
border-radius: 10px;
}
.dai-root .dai-history-open:focus-visible {
outline: 2px solid var(--dai-accent);
outline-offset: -2px;
}
.dai-root .dai-history-title {
font-size: 13px;
font-weight: 500;
color: var(--dai-text);
display: block;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dai-root .dai-history-meta {
font-size: 11px;
color: var(--dai-text-muted);
}
.dai-root .dai-history-empty-title {
font-size: 13.5px;
font-weight: 600;
color: var(--dai-text);
}
.dai-root .dai-history-empty {
font-size: 12.5px;
line-height: 1.5;
color: var(--dai-text-muted);
}
/* Microphone. /* Microphone.
Astryx's smallest dictation button is 28px, two short of the send button it Astryx's smallest dictation button is 28px, two short of the send button it

View File

@@ -60,10 +60,15 @@ const AIFlowStep = ({ step, onChoose, onCancel, draft, isBusy }) => {
if (!options.length) { if (!options.length) {
return ( return (
<VStack className="dai-action" gap={1.5} padding={0}> <VStack className="dai-action" gap={1.5} padding={0}>
{/* An empty list must still leave a way forward. The generic message
plus a lone Cancel is a dead end, and it is reached exactly when
something upstream failed — which is the worst moment to strip the
operator of options. A step that can also accept a typed answer
says so here via `emptyHint`. */}
<Text className="dai-field-err"> <Text className="dai-field-err">
{step.id === 'pickuplocationid' {step.id === 'pickuplocationid'
? 'This tenant has no saved business locations — add one on the Tenants page before creating an order.' ? 'This tenant has no saved business locations — add one on the Tenants page before creating an order.'
: 'There’s nothing to choose from here.'} : step.emptyHint || 'There’s nothing to choose from here.'}
</Text> </Text>
<Button label="Cancel" variant="secondary" size="sm" onClick={onCancel} /> <Button label="Cancel" variant="secondary" size="sm" onClick={onCancel} />
</VStack> </VStack>

View File

@@ -135,7 +135,7 @@ const AssistantMessage = ({ message, onCopy, onSubmitForm, onCancelAction, onCho
/> />
)} )}
{['confirmCustomer', 'confirmOrder', 'confirmBulk', 'confirmAssign', 'confirmBatchAssign'].includes(message.form?.kind) && ( {['confirmCustomer', 'confirmOrder', 'confirmBulk', 'confirmAssign'].includes(message.form?.kind) && (
<VStack className="dai-action" gap={1.5} padding={0}> <VStack className="dai-action" gap={1.5} padding={0}>
{message.form.status === 'open' ? ( {message.form.status === 'open' ? (
<HStack gap={1.5} padding={0}> <HStack gap={1.5} padding={0}>
@@ -149,8 +149,6 @@ const AssistantMessage = ({ message, onCopy, onSubmitForm, onCancelAction, onCho
? 'Create order' ? 'Create order'
: message.form.kind === 'confirmAssign' : message.form.kind === 'confirmAssign'
? 'Assign rider' ? 'Assign rider'
: message.form.kind === 'confirmBatchAssign'
? `Assign ${message.form.draft?.bookingIds?.length || 0} orders`
: 'Submit' : 'Submit'
} }
variant="primary" variant="primary"

View File

@@ -2,9 +2,10 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { CloseOutlined, MoreOutlined } from '@ant-design/icons'; import { CloseOutlined, MoreOutlined } from '@ant-design/icons';
import { LuChevronDown, LuPanelRightOpen, LuPanelRightClose } from 'react-icons/lu'; import { LuChevronDown, LuPanelRightOpen, LuPanelRightClose, LuHistory, LuTrash2, LuMessageSquare } from 'react-icons/lu';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
@@ -22,10 +23,10 @@ import { startCustomerFlow, answerStep } from '../customerFlow';
import { startOrderFlow, answerOrderStep } from '../orderFlow'; import { startOrderFlow, answerOrderStep } from '../orderFlow';
import { startBulkFlow, answerBulkStep, resolveBulkRows, lookupsNeeded, batchCount, GEOCODE_INTERVAL_MS } from '../bulkFlow'; import { startBulkFlow, answerBulkStep, resolveBulkRows, lookupsNeeded, batchCount, GEOCODE_INTERVAL_MS } from '../bulkFlow';
import { startAssignFlow, answerAssignStep } from '../assignFlow'; import { startAssignFlow, answerAssignStep } from '../assignFlow';
import { executeAssign, executeBatchAssign, describeRider } from '../assignActions'; import { executeAssign, executeRepeatAssign, describeRider } from '../assignActions';
import { startRepeatFlow, answerRepeatStep } from '../repeatFlow'; import { startRepeatFlow, answerRepeatStep } from '../repeatFlow';
import { buildRepeatRun, describeDay } from '../repeatRuns'; import { buildRepeatRun, describeDay } from '../repeatRuns';
import { getPageContext, getFollowUps, toChips } from './pageContext'; import { getPageContext, getFollowUps, toChips, ORDER_CREATED } from './pageContext';
import { Spark, LiveIndicator, TypingIndicator } from './AIParts'; import { Spark, LiveIndicator, TypingIndicator } from './AIParts';
import AIWelcome, { SuggestionChips } from './AIWelcome'; import AIWelcome, { SuggestionChips } from './AIWelcome';
import AIMessage from './AIMessage'; import AIMessage from './AIMessage';
@@ -46,6 +47,8 @@ import '../DoormileAI.css';
const ANIMATION_MS = 260; const ANIMATION_MS = 260;
const HISTORY_KEY = 'doormileBotHistory'; const HISTORY_KEY = 'doormileBotHistory';
const HISTORY_LIMIT = 50; const HISTORY_LIMIT = 50;
const CONVERSATIONS_KEY = 'doormileBotConversations';
const CONVERSATIONS_LIMIT = 25;
const PIN_THRESHOLD_PX = 48; const PIN_THRESHOLD_PX = 48;
// Dock widths. Each is a percentage of the viewport with a pixel floor — 25vw // Dock widths. Each is a percentage of the viewport with a pixel floor — 25vw
@@ -58,10 +61,37 @@ const DOCK_NORMAL = 'max(300px, 25vw)';
const DOCK_WIDE = 'max(340px, 30vw)'; const DOCK_WIDE = 'max(340px, 30vw)';
const WIDTH_KEY = 'doormileBotWidth'; const WIDTH_KEY = 'doormileBotWidth';
let nextId = 1; // Seeded from the clock, not from 1.
//
// Messages are keyed by this id, and a thread is restored from localStorage on
// mount — but the counter restarted at 1 every page load, so the first new
// message collided with the first restored one. React then logged "Encountered
// two children with the same key" (seen live, dozens of times) and is free to
// duplicate or drop either message; the warning says the behaviour is
// unsupported.
//
// Restoring an ARCHIVED conversation made it worse still: that injects a whole
// thread of old ids into the middle of a running count.
//
// A clock seed removes the class of bug rather than patching one path — every
// id issued now is larger than every id issued in any earlier session, so new
// and restored messages cannot occupy the same key.
let nextId = Date.now();
const makeId = () => nextId++; const makeId = () => nextId++;
const now = () => dayjs().format('hh:mm A'); const now = () => dayjs().format('hh:mm A');
// Reissues every id so a thread is internally unique, whatever it arrived
// with. Storage written before ids were clock-seeded holds runs like
// 1,2,3,4,5,6,7,1,2,3 — the counter restarted each session and wrote straight
// over itself — and React keys off these, so those threads log "two children
// with the same key" on every single mount until the data itself is repaired.
//
// Seeding the counter fixed new writes; this fixes what is already on disk.
// Ids only need to be unique within a session, so reissuing is safe: nothing
// persists a reference to one, and setFormStatus matches within the same
// render tree.
const withUniqueIds = (messages) => messages.map((m) => ({ ...m, id: makeId() }));
const loadHistory = () => { const loadHistory = () => {
try { try {
const raw = localStorage.getItem(HISTORY_KEY); const raw = localStorage.getItem(HISTORY_KEY);
@@ -71,10 +101,12 @@ const loadHistory = () => {
// last closed is retired on load. Otherwise a Create button could sit in // last closed is retired on load. Otherwise a Create button could sit in
// restored history for days and fire against data the operator can no // restored history for days and fire against data the operator can no
// longer see — the proposal must be regenerated against current state. // longer see — the proposal must be regenerated against current state.
return parsed.map((m) => { return withUniqueIds(
const next = m.flowStep ? { ...m, flowStep: undefined } : m; parsed.map((m) => {
return next.form?.status === 'open' ? { ...next, form: { ...next.form, status: 'stale' } } : next; const next = m.flowStep ? { ...m, flowStep: undefined } : m;
}); return next.form?.status === 'open' ? { ...next, form: { ...next.form, status: 'stale' } } : next;
})
);
} catch { } catch {
return []; return [];
} }
@@ -88,13 +120,84 @@ const saveHistory = (messages) => {
} }
}; };
// ---------------------------------------------------------------------------
// Past conversations.
//
// HISTORY_KEY holds the CURRENT thread and is overwritten on every message.
// Before this existed, "New conversation" cleared `messages`, that save effect
// fired, and the previous conversation was gone — permanently, with no warning
// and nothing to restore. Archiving first is what makes starting a new chat a
// safe thing to do rather than a destructive one.
// ---------------------------------------------------------------------------
const loadConversations = () => {
try {
const raw = localStorage.getItem(CONVERSATIONS_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
const saveConversations = (list) => {
try {
localStorage.setItem(CONVERSATIONS_KEY, JSON.stringify(list.slice(0, CONVERSATIONS_LIMIT)));
} catch {
/* private mode / quota — archives just won't persist */
}
};
// A conversation is named after the first thing the operator asked. That is
// the only line in a thread that reliably says what it was about — the
// assistant's own first reply is usually a number with no subject.
const conversationTitle = (messages) => {
const firstAsk = messages.find((m) => m.sender === 'user' && m.text);
const raw = (firstAsk?.text || 'Untitled conversation').trim();
return raw.length > 58 ? `${raw.slice(0, 57)}…` : raw;
};
const AIPanel = ({ isOpen, onClose }) => { const AIPanel = ({ isOpen, onClose }) => {
const queryClient = useQueryClient();
// The bot writes through its own calls, so the PAGE behind it keeps serving
// whatever TanStack cached before the write. Assign a rider from here and the
// API is correct immediately, while the Deliveries table still shows the
// booking as undispatched — that page selects on `assignedmileruserid`, which
// has just changed underneath it. Reported as "it assigned but Deliveries
// doesn't show it"; the assignment was real, the table was stale.
//
// Invalidated broadly and deliberately. Naming each page's keys here would
// couple the assistant to every table's cache layout and rot the first time
// one changes; TanStack only refetches ACTIVE queries at once, so in practice
// this refreshes the page the operator is looking at and marks the rest
// stale. CLAUDE.md §7 asks for .refetch() after a mutation, but that only
// reaches queries the same component owns — a cross-page write needs the
// client.
const refreshPageData = useCallback(() => {
queryClient.invalidateQueries();
}, [queryClient]);
const { pathname } = useLocation(); const { pathname } = useLocation();
const pageContext = getPageContext(pathname); const pageContext = getPageContext(pathname);
const [isMounted, setIsMounted] = useState(isOpen); const [isMounted, setIsMounted] = useState(isOpen);
const [isShown, setIsShown] = useState(false); const [isShown, setIsShown] = useState(false);
const [messages, setMessages] = useState(loadHistory); const [messages, setMessages] = useState(loadHistory);
const [conversations, setConversations] = useState(loadConversations);
// 'chat' | 'history'. History is a STATE of the panel, not a route: the
// header and composer stay exactly where they are, so it is somewhere you
// look rather than somewhere you navigate to and have to find a way out of.
const [view, setView] = useState('chat');
// Set when a write SUCCEEDS, so the follow-up strip can offer what comes
// after an order exists rather than what comes after asking to make one.
// Cleared the moment anything else is asked — it describes the last thing
// that HAPPENED, not a standing preference.
const [outcomeKey, setOutcomeKey] = useState(null);
// The orders the LAST write produced. Held so that "Assign order" means
// "assign the ones we just made" rather than "name an order for me".
// { ids: string[], booking: object|null }
// `booking` is only populated for a single create, where the operator picks
// the rider by hand; a batch goes to the optimiser-backed endpoint instead.
const [lastCreated, setLastCreated] = useState({ ids: [], booking: null });
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const [isSending, setIsSending] = useState(false); const [isSending, setIsSending] = useState(false);
const [context, setContext] = useState({ lastIntentId: null, lastParams: null }); const [context, setContext] = useState({ lastIntentId: null, lastParams: null });
@@ -396,7 +499,14 @@ const AIPanel = ({ isOpen, onClose }) => {
meta: r.deliveryaddress meta: r.deliveryaddress
})) }))
}, },
detail: `Creating them again produces a SECOND set of orders to the same customers — a second real rider to each door. Only do it if that is what you mean.`, detail: [
`Creating them again produces a SECOND set of orders to the same customers — a second real rider to each door. Only do it if that is what you mean.`,
run.already.some((r) => r.__previousMilerUserId != null)
? 'They are also dispatched straight back to their previous riders, so those riders get the same drops twice.'
: null
]
.filter(Boolean)
.join(' '),
form: { form: {
kind: 'confirmBulk', kind: 'confirmBulk',
status: 'open', status: 'open',
@@ -448,7 +558,17 @@ const AIPanel = ({ isOpen, onClose }) => {
run.unpriced.length ? `${run.unpriced.length} couldn’t be priced at today’s tariff.` : null, run.unpriced.length ? `${run.unpriced.length} couldn’t be priced at today’s tariff.` : null,
run.changed.length run.changed.length
? `${run.changed.length} price${run.changed.length === 1 ? '' : 's'} changed since ${describeDay(draft.day)}.` ? `${run.changed.length} price${run.changed.length === 1 ? '' : 's'} changed since ${describeDay(draft.day)}.`
: null : null,
// The confirm button now does two things, so it has to say two
// things. Creating and then silently dispatching to real riders is
// more than "create" promises.
(() => {
const withRider = run.valid.filter((r) => r.__previousMilerUserId != null).length;
if (!withRider) return null;
return withRider === run.valid.length
? `All ${withRider} go straight back to the rider who ran them on ${describeDay(draft.day)} — no separate assign step.`
: `${withRider} of ${run.valid.length} go straight back to ${describeDay(draft.day)}’s rider; the rest stay pending.`;
})()
] ]
.filter(Boolean) .filter(Boolean)
.join(' '), .join(' '),
@@ -593,6 +713,7 @@ const AIPanel = ({ isOpen, onClose }) => {
lastQuestionRef.current = question; lastQuestionRef.current = question;
setValue(''); setValue('');
setIsPinned(true); setIsPinned(true);
setOutcomeKey(null);
setMessages((prev) => [...prev, { id: makeId(), sender: 'user', text: question, timestamp: now() }]); setMessages((prev) => [...prev, { id: makeId(), sender: 'user', text: question, timestamp: now() }]);
setIsSending(true); setIsSending(true);
@@ -685,7 +806,25 @@ const AIPanel = ({ isOpen, onClose }) => {
} }
if (result.form?.kind === 'assignRider') { if (result.form?.kind === 'assignRider') {
const started = await startAssignFlow(result.form.booking); // "Assign a rider" with no order named in the sentence used to go
// straight to "Which order? Give me its number" — which, right
// after creating thirteen of them, is the assistant asking the
// operator to repeat something it just did itself.
//
// If the last write is still the most recent thing that happened,
// that IS the answer to "which order". Only fall through to asking
// when the question named no order AND nothing recent was created.
// Every assignment goes through the same two questions: which
// order, then which rider. There used to be a bulk shortcut here
// for a freshly created run, but it routed through the hub console
// — an endpoint this console's logins are not granted — so it read
// as the fast path and then refused. One flow that works beats two
// where the quicker one usually cannot run.
//
// A run of exactly one still skips the first question: the order is
// not in doubt, so asking which one would be theatre.
const target = result.form.booking || (lastCreated.ids.length === 1 ? lastCreated.booking : null);
const started = await startAssignFlow(target);
setFlow(started.flow); setFlow(started.flow);
await renderFlowStep(started, finishAssign); await renderFlowStep(started, finishAssign);
setIsSending(false); setIsSending(false);
@@ -773,7 +912,11 @@ const AIPanel = ({ isOpen, onClose }) => {
setIsSending(false); setIsSending(false);
} }
}, },
[context, isSending, flow, renderFlowStep, finishOrder, finishBulk, finishAssign, finishRepeat, push] // `lastCreated` belongs here. Without it this callback closes over the
// value from the render in which it was created — the empty array — so the
// batch shortcut would look at stale state and never fire, no matter how
// many orders had just been made.
[context, isSending, flow, renderFlowStep, finishOrder, finishBulk, finishAssign, finishRepeat, push, lastCreated]
); );
const copyMessage = useCallback((m) => { const copyMessage = useCallback((m) => {
@@ -784,6 +927,10 @@ const AIPanel = ({ isOpen, onClose }) => {
.catch(() => OpenToast('Could not copy', 'error', 1500)); .catch(() => OpenToast('Could not copy', 'error', 1500));
}, []); }, []);
// Wipes the thread without keeping it. The menu labels this destructively
// ("Delete this conversation") because that is what it does — it used to be
// offered as "New conversation" too, so the safe-sounding option and the
// destructive one ran the same code.
const clearConversation = useCallback(() => { const clearConversation = useCallback(() => {
setMessages([]); setMessages([]);
setContext({ lastIntentId: null, lastParams: null }); setContext({ lastIntentId: null, lastParams: null });
@@ -792,6 +939,59 @@ const AIPanel = ({ isOpen, onClose }) => {
setIsPinned(true); setIsPinned(true);
}, []); }, []);
// Archive-then-clear. Returns the new archive so callers that also want to
// load something can work from the updated list rather than stale state.
const archiveCurrent = useCallback(() => {
let next = conversations;
if (messages.length > 0) {
next = [{ id: `c${Date.now()}`, title: conversationTitle(messages), at: dayjs().toISOString(), messages }, ...conversations].slice(
0,
CONVERSATIONS_LIMIT
);
setConversations(next);
saveConversations(next);
}
return next;
}, [conversations, messages]);
const newConversation = useCallback(() => {
archiveCurrent();
clearConversation();
setView('chat');
}, [archiveCurrent, clearConversation]);
// Opening a past conversation archives whatever is on screen first, then
// lifts the chosen one OUT of the archive and makes it current — so a thread
// is never in two places at once, and reopening one does not silently
// duplicate it every time.
const openConversation = useCallback(
(id) => {
const archived = archiveCurrent();
const found = archived.find((c) => c.id === id);
if (!found) return;
const remaining = archived.filter((c) => c.id !== id);
setConversations(remaining);
saveConversations(remaining);
// Archived threads were written by the same broken counter.
setMessages(withUniqueIds(found.messages));
setContext({ lastIntentId: null, lastParams: null });
setFlow(null);
setView('chat');
pinnedRef.current = true;
setIsPinned(true);
},
[archiveCurrent]
);
const deleteConversation = useCallback(
(id) => {
const next = conversations.filter((c) => c.id !== id);
setConversations(next);
saveConversations(next);
},
[conversations]
);
const setFormStatus = useCallback( const setFormStatus = useCallback(
(id, patch) => setMessages((prev) => prev.map((m) => (m.id === id ? { ...m, form: { ...m.form, ...patch } } : m))), (id, patch) => setMessages((prev) => prev.map((m) => (m.id === id ? { ...m, form: { ...m.form, ...patch } } : m))),
[] []
@@ -809,13 +1009,19 @@ const AIPanel = ({ isOpen, onClose }) => {
const { booking, __rider: rider } = message.form.draft; const { booking, __rider: rider } = message.form.draft;
const res = await executeAssign(booking, rider); const res = await executeAssign(booking, rider);
if (res.ok) { if (res.ok) {
refreshPageData();
setFormStatus(message.id, { status: 'done' }); setFormStatus(message.id, { status: 'done' });
setFlow(null); setFlow(null);
push({ push({
sender: 'assistant', sender: 'assistant',
text: `${res.bookingLabel} is now with ${describeRider(rider)}.`, text: `${res.bookingLabel} is now with ${describeRider(rider)}.`,
// Three outcomes, not two. The old copy assumed a failed push could
// only mean "no profile id", so a notify that returned 400 was
// reported as a successful notification. Say which one it was.
detail: res.notified detail: res.notified
? 'The rider has been notified.' ? 'The rider has been notified.'
: rider?.milerprofileid
? 'The order is assigned, but the notification failed — tell the rider yourself. See Sources.'
: 'The assignment went through, but this rider has no profile id so no notification was sent.', : 'The assignment went through, but this rider has no profile id so no notification was sent.',
sourceCalls: res.sourceCalls sourceCalls: res.sourceCalls
}); });
@@ -834,42 +1040,20 @@ const AIPanel = ({ isOpen, onClose }) => {
// //
// batch-assign, never N single assigns: it is the only call that sequences // batch-assign, never N single assigns: it is the only call that sequences
// each rider's stops afterwards (doormile-flow.md §4). // each rider's stops afterwards (doormile-flow.md §4).
if (message.form.kind === 'confirmBatchAssign') {
const ids = message.form.draft.bookingIds;
const res = await executeBatchAssign(ids);
if (res.ok) {
setFormStatus(message.id, { status: 'done' });
push({
sender: 'assistant',
text: `Assigned ${res.assigned} of ${ids.length} order${ids.length === 1 ? '' : 's'}.`,
detail: [
res.sequenced ? `${res.sequenced} rider route${res.sequenced === 1 ? '' : 's'} sequenced by the optimiser.` : null,
res.notified ? `${res.notified} rider${res.notified === 1 ? '' : 's'} notified.` : null,
res.skipped ? `${res.skipped} could not be assigned — no rider in range, most likely.` : null
]
.filter(Boolean)
.join(' '),
sourceCalls: res.sourceCalls
});
} else {
setFormStatus(message.id, { status: 'failed', error: res.message });
push({
sender: 'assistant',
state: { tone: STATUS.error, iconKey: 'warning', title: 'Nothing was assigned', body: res.message },
sourceCalls: res.sourceCalls
});
}
return;
}
// ---- conversational order: submit what the chat gathered ------------- // ---- conversational order: submit what the chat gathered -------------
if (message.form.kind === 'confirmOrder') { if (message.form.kind === 'confirmOrder') {
const draft = message.form.draft; const draft = message.form.draft;
const payload = buildOrderPayload(draft, draft.__pickup); const payload = buildOrderPayload(draft, draft.__pickup);
const res = await executeCreateOrder(payload); const res = await executeCreateOrder(payload);
if (res.ok) { if (res.ok) {
refreshPageData();
setFormStatus(message.id, { status: 'done' }); setFormStatus(message.id, { status: 'done' });
setFlow(null); setFlow(null);
// The order now EXISTS, so assigning a rider becomes the next step.
// The auto-hand-off below covers the normal path; this covers the
// case where it cannot run (no bookingid came back) and gives the
// operator the same step as a chip instead of a dead end.
setOutcomeKey(ORDER_CREATED);
push({ push({
sender: 'assistant', sender: 'assistant',
text: `Created order ${res.bookingno || res.id || ''}.`.trim(), text: `Created order ${res.bookingno || res.id || ''}.`.trim(),
@@ -883,6 +1067,7 @@ const AIPanel = ({ isOpen, onClose }) => {
// replacing them rather than silently overwriting a proximity-scored // replacing them rather than silently overwriting a proximity-scored
// choice. // choice.
const booking = { bookingid: res.id, bookingno: res.bookingno, ...(res.created || {}) }; const booking = { bookingid: res.id, bookingno: res.bookingno, ...(res.created || {}) };
if (booking.bookingid) setLastCreated({ ids: [booking.bookingid], booking });
if (booking.bookingid) { if (booking.bookingid) {
const started = await startAssignFlow(booking); const started = await startAssignFlow(booking);
setFlow(started.flow); setFlow(started.flow);
@@ -904,6 +1089,7 @@ const AIPanel = ({ isOpen, onClose }) => {
try { try {
const res = await executeCreateCustomer(message.form.payload); const res = await executeCreateCustomer(message.form.payload);
if (res.ok) { if (res.ok) {
refreshPageData();
setFormStatus(message.id, { status: 'done' }); setFormStatus(message.id, { status: 'done' });
setFlow(null); setFlow(null);
push({ push({
@@ -936,6 +1122,9 @@ const AIPanel = ({ isOpen, onClose }) => {
// alternative that includes today's existing rows. // alternative that includes today's existing rows.
const { rows, invalid, ...shared } = values?.rows ? values : message.form.draft; const { rows, invalid, ...shared } = values?.rows ? values : message.form.draft;
const res = await executeCreateBulk(rows, shared); const res = await executeCreateBulk(rows, shared);
// Unconditional: a bulk import is normally PARTIAL, so there is no
// res.ok to gate on, and any row that landed has changed both tables.
refreshPageData();
// A bulk import is normally a PARTIAL result, so the outcome is reported // A bulk import is normally a PARTIAL result, so the outcome is reported
// per row rather than as a single success/failure. Without that, the only // per row rather than as a single success/failure. Without that, the only
// safe response to any error is to re-submit everything — which // safe response to any error is to re-submit everything — which
@@ -944,6 +1133,26 @@ const AIPanel = ({ isOpen, onClose }) => {
status: res.failed && !res.created ? 'failed' : 'done', status: res.failed && !res.created ? 'failed' : 'done',
error: res.failed ? `${res.failed} row(s) failed` : undefined error: res.failed ? `${res.failed} row(s) failed` : undefined
}); });
// Partial success still means orders exist and need riders. Only a run
// where nothing landed leaves nothing to assign.
if (res.created > 0) setOutcomeKey(ORDER_CREATED);
// A repeat carries yesterday's rider on each row, so the new copies go
// straight back to whoever ran them — no second question. Rows with no
// previous rider simply stay pending, and a CSV import has none at all,
// so this is a no-op there rather than a special case.
const repeatAssign = rows.some((r) => r.__previousMilerUserId != null)
? await executeRepeatAssign(res.createdPairs, rows)
: null;
if (repeatAssign?.assigned) refreshPageData();
// Remember exactly which ones, so "Assign order" can act on them —
// minus any that just got a rider automatically.
const stillUnassigned = repeatAssign
? (res.createdPairs || [])
.filter(({ index }) => rows?.[index]?.__previousMilerUserId == null)
.map((p) => p.bookingid)
: res.createdIds;
if (stillUnassigned?.length) setLastCreated({ ids: stillUnassigned, booking: null });
push({ push({
sender: 'assistant', sender: 'assistant',
text: `Created ${res.created} of ${rows.length} order${rows.length === 1 ? '' : 's'}.`, text: `Created ${res.created} of ${rows.length} order${rows.length === 1 ? '' : 's'}.`,
@@ -954,28 +1163,34 @@ const AIPanel = ({ isOpen, onClose }) => {
items: res.failures.map((f) => ({ label: rows[f.index]?.customer_name || `Row ${f.index + 1}`, meta: f.reason })) items: res.failures.map((f) => ({ label: rows[f.index]?.customer_name || `Row ${f.index + 1}`, meta: f.reason }))
} }
: undefined, : undefined,
detail: detail: [
res.failed === 0 res.failed === 0
? `All rows submitted${res.batches > 1 ? ` in ${res.batches} batches` : ''}. They appear on the Orders page under Pending.` ? `All rows submitted${res.batches > 1 ? ` in ${res.batches} batches` : ''}.`
: `${res.created} landed and ${res.failed} did not. Only the failed rows need re-submitting — re-sending everything would duplicate the ones that worked.`, : `${res.created} landed and ${res.failed} did not. Only the failed rows need re-submitting — re-sending everything would duplicate the ones that worked.`,
// Stated plainly, including what was NOT done. An operator who
// believes all 13 went back to their riders will not check the
// three that stayed pending.
repeatAssign?.assigned
? `${repeatAssign.assigned} went straight back to yesterday's rider across ${repeatAssign.riders} rider${
repeatAssign.riders === 1 ? '' : 's'
}, notified automatically.`
: null,
repeatAssign?.skipped ? `${repeatAssign.skipped} had no rider yesterday and are waiting for one.` : null,
repeatAssign?.failures?.length
? `${repeatAssign.failures.length} could not be re-assigned — that rider may no longer be active.`
: null,
// Sequencing is what the hub batch endpoint would have added.
repeatAssign?.assigned ? 'Stop order is unchanged from yesterday, not re-optimised.' : null
]
.filter(Boolean)
.join(' '),
// Rejected rows plus anything the check had already set aside, in the // Rejected rows plus anything the check had already set aside, in the
// template's own columns, so they can be fixed and re-uploaded // template's own columns, so they can be fixed and re-uploaded
// instead of re-sending a file that would double-book what landed. // instead of re-sending a file that would double-book what landed.
downloadRows: [...res.failures.map((f) => ({ ...rows[f.index], error: f.reason })), ...(invalid || [])], downloadRows: [...res.failures.map((f) => ({ ...rows[f.index], error: f.reason })), ...(invalid || [])],
sourceCalls: res.sourceCalls sourceCalls: [...(res.sourceCalls || []), ...(repeatAssign?.sourceCalls || [])]
}); });
// Only offered when the endpoint actually returned the ids. No ids means
// "cannot offer assignment" — never "nothing was created".
if (res.createdIds?.length) {
push({
sender: 'assistant',
text: `Assign ${res.createdIds.length} of them now?`,
detail:
'Riders are picked by the backend and each one’s stops are sequenced by the route optimiser — that only happens through the batch endpoint, not by assigning one at a time.',
form: { kind: 'confirmBatchAssign', status: 'open', draft: { bookingIds: res.createdIds } }
});
}
setFlow(null); setFlow(null);
return; return;
} }
@@ -987,7 +1202,13 @@ const AIPanel = ({ isOpen, onClose }) => {
const payload = buildOrderPayload(values, values.__pickup); const payload = buildOrderPayload(values, values.__pickup);
const res = await executeCreateOrder(payload); const res = await executeCreateOrder(payload);
if (res.ok) { if (res.ok) {
refreshPageData();
setFormStatus(message.id, { status: 'done' }); setFormStatus(message.id, { status: 'done' });
// Same as the confirmOrder branch: the order exists, so the next
// step is a rider. This path has no auto-hand-off at all, which is
// exactly why the chip matters here.
setOutcomeKey(ORDER_CREATED);
if (res.id) setLastCreated({ ids: [res.id], booking: { bookingid: res.id, bookingno: res.bookingno } });
push({ push({
sender: 'assistant', sender: 'assistant',
text: `Created order ${res.bookingno || res.id || ''}.`.trim(), text: `Created order ${res.bookingno || res.id || ''}.`.trim(),
@@ -1017,6 +1238,7 @@ const AIPanel = ({ isOpen, onClose }) => {
const payload = buildCustomerPayload(values); const payload = buildCustomerPayload(values);
const res = await executeCreateCustomer(payload); const res = await executeCreateCustomer(payload);
if (res.ok) { if (res.ok) {
refreshPageData();
setFormStatus(message.id, { status: 'done' }); setFormStatus(message.id, { status: 'done' });
push({ push({
sender: 'assistant', sender: 'assistant',
@@ -1132,7 +1354,7 @@ const AIPanel = ({ isOpen, onClose }) => {
// 2. the intent's own follow-ups, for a TYPED question the map doesn't // 2. the intent's own follow-ups, for a TYPED question the map doesn't
// cover — this is what the in-message chips used to render // cover — this is what the in-message chips used to render
// 3. this page's own suggestions, so the strip is never empty // 3. this page's own suggestions, so the strip is never empty
const followUps = hasThread ? getFollowUps(lastAsked) : []; const followUps = hasThread ? getFollowUps(outcomeKey || lastAsked) : [];
const intentFollowUps = hasThread && followUps.length === 0 ? toChips(lastAnswerFollowUps) : []; const intentFollowUps = hasThread && followUps.length === 0 ? toChips(lastAnswerFollowUps) : [];
const nextSteps = followUps.length > 0 ? followUps : intentFollowUps; const nextSteps = followUps.length > 0 ? followUps : intentFollowUps;
const suggestionItems = hasThread const suggestionItems = hasThread
@@ -1190,6 +1412,15 @@ const AIPanel = ({ isOpen, onClose }) => {
close button is ambiguous (collapse? navigate? close?), close button is ambiguous (collapse? navigate? close?),
whereas these draw the side panel itself getting wider or whereas these draw the side panel itself getting wider or
narrower, which is literally what the control does. */} narrower, which is literally what the control does. */}
<IconButton
size="sm"
variant="ghost"
label={view === 'history' ? 'Back to the conversation' : 'Conversation history'}
tooltip={view === 'history' ? 'Back to the conversation' : 'History'}
aria-pressed={view === 'history'}
icon={view === 'history' ? <LuMessageSquare /> : <LuHistory />}
onClick={() => setView((v) => (v === 'history' ? 'chat' : 'history'))}
/>
<IconButton <IconButton
size="sm" size="sm"
variant="ghost" variant="ghost"
@@ -1203,8 +1434,12 @@ const AIPanel = ({ isOpen, onClose }) => {
hasChevron={false} hasChevron={false}
button={{ label: 'Conversation options', icon: <MoreOutlined />, isIconOnly: true, variant: 'ghost', size: 'sm' }} button={{ label: 'Conversation options', icon: <MoreOutlined />, isIconOnly: true, variant: 'ghost', size: 'sm' }}
items={[ items={[
{ label: 'New conversation', onClick: clearConversation }, // These were both wired to clearConversation, so the safe
{ label: 'Clear conversation', onClick: clearConversation } // option and the destructive one did the same thing. Now
// "New" keeps the thread and "Delete" says that it does not.
{ label: 'New conversation', onClick: newConversation },
{ label: 'Conversation history', onClick: () => setView('history') },
{ label: 'Delete this conversation', onClick: clearConversation }
]} ]}
/> />
<IconButton <IconButton
@@ -1225,7 +1460,38 @@ const AIPanel = ({ isOpen, onClose }) => {
{/* ---- conversation / welcome ---- */} {/* ---- conversation / welcome ---- */}
<VStack className="dai-scroll-wrap" gap={0} padding={0}> <VStack className="dai-scroll-wrap" gap={0} padding={0}>
<VStack ref={scrollRef} className="dai-scroll" gap={0} padding={0} onScroll={handleScroll}> <VStack ref={scrollRef} className="dai-scroll" gap={0} padding={0} onScroll={handleScroll}>
{hasThread ? ( {view === 'history' ? (
<VStack className="dai-history" gap={2} padding={0}>
{conversations.length === 0 ? (
<VStack gap={1} padding={0}>
<Text className="dai-history-empty-title">No past conversations yet</Text>
<Text className="dai-history-empty">
Starting a new conversation keeps the current one here, so you can come back to it.
</Text>
</VStack>
) : (
conversations.map((c) => (
<HStack key={c.id} className="dai-history-row" gap={2} padding={0} vAlign="center">
<button type="button" className="dai-history-open" onClick={() => openConversation(c.id)}>
<Text className="dai-history-title">{c.title}</Text>
<Text className="dai-history-meta">
{dayjs(c.at).format('DD MMM · hh:mm A')} · {c.messages.length} message
{c.messages.length === 1 ? '' : 's'}
</Text>
</button>
<IconButton
size="sm"
variant="ghost"
label={`Delete conversation: ${c.title}`}
tooltip="Delete"
icon={<LuTrash2 />}
onClick={() => deleteConversation(c.id)}
/>
</HStack>
))
)}
</VStack>
) : hasThread ? (
<VStack className="dai-thread" gap={4} padding={0}> <VStack className="dai-thread" gap={4} padding={0}>
{messages.map((m) => ( {messages.map((m) => (
<AIMessage <AIMessage
@@ -1270,7 +1536,7 @@ const AIPanel = ({ isOpen, onClose }) => {
conversation the strip drops to this page's four primary conversation the strip drops to this page's four primary
suggestions — twenty chips permanently above the composer would suggestions — twenty chips permanently above the composer would
claim ~260px of a panel whose whole job is the conversation. */} claim ~260px of a panel whose whole job is the conversation. */}
{suggestionItems.length > 0 && ( {view === 'chat' && suggestionItems.length > 0 && (
<VStack className="dai-suggestions-bar" data-compact={isCompactStrip} gap={1} padding={0}> <VStack className="dai-suggestions-bar" data-compact={isCompactStrip} gap={1} padding={0}>
<Text className="dai-section-label"> <Text className="dai-section-label">
{hasThread ? (nextSteps.length > 0 ? 'Next steps' : 'Ask something else') : 'Suggested questions'} {hasThread ? (nextSteps.length > 0 ? 'Next steps' : 'Ask something else') : 'Suggested questions'}

View File

@@ -100,8 +100,12 @@ const AIRowsStep = ({ onSubmit, onCancel, isBusy }) => {
</Text> </Text>
)} )}
{parsed.errors.slice(0, 5).map((e) => ( {/* Keyed by line AND index: one row can fail several validations, so
<Text key={e.line} className="dai-field-err"> `line` alone is not unique and React logged "two children with the
same key" for every repeat — seen live as a 1/2/3 cycle. Same
shape the list renderer in AIParts already uses. */}
{parsed.errors.slice(0, 5).map((e, i) => (
<Text key={`${e.line}-${i}`} className="dai-field-err">
Line {e.line}: {e.reason} Line {e.line}: {e.reason}
</Text> </Text>
))} ))}

View File

@@ -65,7 +65,7 @@ export const CHIP_LABELS = {
'How many orders this week?': 'Orders this week', 'How many orders this week?': 'Orders this week',
'Total revenue this week': 'Revenue this week', 'Total revenue this week': 'Revenue this week',
'How many customers do we have?': 'Customers', 'How many customers do we have?': 'Customers',
'Assign a rider': 'Assign rider', 'Assign a rider': 'Assign order',
"Repeat yesterday's orders": 'Repeat yesterday' "Repeat yesterday's orders": 'Repeat yesterday'
}; };
@@ -81,6 +81,13 @@ const ASSIGN_TEXT = 'Assign a rider';
// blank create form. // blank create form.
const REPEAT_TEXT = "Repeat yesterday's orders"; const REPEAT_TEXT = "Repeat yesterday's orders";
// Not a question — an OUTCOME. AIPanel sets this as the follow-up key when a
// create or repeat run actually succeeds, so the next steps offered are the
// ones that follow from an order existing, rather than the ones that follow
// from having asked to make one. Assigning a rider only makes sense on this
// side of the write.
export const ORDER_CREATED = '__orderCreated';
const ORDERS = { const ORDERS = {
label: 'Orders', label: 'Orders',
suggestions: [ suggestions: [
@@ -215,12 +222,14 @@ const FOLLOW_UPS = {
// duplicate a step the product performs on its own or fire it too early. // duplicate a step the product performs on its own or fire it too early.
// //
// ASSIGN_TEXT belongs on the questions below that surface orders which // ASSIGN_TEXT belongs on the questions below that surface orders which
// already EXIST and are waiting for a rider. // already EXIST and are waiting for a rider — and on ORDER_CREATED, which is
// keyed off the write succeeding rather than off anything that was asked.
'Create an order': [REPEAT_TEXT, 'How many pending orders today?', 'Which orders are delayed?', 'Create multiple orders'], 'Create an order': [REPEAT_TEXT, 'How many pending orders today?', 'Which orders are delayed?', 'Create multiple orders'],
'Create multiple orders': ['How many pending orders today?', 'How many orders today?'], 'Create multiple orders': ['How many pending orders today?', 'How many orders today?'],
[REPEAT_TEXT]: ['How many pending orders today?', 'How many orders today?', 'Orders today vs yesterday'], [REPEAT_TEXT]: ['How many pending orders today?', 'How many orders today?', 'Orders today vs yesterday'],
'Create a customer': ['Create an order', 'How many customers do we have?'], 'Create a customer': ['Create an order', 'How many customers do we have?'],
[ASSIGN_TEXT]: ['How many pending orders today?', 'Which orders are delayed?', 'How many riders are active?'], [ASSIGN_TEXT]: ['How many pending orders today?', 'Which orders are delayed?', 'How many riders are active?'],
[ORDER_CREATED]: [ASSIGN_TEXT, 'How many pending orders today?', 'Which orders are delayed?', 'Create an order'],
// --- order volume --- // --- order volume ---
"Give me today's operations summary": [ "Give me today's operations summary": [

View File

@@ -1,4 +1,4 @@
import { getMilers, assignMilerToBooking, batchAssignBookings } from 'pages/api/doormileApi'; import { getMilers, assignMilerToBooking } from 'pages/api/doormileApi';
import { buildMilerLookup, notifyRider } from 'pages/api/api'; import { buildMilerLookup, notifyRider } from 'pages/api/api';
// ==============================|| Doormile AI — assigning a rider ||============================== // // ==============================|| Doormile AI — assigning a rider ||============================== //
@@ -95,9 +95,22 @@ export const executeAssign = async (booking, rider) => {
// CLAUDE.md §9: any mutation that affects a rider is followed by a push. // CLAUDE.md §9: any mutation that affects a rider is followed by a push.
// It is deliberately NOT allowed to fail the assignment — the order IS // It is deliberately NOT allowed to fail the assignment — the order IS
// assigned at this point, and reporting otherwise would be a lie. // assigned at this point, and reporting otherwise would be a lie.
// Whether the push ACTUALLY went out, not whether it could have been
// attempted. This used to be reported as `Boolean(rider.milerprofileid)`
// — i.e. "this rider has an id, so assume they were told" — which is a
// different claim entirely. Caught live: notify returned 400 and the
// assistant still said "The rider has been notified."
//
// The assignment itself is unaffected; it had already landed. But a
// dispatcher who believes a rider was pinged does not follow up, and this
// bot's whole contract is that it never states something it has not
// confirmed.
let notified = false;
if (rider.milerprofileid) { if (rider.milerprofileid) {
try { try {
await notifyRider(rider.milerprofileid); await notifyRider(rider.milerprofileid);
notified = true;
sourceCalls.push({ sourceCalls.push({
name: 'notifyRider', name: 'notifyRider',
target: `POST /admin/milers/${rider.milerprofileid}/notify`, target: `POST /admin/milers/${rider.milerprofileid}/notify`,
@@ -123,7 +136,7 @@ export const executeAssign = async (booking, rider) => {
}); });
} }
return { ok: true, rider, bookingLabel, notified: Boolean(rider.milerprofileid), sourceCalls }; return { ok: true, rider, bookingLabel, notified, sourceCalls };
} catch (err) { } catch (err) {
// doormileAxios rejects with the response BODY; the status rides on // doormileAxios rejects with the response BODY; the status rides on
// err.httpStatus. // err.httpStatus.
@@ -141,74 +154,99 @@ export const executeAssign = async (booking, rider) => {
} }
}; };
// ---- many orders ------------------------------------------------------------ // ---- repeat a run, keeping each order with the rider who ran it last --------
// //
// The backend picks the riders here, not the operator: batch-assign spreads the // A repeated run is the SAME drops to the SAME doors. The rider who did them
// set across riders within range and then sequences each one's stops. Its reply // yesterday already knows the buzzer, the gate code and which side of the
// is per-booking, so a partial result is reported per row rather than as a bare // building to park on, so re-deriving an assignment from scratch throws away
// success. // the one piece of routing knowledge the previous day produced.
export const executeBatchAssign = async (bookingIds, maxPerRider = 5) => { //
// Deliberately built on /admin/bookings/:id/assign-miler, one call per order,
// rather than the hub batch endpoint: batch-assign lets the BACKEND choose
// riders, which is the opposite of the intent here, and it is refused to every
// non-hub login anyway (403, confirmed live).
//
// The trade-off this accepts: assigning individually does not sequence a
// rider's stops. Yesterday's run was already sequenced for these same drops,
// so the ordering is not arbitrary — but it is not recomputed either, and the
// caller states that rather than implying a fresh optimisation.
export const executeRepeatAssign = async (createdPairs, rows) => {
const started = Date.now(); const started = Date.now();
const call = {
name: 'batchAssignBookings',
target: 'POST /hub/bookings/batch-assign',
stats: `${bookingIds.length} order${bookingIds.length === 1 ? '' : 's'}`
};
try { // Only rows whose source order actually had a rider. A blank one is not a
const res = await batchAssignBookings(bookingIds, maxPerRider); // failure — yesterday's copy was never assigned either.
const duration = `${Date.now() - started}ms`; const targets = (createdPairs || [])
const results = Array.isArray(res?.results) ? res.results : null; .map(({ index, bookingid }) => ({ bookingid, mileruserid: rows?.[index]?.__previousMilerUserId ?? null }))
const assigned = Number.isFinite(res?.assigned) ? res.assigned : results?.filter((r) => r.assigned).length; .filter((t) => t.mileruserid != null);
const sourceCalls = [ if (!targets.length) {
{ return { ok: true, assigned: 0, skipped: (createdPairs || []).length, failures: [], notified: 0, sourceCalls: [] };
...call, }
duration,
status: 'complete',
stats: `${assigned ?? '?'} assigned, ${res?.riderssequenced ?? 0} rider route${res?.riderssequenced === 1 ? '' : 's'} sequenced`
}
];
// One push per rider, not per order — ten orders to one rider is one buzz. const { lookup } = await loadRiders().catch(() => ({ lookup: null }));
const { lookup } = await loadRiders().catch(() => ({ lookup: null })); const assignedRiders = new Set();
const notified = new Set(); const failures = [];
if (results && lookup) { let assigned = 0;
// eslint-disable-next-line no-restricted-syntax
for (const r of results) { // Sequential on purpose. These are writes against real dispatch records, and
const rider = r.mileruserid ? lookup.byUserId.get(String(r.mileruserid)) : null; // firing a burst of them concurrently makes a partial failure much harder to
if (rider?.milerprofileid && !notified.has(rider.milerprofileid)) { // report accurately — which order did not land, and to whom.
notified.add(rider.milerprofileid); // eslint-disable-next-line no-restricted-syntax
for (const t of targets) {
try {
// eslint-disable-next-line no-await-in-loop
await assignMilerToBooking(t.bookingid, { mileruserid: Number(t.mileruserid) });
assigned += 1;
assignedRiders.add(String(t.mileruserid));
} catch (err) {
failures.push({ bookingid: t.bookingid, reason: err.message || `HTTP ${err.httpStatus || '?'}` });
}
}
const sourceCalls = [
{
name: 'assignMilerToBooking',
target: 'POST /admin/bookings/:id/assign-miler',
duration: `${Date.now() - started}ms`,
status: failures.length ? 'error' : 'complete',
stats: `${assigned} of ${targets.length} re-assigned to yesterday's rider`,
errorMessage: failures.length ? `${failures.length} could not be assigned` : undefined
}
];
// One push per rider, not per order — a rider getting ten of yesterday's
// drops back should feel one buzz, not ten.
let notified = 0;
if (lookup) {
// eslint-disable-next-line no-restricted-syntax
for (const userid of assignedRiders) {
const rider = lookup.byUserId.get(userid);
if (rider?.milerprofileid) {
try {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await notifyRider(rider.milerprofileid).catch(() => {}); await notifyRider(rider.milerprofileid);
notified += 1;
} catch {
// Notification failure never fails the assignment — the order IS
// assigned by this point. It is reported, not swallowed.
} }
} }
sourceCalls.push({
name: 'notifyRider',
target: '/admin/milers/:id/notify',
status: 'complete',
stats: `${notified.size} rider${notified.size === 1 ? '' : 's'} notified`
});
} }
sourceCalls.push({
return { name: 'notifyRider',
ok: (assigned ?? 0) > 0, target: 'POST /admin/milers/:id/notify',
assigned: assigned ?? 0, status: notified === assignedRiders.size ? 'complete' : 'error',
skipped: res?.skipped ?? Math.max(0, bookingIds.length - (assigned ?? 0)), stats: `${notified} of ${assignedRiders.size} rider${assignedRiders.size === 1 ? '' : 's'} notified`
sequenced: res?.riderssequenced ?? 0, });
results,
notified: notified.size,
sourceCalls
};
} catch (err) {
const status = err.httpStatus;
const message = status
? `POST /hub/bookings/batch-assign returned ${status}${err.message ? ` — ${err.message}` : ''}. Nothing was assigned.`
: `${err.message || 'The request failed'} — nothing was assigned.`;
return {
ok: false,
message,
sourceCalls: [{ ...call, duration: `${Date.now() - started}ms`, status: 'error', errorMessage: message }]
};
} }
return {
ok: assigned > 0,
assigned,
skipped: (createdPairs || []).length - targets.length,
failures,
notified,
riders: assignedRiders.size,
sourceCalls
};
}; };

View File

@@ -1,4 +1,5 @@
import { scanBookings } from './intents'; import { scanBookings } from './intents';
import { getStatusMeta } from 'themes/dt/status';
import { loadRiders, riderOptions, currentAssignee, describeRider } from './assignActions'; import { loadRiders, riderOptions, currentAssignee, describeRider } from './assignActions';
import { advanceFlow, startFlow, answerFlowStep } from './flowEngine'; import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
@@ -25,10 +26,56 @@ import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
export const ASSIGN_STEPS = [ export const ASSIGN_STEPS = [
{ {
id: 'bookingno', id: 'bookingno',
type: 'text', // A SELECT, not a text field.
ask: 'Which order? Give me its number — for example DM-BK-0D915D43-33705.', //
// Seeded by the panel when this follows a create, so it is skipped there. // This used to ask "Give me its number — for example DM-BK-0D915D43-33705"
// and wait for it to be typed. Two problems with that. An operator does not
// know the number by heart, so the question sent them to another screen to
// go and read one. And it was the fallback reached whenever a create did
// not hand back a booking id — so the moment the API response shape was
// anything other than expected, "assign the order I just made" turned into
// "recite a 20-character reference".
//
// A list removes the failure mode rather than patching it: unassigned
// orders first, because those are the ones anyone is here to assign.
//
// `resolve` below is kept, so typing a number still works — the engine runs
// it for a picked option too, and it already matches on bookingid.
type: 'select',
ask: 'Which order should I assign?',
// The list is built from a live scan, so it can come back empty — an API
// failure, or genuinely no bookings. `resolve` below still accepts a typed
// number, so say that rather than leaving Cancel as the only way out.
emptyHint: 'I couldn’t load the order list. Type the order number instead — for example DM-BK-0D915D43-33705.',
// Seeded by the panel when this follows a create, so it is skipped there —
// that path goes straight to the rider list.
when: (d) => !d.booking, when: (d) => !d.booking,
options: async () => {
const scan = await scanBookings();
const { lookup } = await loadRiders().catch(() => ({ lookup: null }));
return [...(scan.rows || [])]
.map((b) => ({ b, holder: lookup ? currentAssignee(b, lookup) : null }))
// Unassigned first; the API already returns newest-first, and that
// order is preserved within each group by a stable sort.
.sort((x, y) => Number(Boolean(x.holder)) - Number(Boolean(y.holder)))
// A dropdown is for picking, not for browsing. Past this many the
// operator is better served by naming the order.
.slice(0, 30)
.map(({ b, holder }) => ({
value: String(b.bookingid),
label: [
b.bookingno || `#${b.bookingid}`,
// A raw booking carries `status`; `orderstatus` is the mapped
// field the LIST pages add. Reading the wrong one made every
// option in this dropdown say "Unknown" — seen live.
getStatusMeta(b.status ?? b.orderstatus).label,
holder ? `held by ${describeRider(holder)}` : 'unassigned'
]
.filter(Boolean)
.join(' · ')
}));
},
resolve: async (raw) => { resolve: async (raw) => {
const needle = String(raw || '') const needle = String(raw || '')
.trim() .trim()
@@ -36,14 +83,32 @@ export const ASSIGN_STEPS = [
if (!needle) return { error: 'I need an order number.' }; if (!needle) return { error: 'I need an order number.' };
const scan = await scanBookings(); const scan = await scanBookings();
const found = scan.rows.find(
(b) => // EXACT matches win across the whole list before any fuzzy one is
String(b.bookingno || '').toLowerCase() === needle || // considered. The old version tested all three conditions per row inside
String(b.bookingid) === needle || // a single find(), so row ORDER decided the winner: a row whose
String(b.bookingno || '') // bookingno merely CONTAINED the needle could match before the row whose
.toLowerCase() // bookingid actually equalled it.
.includes(needle) //
// That is not theoretical. Picking from the dropdown sends a bare
// numeric bookingid as the answer, and every bookingno ends in a digit
// run — so "32143" substring-matched DM-BK-81DFAF19-32143 while some
// other booking genuinely had id 32143. The assignment then went to a
// different order than the one on screen, which reads as "it said it
// assigned but nothing updated".
const exact = scan.rows.find(
(b) => String(b.bookingid) === needle || String(b.bookingno || '').toLowerCase() === needle
); );
// Substring is a convenience for someone typing part of a reference, so
// it needs enough characters to identify one order. Below this it is
// guesswork — "1" would match most of the list.
const MIN_FUZZY = 4;
const found =
exact ||
(needle.length >= MIN_FUZZY
? scan.rows.find((b) => String(b.bookingno || '').toLowerCase().includes(needle))
: null);
if (!found) { if (!found) {
return { return {
error: scan.truncated error: scan.truncated
@@ -84,10 +149,34 @@ export const ASSIGN_STEPS = [
const { milers } = await loadRiders(); const { milers } = await loadRiders();
return riderOptions(milers); return riderOptions(milers);
}, },
// The whole miler record travels with the answer: the assign call needs its // Resolved rather than taken straight from the clicked option.
// `userid` and the notification needs its `milerprofileid`, and those are //
// different fields on the same record. // `apply` used to read `option?.record`, which is only populated when a
apply: (d, v, option) => ({ ...d, mileruserid: v, __rider: option?.record }) // button was CLICKED. Answer this step by typing — a rider's name, or an
// id — and option is undefined, so __rider was undefined, and executeAssign
// then threw on `rider.userid` inside its try/catch. The operator saw a
// failed assignment with "Cannot read properties of undefined" instead of
// an answer.
//
// Resolving here means both paths produce a real miler record, and a name
// that matches nobody gets a sentence rather than a crash.
resolve: async (raw) => {
const needle = String(raw || '').trim();
if (!needle) return { error: 'I need a rider.' };
const { milers, lookup } = await loadRiders();
const rider =
lookup?.byUserId?.get(needle) ||
lookup?.byName?.get(needle.toLowerCase()) ||
(milers || []).find((m) => String(m.userid) === needle) ||
(milers || []).find((m) => (m.displayname || m.authname || '').toLowerCase() === needle.toLowerCase());
if (!rider) return { error: `I couldn't find a rider matching ${raw}. Pick one from the list.` };
// Both ids travel: assign-miler needs `userid`, the push needs
// `milerprofileid`, and they are different fields on the same record.
return { value: { mileruserid: String(rider.userid), rider } };
},
apply: (d, v) => ({ ...d, mileruserid: v.mileruserid, __rider: v.rider })
} }
]; ];

View File

@@ -202,6 +202,8 @@ export const executeCreateBulk = async (rows, shared) => {
// The ids of what actually landed, so the run can be handed straight to // The ids of what actually landed, so the run can be handed straight to
// batch-assign without a re-scan of /admin/bookings to find them again. // batch-assign without a re-scan of /admin/bookings to find them again.
const createdIds = []; const createdIds = [];
// {index, bookingid} — the same ids, but each still tied to its source row.
const createdPairs = [];
for (let b = 0; b < batches.length; b += 1) { for (let b = 0; b < batches.length; b += 1) {
const batch = batches[b]; const batch = batches[b];
@@ -209,7 +211,22 @@ export const executeCreateBulk = async (rows, shared) => {
try { try {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
const res = await createExpressBookingBulk(batch); const res = await createExpressBookingBulk(batch);
const perRow = Array.isArray(res?.data) ? res.data : Array.isArray(res?.results) ? res.results : null; // Three shapes, most-nested first. The live endpoint returns
// { data: { results: [ { index, success, bookingid, bookingno } ] } }
// — confirmed against api.doormile.com — and only the two flatter shapes
// were checked here. So `res.data` was an object rather than an array,
// `res.results` was undefined, perRow fell through to null, and the
// whole run was treated as all-or-nothing: the count came out right by
// accident (created += batch.length) while EVERY booking id was thrown
// away. That is why "Assign the 13 you just created" never appeared —
// there were no ids to offer.
const perRow = Array.isArray(res?.data?.results)
? res.data.results
: Array.isArray(res?.data)
? res.data
: Array.isArray(res?.results)
? res.results
: null;
if (res?.success === false) { if (res?.success === false) {
failures.push(...batch.map((_, i) => ({ index: b * BULK_MAX + i, reason: res.message || 'Rejected' }))); failures.push(...batch.map((_, i) => ({ index: b * BULK_MAX + i, reason: res.message || 'Rejected' })));
@@ -228,10 +245,18 @@ export const executeCreateBulk = async (rows, shared) => {
// rather than inventing a success count. // rather than inventing a success count.
if (perRow) { if (perRow) {
perRow.forEach((r, i) => { perRow.forEach((r, i) => {
if (r?.success === false || r?.error) failures.push({ index: b * BULK_MAX + i, reason: r.message || r.error || 'Rejected' }); const index = b * BULK_MAX + i;
if (r?.success === false || r?.error) failures.push({ index, reason: r.message || r.error || 'Rejected' });
else { else {
created += 1; created += 1;
if (r?.bookingid) createdIds.push(r.bookingid); // Paired with the row it came from, not just collected. A bare list
// of ids cannot say WHICH row produced which booking, and the
// repeat flow needs exactly that to hand each new order back to the
// rider who ran it last time.
if (r?.bookingid) {
createdIds.push(r.bookingid);
createdPairs.push({ index, bookingid: r.bookingid });
}
} }
}); });
} else { } else {
@@ -265,6 +290,7 @@ export const executeCreateBulk = async (rows, shared) => {
// Empty when the endpoint returned no per-row array — the caller must treat // Empty when the endpoint returned no per-row array — the caller must treat
// "no ids" as "cannot offer assignment", not as "nothing was created". // "no ids" as "cannot offer assignment", not as "nothing was created".
createdIds, createdIds,
createdPairs,
failed: failures.length, failed: failures.length,
failures, failures,
batches: batches.length, batches: batches.length,

View File

@@ -111,6 +111,11 @@ const toRow = (booking, customer, index) => ({
itemdescription: booking.parcels?.[0]?.itemdescription || 'Order', itemdescription: booking.parcels?.[0]?.itemdescription || 'Order',
quantity: Math.max(1, booking.parcels?.length || 1), quantity: Math.max(1, booking.parcels?.length || 1),
notes: booking.notes || '', notes: booking.notes || '',
// Who ran this drop last time. Carried so the repeat can hand the new order
// back to the same rider — they already know the door, the buzzer and the
// customer. Null when yesterday's order was never assigned, which is a
// normal case and simply means the copy stays pending.
__previousMilerUserId: booking.assignedmileruserid ?? null,
__pickup: { __pickup: {
address: booking.pickupaddress, address: booking.pickupaddress,
pincode: booking.pickuppincode, pincode: booking.pickuppincode,

View File

@@ -1298,33 +1298,33 @@ const Clients1 = () => {
{KPI_META.map((item) => { {KPI_META.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const value = tenantStatusCounts[item.countKey] ?? 0; const value = tenantStatusCounts[item.countKey] ?? 0;
// 'total' has no matching entry in STATUS_TABS (there's no "All" const total = tenantStatusCounts.total ?? 0;
// tab) — only the three status tiles switch tabs when clicked.
const tabIdx = STATUS_TABS.findIndex((t) => t.countKey === item.countKey); const tabIdx = STATUS_TABS.findIndex((t) => t.countKey === item.countKey);
const clickable = tabIdx !== -1; const clickable = tabIdx !== -1;
const share =
item.countKey === 'total'
? `${value} clients`
: total > 0
? `${Math.round((value / total) * 100)}% of ${total}`
: null;
return ( return (
<div <StatCard
key={item.key} key={item.key}
role={clickable ? 'button' : undefined} title={item.label}
tabIndex={clickable ? 0 : undefined} // "all tenants" dropped — the card is already titled, and the
// count beside it says "N clients". `item.hint` is kept so any
// card that defines its own gloss still shows it.
range={item.hint}
value={value ?? 0}
meta={share}
icon={<Icon size={20} />}
color={item.color}
loading={getalltenantsIsLoading}
clickable={clickable}
isSelected={clickable && value0 === tabIdx}
onClick={clickable ? () => handleChange(tabIdx) : undefined} onClick={clickable ? () => handleChange(tabIdx) : undefined}
onKeyDown={ />
clickable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') handleChange(tabIdx);
}
: undefined
}
style={{ cursor: clickable ? 'pointer' : 'default', height: '100%' }}
>
<StatCard
title={item.label}
value={value ?? 0}
icon={<Icon size={20} />}
color={item.color}
loading={getalltenantsIsLoading}
/>
</div>
); );
})} })}
</KpiGrid> </KpiGrid>

View File

@@ -141,6 +141,7 @@ const Customers = () => {
const [addressInput, setAddressInput] = useState(''); const [addressInput, setAddressInput] = useState('');
const [pickAddress, setPickAddress] = useState({}); const [pickAddress, setPickAddress] = useState({});
const [latLng, setLatLng] = useState({ latitude: '', longitude: '' }); const [latLng, setLatLng] = useState({ latitude: '', longitude: '' });
const [activeFilter, setActiveFilter] = useState('all');
const [sorting, setSorting] = useState([]); const [sorting, setSorting] = useState([]);
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
const [columnSizing, setColumnSizing] = useState({}); const [columnSizing, setColumnSizing] = useState({});
@@ -148,12 +149,18 @@ const Customers = () => {
const { data: customers = [], isLoading } = useQuery({ queryKey: ['tenant-customers'], queryFn: getTenantCustomers }); const { data: customers = [], isLoading } = useQuery({ queryKey: ['tenant-customers'], queryFn: getTenantCustomers });
const rows = useMemo(() => { const rows = useMemo(() => {
if (!debouncedSearch) return customers; let list = customers;
if (activeFilter === 'withEmail') {
list = list.filter((c) => Boolean(c.email));
} else if (activeFilter === 'withPhone') {
list = list.filter((c) => Boolean(c.phone));
}
if (!debouncedSearch) return list;
const q = debouncedSearch.toLowerCase().trim(); const q = debouncedSearch.toLowerCase().trim();
return customers.filter((row) => return list.filter((row) =>
[custName(row), custPhone(row), custEmail(row)].filter(Boolean).some((f) => String(f).toLowerCase().includes(q)) [custName(row), custPhone(row), custEmail(row)].filter(Boolean).some((f) => String(f).toLowerCase().includes(q))
); );
}, [customers, debouncedSearch]); }, [customers, activeFilter, debouncedSearch]);
const stats = useMemo(() => { const stats = useMemo(() => {
const total = customers.length; const total = customers.length;
@@ -389,10 +396,43 @@ const Customers = () => {
/> />
<KpiGrid> <KpiGrid>
<StatCard title="Total Customers" value={stats.total} icon={<MdOutlinePeopleAlt size={20} />} color={BRAND} /> <StatCard
<StatCard title="With Email" value={stats.withEmail} icon={<MdOutlineMailOutline size={20} />} color="#0ea5e9" /> title="Total Customers"
<StatCard title="With Phone" value={stats.withPhone} icon={<MdOutlinePhoneAndroid size={20} />} color="#8b5cf6" /> value={stats.total}
<StatCard title="Total Bookings" value={stats.totalBookings} icon={<MdOutlineReceiptLong size={20} />} color="#10b981" /> meta={`${stats.total} customers`}
icon={<MdOutlinePeopleAlt size={20} />}
color={BRAND}
clickable
isSelected={activeFilter === 'all'}
onClick={() => setActiveFilter('all')}
/>
<StatCard
title="With Email"
value={stats.withEmail}
meta={`${stats.total > 0 ? Math.round((stats.withEmail / stats.total) * 100) : 0}% of ${stats.total}`}
icon={<MdOutlineMailOutline size={20} />}
color="#0ea5e9"
clickable
isSelected={activeFilter === 'withEmail'}
onClick={() => setActiveFilter((prev) => (prev === 'withEmail' ? 'all' : 'withEmail'))}
/>
<StatCard
title="With Phone"
value={stats.withPhone}
meta={`${stats.total > 0 ? Math.round((stats.withPhone / stats.total) * 100) : 0}% of ${stats.total}`}
icon={<MdOutlinePhoneAndroid size={20} />}
color="#8b5cf6"
clickable
isSelected={activeFilter === 'withPhone'}
onClick={() => setActiveFilter((prev) => (prev === 'withPhone' ? 'all' : 'withPhone'))}
/>
<StatCard
title="Total Bookings"
value={stats.totalBookings}
meta={`${stats.totalBookings} bookings`}
icon={<MdOutlineReceiptLong size={20} />}
color="#10b981"
/>
</KpiGrid> </KpiGrid>
<DataCard <DataCard

View File

@@ -15,10 +15,6 @@ import {
MdCheckCircle, MdCheckCircle,
MdCancel, MdCancel,
MdInventory2, MdInventory2,
MdOutlineLocalShipping,
MdOutlinePendingActions,
MdOutlineCheckCircle,
MdOutlineCancel,
MdKeyboardArrowDown, MdKeyboardArrowDown,
MdKeyboardArrowUp, MdKeyboardArrowUp,
MdArrowDownward, MdArrowDownward,
@@ -89,11 +85,10 @@ import { OpenToast } from 'components/third-party/OpenToast';
import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete'; import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete';
import LoaderWithImage from 'components/nearle_components/LoaderWithImage'; import LoaderWithImage from 'components/nearle_components/LoaderWithImage';
import PageHeader from 'components/nearle_components/PageHeader'; import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard';
import StatusBadge from 'components/nearle_components/StatusBadge'; import StatusBadge from 'components/nearle_components/StatusBadge';
import StatusTabs from 'components/nearle_components/StatusTabs'; import StatusTabs from 'components/nearle_components/StatusTabs';
import TableLoader from 'components/nearle_components/TableLoader'; import TableLoader from 'components/nearle_components/TableLoader';
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout'; import { PageShell, DataCard } from 'components/nearle_components/PageLayout';
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard'; import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
import { useIsNarrowViewport } from 'hooks/useIsNarrowViewport'; import { useIsNarrowViewport } from 'hooks/useIsNarrowViewport';
@@ -127,14 +122,6 @@ const STATUS_TABS = [
{ status: 'cancelled', countKey: 'cancelLength' } { status: 'cancelled', countKey: 'cancelLength' }
]; ];
// KPI palette + icons — mirrors the four cards across the top of the page.
const KPI_META = [
{ key: 'created', label: 'Created Orders', color: '#0ea5e9', icon: MdOutlineLocalShipping },
{ key: 'pending', label: 'Pending Orders', color: '#f59e0b', icon: MdOutlinePendingActions },
{ key: 'delivered', label: 'Delivered Orders', color: '#10b981', icon: MdOutlineCheckCircle },
{ key: 'cancelled', label: 'Cancelled Orders', color: '#ef4444', icon: MdOutlineCancel }
];
// Batches mirror the dispatch page's slot definitions so an operator who // Batches mirror the dispatch page's slot definitions so an operator who
// segments the day there sees the same buckets here. Hours are 24h, half-open // segments the day there sees the same buckets here. Hours are 24h, half-open
// [startHour, endHour) — a delivery at exactly endHour falls into the *next* // [startHour, endHour) — a delivery at exactly endHour falls into the *next*
@@ -479,8 +466,7 @@ const Deliveries = () => {
setChangeDialogOpen(false); setChangeDialogOpen(false);
if (res.success) { if (res.success) {
logger.info( logger.info(
`Rider changed successfully for order ID ${selectedRow?.orderid}. New Rider: ${ `Rider changed successfully for order ID ${selectedRow?.orderid}. New Rider: ${selectedRider?.displayname || selectedRider?.authname
selectedRider?.displayname || selectedRider?.authname
}` }`
); );
opentoast('Rider Changed Successfully', 'success'); opentoast('Rider Changed Successfully', 'success');
@@ -773,7 +759,7 @@ const Deliveries = () => {
}, },
{ {
id: 'serial', id: 'serial',
size: 56, size: 54,
minSize: 48, minSize: 48,
enableSorting: false, enableSorting: false,
meta: { label: '#' }, meta: { label: '#' },
@@ -792,18 +778,13 @@ const Deliveries = () => {
id: 'status', id: 'status',
accessorKey: 'orderstatus', accessorKey: 'orderstatus',
size: 130, size: 130,
minSize: 100, minSize: 110,
meta: { label: 'Status' }, meta: { label: 'Status' },
header: () => ( header: () => (
<Text type="label" weight="bold" color="secondary"> <Text type="label" weight="bold" color="secondary">
Status Status
</Text> </Text>
), ),
// The badge explains itself when the value did not come from the
// booking. Without this an operator comparing the table against
// GET /admin/bookings sees "Active" next to
// `"status": "Converted_To_Consignment"` and reasonably assumes the
// page is wrong — it isn't; the booking simply stops moving at pickup.
cell: ({ getValue, row }) => cell: ({ getValue, row }) =>
row.original.statusfromconsignment ? ( row.original.statusfromconsignment ? (
<Tooltip <Tooltip
@@ -818,150 +799,106 @@ const Deliveries = () => {
) )
}, },
{ {
id: 'tenant', id: 'orderTenant',
accessorKey: 'tenantname', accessorFn: (row) => `${row.orderid} ${row.tenantname}`,
size: 170, size: 260,
minSize: 130, minSize: 210,
meta: { label: 'Tenant' }, meta: { label: 'Order & Tenant' },
header: () => ( header: () => (
<Text type="label" weight="bold" color="secondary"> <Text type="label" weight="bold" color="secondary">
Tenant Order & Tenant
</Text> </Text>
), ),
cell: ({ row }) => (
<Tooltip content={row.original.tenantadress || ''}>
<VStack gap={0} padding={0}>
<Text weight="semibold" maxLines={1}>
{row.original.tenantname}
</Text>
<Text type="supporting" color="secondary" maxLines={1}>
{row.original.tenantsuburb}
</Text>
<Text type="supporting" color="secondary" maxLines={1}>
{row.original.applocation}
</Text>
</VStack>
</Tooltip>
)
},
{
id: 'orderLocation',
accessorFn: (row) => row.orderid,
size: 240,
minSize: 180,
enableSorting: false,
meta: { label: 'Order / Location' },
header: () => (
<Text type="label" weight="bold" color="secondary">
Order / Location
</Text>
),
// Order and delivery are stacked, not side by side. They used to be two
// VStacks inside an HStack, and flex items default to `min-width: auto`
// — so the order id (`DM-BK-BC87D8B0-30828`, unbreakable and wider than
// half the column) refused to shrink and crushed the delivery stack to
// zero width. A zero-width cell doesn't hide its text, it wraps it one
// character per line, which is what rendered as a vertical strip of
// letters. Two 20-character ids never fitted a 220px column; labelling
// them and giving each its own line is what actually fixes it, and the
// bare "-" separator between them conveyed nothing.
cell: ({ row }) => { cell: ({ row }) => {
const r = row.original; const r = row.original;
// express-console-api.md: Doormile timestamps are IST wall-clock with
// no timezone marker. A Z-suffixed response reinterpreted as local
// time shifts by 5:30 — showing the wrong time and sometimes the
// wrong day. parseDoormileTimestamp strips that, matching
// getRowBatchId above.
const ordered = parseDoormileTimestamp(r.orderdate); const ordered = parseDoormileTimestamp(r.orderdate);
const delivered = parseDoormileTimestamp(r.deliverydate);
return ( return (
<VStack gap={0} padding={0} minWidth={0}> <VStack gap={0.5} padding={0} minWidth={0}>
<Tooltip content="Location Name-Suburb"> <Tooltip content={`Order ID: ${r.orderid} · Hub: ${r.locationname || '—'}`}>
<Text maxLines={1}>{r.locationsuburb ? `${r.locationname}-(${r.locationsuburb})` : r.locationname}</Text> <Text weight="bold" maxLines={1} style={{ fontSize: 13, color: DT.textPrimary, letterSpacing: -0.2 }}>
{r.orderid}
</Text>
</Tooltip> </Tooltip>
<HStack gap={1} vAlign="center" minWidth={0}>
<Tooltip content="Order Id"> <Tooltip content={r.tenantadress ? `${r.tenantname} · ${r.tenantadress}` : r.tenantname}>
<Text maxLines={1}>{r.orderid}</Text> <Text weight="medium" color="secondary" maxLines={1} style={{ fontSize: 12 }}>
</Tooltip> {r.tenantname}
<Text type="supporting" color="secondary"> </Text>
Ordered {ordered.format('DD/MM/YYYY · hh:mm A')} </Tooltip>
</Text> <Text type="supporting" color="secondary" style={{ fontSize: 11, flexShrink: 0 }}>
· {ordered.isValid() ? ordered.format('hh:mm A') : '—'}
<Tooltip content="Delivery Id"> </Text>
<Text maxLines={1}>{r.deliveryid}</Text> </HStack>
</Tooltip>
<Text type="supporting" color="secondary">
Delivery {delivered.format('DD/MM/YYYY · hh:mm A')}
</Text>
</VStack> </VStack>
); );
} }
}, },
{ {
id: 'pickup', id: 'route',
accessorKey: 'pickupcustomer', accessorFn: (row) => `${row.pickupcustomer} ${row.deliverycustomer}`,
size: 190, size: 320,
minSize: 140, minSize: 260,
meta: { label: 'Pickup' }, meta: { label: 'Route (Pickup → Drop)' },
header: () => ( header: () => (
<Text type="label" weight="bold" color="secondary"> <Text type="label" weight="bold" color="secondary">
Pickup Route (Pickup → Drop)
</Text> </Text>
), ),
// pickup — capped to the same maxWidth as drop below, with CSS ellipsis cell: ({ row }) => {
// truncation, so one long address can't stretch this column wider than const r = row.original;
// its neighbour. row.pickuplocation is just the full raw address (see const pickupAddr = r.pickupsuburb || r.pickuplocation || r.Pickupaddress || '';
// fetchDeliveries in api.js), not a pre-shortened name, so the old const dropAddr = r.deliverysuburb || r.deliverylocation || r.deliveryaddress || '';
// `.slice(0, 14)` fallback never actually ran. Full text still const routeTooltip = (
// available via the tooltip. <div style={{ textAlign: 'left', minWidth: 200, maxWidth: 300, whiteSpace: 'normal', color: '#fff' }}>
cell: ({ row }) => ( <div style={{ display: 'flex', gap: '8px', alignItems: 'flex-start', marginBottom: '8px' }}>
<VStack gap={0} padding={0} minWidth={0}> <img src="/pickup-icon.png" alt="Pickup" style={{ width: 18, height: 18, marginTop: 2, flexShrink: 0 }} />
<Text weight="semibold" maxLines={1}> <div style={{ display: 'flex', flexDirection: 'column' }}>
{row.original.pickupcustomer} <span style={{ fontWeight: 'bold', fontSize: '12px' }}>Pickup: {r.pickupcustomer || 'Pickup'} {r.pickupcontactno ? `(${r.pickupcontactno})` : ''}</span>
</Text> <span style={{ fontSize: '11px', opacity: 0.9 }}>{r.Pickupaddress || r.pickuplocation || 'No address'}</span>
<Text type="supporting" color="secondary"> </div>
{row.original.pickupcontactno} </div>
</Text> <div style={{ display: 'flex', gap: '8px', alignItems: 'flex-start' }}>
<Tooltip content={row.original.Pickupaddress || ''}> <img src="/drop-icon.png" alt="Drop" style={{ width: 18, height: 18, marginTop: 2, flexShrink: 0 }} />
<Text type="supporting" color="secondary" maxLines={1}> <div style={{ display: 'flex', flexDirection: 'column' }}>
{row.original.pickuplocation || row.original.Pickupaddress || '—'} <span style={{ fontWeight: 'bold', fontSize: '12px' }}>Drop: {r.deliverycustomer || 'Drop'} {r.deliverycontactno ? `(${r.deliverycontactno})` : ''}</span>
</Text> <span style={{ fontSize: '11px', opacity: 0.9 }}>{r.deliveryaddress || r.deliverylocation || 'No address'}</span>
</div>
</div>
</div>
);
return (
<Tooltip content={routeTooltip}>
<VStack gap={0.5} padding={0} minWidth={0} style={{ cursor: 'pointer' }}>
<HStack gap={1} vAlign="center" minWidth={0}>
<img src="/pickup-icon.png" alt="Pickup" style={{ width: 16, height: 16, flexShrink: 0 }} />
<Text weight="semibold" maxLines={1} style={{ fontSize: 12, flexShrink: 0 }}>
{r.pickupcustomer || 'Pickup'}
</Text>
<Text type="supporting" color="secondary" maxLines={1} style={{ fontSize: 11 }}>
{pickupAddr ? `· ${pickupAddr}` : ''}
</Text>
</HStack>
<HStack gap={1} vAlign="center" minWidth={0}>
<img src="/drop-icon.png" alt="Drop" style={{ width: 17, height: 17, flexShrink: 0 }} />
<Text weight="semibold" maxLines={1} style={{ fontSize: 12, flexShrink: 0 }}>
{r.deliverycustomer || 'Drop'}
</Text>
<Text type="supporting" color="secondary" maxLines={1} style={{ fontSize: 11 }}>
{dropAddr ? `· ${dropAddr}` : ''}
</Text>
</HStack>
</VStack>
</Tooltip> </Tooltip>
</VStack> );
) }
},
{
id: 'drop',
accessorKey: 'deliverycustomer',
size: 190,
minSize: 140,
meta: { label: 'Drop' },
header: () => (
<Text type="label" weight="bold" color="secondary">
Drop
</Text>
),
cell: ({ row }) => (
<VStack gap={0} padding={0} minWidth={0}>
<Text weight="semibold" maxLines={1}>
{row.original.deliverycustomer}
</Text>
<Text type="supporting" color="secondary">
{row.original.deliverycontactno}
</Text>
<Tooltip content={row.original.deliveryaddress || ''}>
<Text type="supporting" color="secondary" maxLines={1}>
{row.original.deliverylocation || row.original.deliveryaddress || '—'}
</Text>
</Tooltip>
</VStack>
)
}, },
{ {
id: 'rider', id: 'rider',
accessorKey: 'ridername', accessorKey: 'ridername',
size: 180, size: 190,
minSize: 140, minSize: 150,
sortUndefined: 'last', sortUndefined: 'last',
meta: { label: 'Rider' }, meta: { label: 'Rider' },
header: () => ( header: () => (
@@ -972,89 +909,92 @@ const Deliveries = () => {
cell: ({ row }) => { cell: ({ row }) => {
const r = row.original; const r = row.original;
return r.ridername ? ( return r.ridername ? (
<HStack gap={1.5} vAlign="center"> <HStack gap={1.5} vAlign="center" minWidth={0}>
<AccentAvatar color="#8b5cf6" size={28}> <AccentAvatar color="#8b5cf6" size={28}>
<MdDirectionsBike size={14} /> <MdDirectionsBike size={14} />
</AccentAvatar> </AccentAvatar>
<VStack gap={0} padding={0}> <VStack gap={0} padding={0} minWidth={0}>
<Text weight="semibold" maxLines={1}> <Text weight="semibold" maxLines={1}>
{r.ridername} {r.ridername}
</Text> </Text>
<Text type="supporting" color="secondary"> <Text type="supporting" color="secondary" maxLines={1} style={{ fontSize: 11 }}>
ID #{r.userid} · {r.ridercontact || '—'} ID #{r.userid} {r.ridercontact ? `· ${r.ridercontact}` : ''}
</Text> </Text>
</VStack> </VStack>
</HStack> </HStack>
) : ( ) : (
<Text type="supporting" weight="bold" color="secondary"> <Text type="supporting" weight="medium" color="secondary" style={{ fontStyle: 'italic' }}>
Unassigned Unassigned
</Text> </Text>
); );
} }
}, },
{ {
id: 'eta', id: 'timingRoute',
accessorFn: (row) => row.expecteddeliverytime || undefined, accessorFn: (row) => row.expecteddeliverytime || row.kms,
size: 100, size: 140,
minSize: 90, minSize: 110,
sortUndefined: 'last', sortUndefined: 'last',
meta: { label: 'ETA' }, meta: { label: 'ETA & Distance' },
header: () => ( header: () => (
<Text type="label" weight="bold" color="secondary"> <Text type="label" weight="bold" color="secondary">
ETA ETA & Distance
</Text> </Text>
), ),
cell: ({ getValue }) => { cell: ({ row }) => {
const v = getValue(); const r = row.original;
const eta = r.expecteddeliverytime ? parseDoormileTimestamp(r.expecteddeliverytime).format('hh:mm A') : null;
const kms = Number(r.kms);
return ( return (
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}> <VStack gap={0.5} padding={0} minWidth={0}>
{v ? parseDoormileTimestamp(v).format('hh:mm A') : '—'} <Text weight="bold" style={{ fontSize: 12 }}>
</Text> {eta ? `ETA ${eta}` : 'ETA —'}
</Text>
{kms ? (
<Text type="supporting" color="secondary" style={{ fontSize: 11 }}>
{kms.toFixed(2)} km
</Text>
) : (
<Text type="supporting" color="secondary" style={{ fontSize: 11 }}>
—
</Text>
)}
</VStack>
); );
} }
}, },
{ {
id: 'kms', id: 'amountQty',
accessorFn: (row) => Number(row.kms) || undefined, accessorFn: (row) => Number(row.deliverycharges) || Number(row.deliveryamt),
size: 110, size: 130,
minSize: 90, minSize: 100,
sortUndefined: 'last', sortUndefined: 'last',
meta: { label: 'Kms' }, meta: { label: 'Amount & Qty' },
header: () => ( header: () => (
<Text type="label" weight="bold" color="secondary"> <Text type="label" weight="bold" color="secondary">
Kms Amount & Qty
</Text> </Text>
), ),
cell: ({ getValue }) => ( cell: ({ row }) => {
<VStack gap={0.5} padding={0}> const r = row.original;
<MetricPill variant="red" label={`${(getValue() ?? 0).toFixed(2)} km`} tooltip="Planned KMS" /> const amt = r.deliverycharges ?? r.deliveryamt;
</VStack> return (
) <VStack gap={0.5} padding={0} minWidth={0}>
}, <Text weight="bold" style={{ fontSize: 13, color: DT.textPrimary }}>
{ {amt != null ? `₹ ${Number(amt).toFixed(2)}` : '—'}
id: 'amount', </Text>
accessorFn: (row) => Number(row.deliverycharges) || undefined, <Text type="supporting" color="secondary" style={{ fontSize: 11 }}>
size: 150, {r.Quantity ? `${r.Quantity} items` : '1 parcel'}
minSize: 120, </Text>
sortUndefined: 'last', </VStack>
meta: { label: 'Amount' }, );
header: () => ( }
<Text type="label" weight="bold" color="secondary">
Amount
</Text>
),
cell: ({ row }) => (
<VStack gap={0.5} padding={0}>
<MetricPill variant="red" label={`₹ ${row.original.deliverycharges?.toFixed(2) ?? '0.00'}`} tooltip="Delivery Charge" />
<MetricPill variant="green" label={`₹ ${row.original.deliveryamt?.toFixed(2) ?? '0.00'}`} tooltip="Delivery Amount" />
</VStack>
)
}, },
{ {
id: 'notes', id: 'notes',
accessorKey: 'notes', accessorKey: 'notes',
size: 170, size: 120,
minSize: 120, minSize: 90,
enableSorting: false, enableSorting: false,
meta: { label: 'Notes' }, meta: { label: 'Notes' },
header: () => ( header: () => (
@@ -1069,7 +1009,7 @@ const Deliveries = () => {
<Text <Text
type="supporting" type="supporting"
color="secondary" color="secondary"
style={{ maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'inline-block' }} style={{ maxWidth: 110, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'inline-block' }}
> >
{v} {v}
</Text> </Text>
@@ -1081,23 +1021,6 @@ const Deliveries = () => {
); );
} }
}, },
{
id: 'qty',
accessorKey: 'Quantity',
size: 80,
minSize: 64,
meta: { label: 'Qty' },
header: () => (
<Text type="label" weight="bold" color="secondary">
Qty
</Text>
),
cell: ({ row }) => (
<Text weight="semibold" style={{ color: row.original.Quantity ? undefined : DT.textMuted, whiteSpace: 'nowrap' }}>
{row.original.Quantity || '—'}
</Text>
)
},
{ {
id: 'action', id: 'action',
size: 90, size: 90,
@@ -1140,8 +1063,7 @@ const Deliveries = () => {
} }
} }
], ],
// eslint-disable-next-line react-hooks/exhaustive-deps [tablePagination, buildRowMenuItems, productCollapse]
[tablePagination.pageIndex, tablePagination.pageSize, productCollapse]
); );
// Two sources feed one visibility slice: whatever the operator toggled in // Two sources feed one visibility slice: whatever the operator toggled in
@@ -1343,12 +1265,12 @@ const Deliveries = () => {
const errorMessage = fetchCountIsError const errorMessage = fetchCountIsError
? `Error fetching count summary: ${fetchCountError?.message}` ? `Error fetching count summary: ${fetchCountError?.message}`
: ridersListIsError : ridersListIsError
? `Error fetching riders: ${ridersListError?.message}` ? `Error fetching riders: ${ridersListError?.message}`
: fetchtenantsIsError : fetchtenantsIsError
? `Error tenant list: ${fetchtenantsError?.message}` ? `Error tenant list: ${fetchtenantsError?.message}`
: fetchlocationsIsError : fetchlocationsIsError
? `Error location list: ${fetchlocationsError?.message}` ? `Error location list: ${fetchlocationsError?.message}`
: null; : null;
if (errorMessage) { if (errorMessage) {
OpenToast(errorMessage, 'error', 2000); OpenToast(errorMessage, 'error', 2000);
@@ -1366,14 +1288,33 @@ const Deliveries = () => {
}); });
// Trims the redundant " Batch" off BATCH_OPTIONS' own label (which stays // Trims the redundant " Batch" off BATCH_OPTIONS' own label (which stays
// full elsewhere, e.g. the empty-state message) — "Afternoon Batch · 89" // full elsewhere, e.g. the empty-state message) — "Afternoon Batch" was
// was overflowing/wrapping inside the selector's compact trigger, squeezed // overflowing/wrapping inside the selector's compact trigger, squeezed to a
// to a 200px FilterToolbar track alongside four other filters. // 200px FilterToolbar track alongside four other filters.
//
// The count is NOT in the label any more. It used to be appended as
// "Evening · 0", which put a number in the closed trigger where it read as
// part of the batch's name, and — on any batch with nothing in it — put a
// bare "· 0" in the filter row. A count belongs beside the option it counts,
// as a badge, and a zero is better said by absence than by a "0".
const batchSelectorOptions = BATCH_OPTIONS.map((b) => ({ const batchSelectorOptions = BATCH_OPTIONS.map((b) => ({
value: b.id, value: b.id,
label: `${b.label.replace(/ Batch$/, '')} · ${batchTotals[b.id] ?? 0}` label: b.label.replace(/ Batch$/, '')
})); }));
// The count rides in the open menu only. `renderOption` receives the option
// data, which carries `value` but not the total, so it is looked up here
// rather than smuggled through the label.
const renderBatchOption = (option) => {
const total = batchTotals[option.value] ?? 0;
return (
<HStack gap={2} vAlign="center" justify="between" style={{ width: '100%', minWidth: 0 }}>
<Text>{option.label}</Text>
{total > 0 && <Badge variant="neutral" label={total} />}
</HStack>
);
};
const renderProductDetails = () => ( const renderProductDetails = () => (
<Card padding={0} elevation="none" style={{ margin: 16 }}> <Card padding={0} elevation="none" style={{ margin: 16 }}>
<HStack gap={2} vAlign="center" padding={2} style={{ borderBottom: `1px solid ${DT.divider}`, background: tint(BRAND) }}> <HStack gap={2} vAlign="center" padding={2} style={{ borderBottom: `1px solid ${DT.divider}`, background: tint(BRAND) }}>
@@ -1444,9 +1385,8 @@ const Deliveries = () => {
const emptyDescription = const emptyDescription =
selectedBatch === 'all' selectedBatch === 'all'
? `No ${(getStatusMeta(currentStatus)?.label || tabstatus).toLowerCase()} orders for this filter.` ? `No ${(getStatusMeta(currentStatus)?.label || tabstatus).toLowerCase()} orders for this filter.`
: `No ${(getStatusMeta(currentStatus)?.label || tabstatus).toLowerCase()} orders in ${ : `No ${(getStatusMeta(currentStatus)?.label || tabstatus).toLowerCase()} orders in ${BATCH_OPTIONS.find((b) => b.id === selectedBatch)?.label || 'this batch'
BATCH_OPTIONS.find((b) => b.id === selectedBatch)?.label || 'this batch' }.`;
}.`;
return ( return (
<> <>
@@ -1454,7 +1394,7 @@ const Deliveries = () => {
<Loader /> <Loader />
)} )}
<PageShell fill> <PageShell>
<PageHeader <PageHeader
title="Deliveries" title="Deliveries"
subtitle={`Live · ${locaName || 'All Zones'}`} subtitle={`Live · ${locaName || 'All Zones'}`}
@@ -1470,48 +1410,66 @@ const Deliveries = () => {
} }
/> />
{/* All four tiles now read off the same countSourceRows-derived data {/* Status segment cards at the top of the page */}
the StatusTabs pills use (batchTotals/batchCounts), instead of <StatusTabs
three of them reading a separate, unscoped `fetchCountAPI` call — value={currentStatus}
fetchCountAPI ignores every argument passed to it (see api.js) and onChange={(status) => {
returns an ALL-TIME, un-batched, un-dated total, which is why const idx = STATUS_TABS.findIndex((t) => t.status === status);
"Pending Orders" could show a number the Pending tab itself if (idx !== -1) handleChangetab(idx);
disagreed with. Same fix orders.js's KPI row already applies: }}
a tile and its tab must read the same source so they can't drift. */} tabs={[
<KpiGrid> ...STATUS_TABS.map((t) => ({ status: t.status, count: batchCounts[t.countKey] ?? 0 })),
{[ ...(batchCounts.otherLength > 0
{ ...KPI_META[0], value: batchTotals.all }, ? [{ status: 'other', label: `Other (${unmappedStatuses.join(', ')})`, count: batchCounts.otherLength }]
{ ...KPI_META[1], value: batchCounts.uncoveredLength }, : [])
{ ...KPI_META[2], value: batchCounts.coveredLength }, ]}
{ ...KPI_META[3], value: batchCounts.cancelLength } size="sm"
].map((item) => { hasDivider={false}
const Icon = item.icon; hideHint={true}
return ( />
<StatCard
key={item.key}
title={item.label}
value={item.value ?? 0}
icon={<Icon size={20} />}
color={item.color}
loading={countSourceIsLoading}
/>
);
})}
</KpiGrid>
<DataCard {/* Filters live OUTSIDE the table card.
fill Inside it, the batch/date/tenant/location/rider controls read as
toolbar={ part of the table's own chrome — a header band belonging to the
<FilterToolbar max={5} minWidth={200}> rows — when they actually decide WHICH rows exist. Lifting them
out makes the card start at the data, and the controls read as
what they are: page-level filters acting on the card below. */}
<div
className="dt-deliveries-toolbar"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: '10px',
padding: '0',
width: '100%',
boxSizing: 'border-box'
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: '10px',
flex: '1 1 auto',
minWidth: 0
}}
>
<div style={{ flex: '1 1 130px', minWidth: 120, maxWidth: 180 }}>
<Selector <Selector
label="Batch" label="Batch"
isLabelHidden isLabelHidden
options={batchSelectorOptions} options={batchSelectorOptions}
renderOption={renderBatchOption}
value={selectedBatch} value={selectedBatch}
onChange={(v) => v && setSelectedBatch(v)} onChange={(v) => v && setSelectedBatch(v)}
placeholder="Batch" placeholder="Batch"
/> />
</div>
<div style={{ flexShrink: 0 }}>
<Button <Button
label={ label={
startdate && enddate ? `${dayjs(startdate).format('DD MMM')} – ${dayjs(enddate).format('DD MMM')}` : 'Select date range' startdate && enddate ? `${dayjs(startdate).format('DD MMM')} – ${dayjs(enddate).format('DD MMM')}` : 'Select date range'
@@ -1520,7 +1478,9 @@ const Deliveries = () => {
icon={<MdOutlineDateRange size={16} />} icon={<MdOutlineDateRange size={16} />}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
/> />
</div>
<div style={{ flex: '1 1 160px', minWidth: 155, maxWidth: 240 }}>
<Selector <Selector
label="Tenant" label="Tenant"
isLabelHidden isLabelHidden
@@ -1540,7 +1500,9 @@ const Deliveries = () => {
isDisabled={appId == null} isDisabled={appId == null}
disabledMessage="Please select your zone first!" disabledMessage="Please select your zone first!"
/> />
</div>
<div style={{ flex: '1 1 160px', minWidth: 155, maxWidth: 240 }}>
<Selector <Selector
label="Location" label="Location"
isLabelHidden isLabelHidden
@@ -1560,7 +1522,9 @@ const Deliveries = () => {
isDisabled={!tenantid} isDisabled={!tenantid}
disabledMessage={appId == null ? 'Please select your Zone and Tenant first!' : 'Please select your Tenant first!'} disabledMessage={appId == null ? 'Please select your Zone and Tenant first!' : 'Please select your Tenant first!'}
/> />
</div>
<div style={{ flex: '1 1 160px', minWidth: 155, maxWidth: 240 }}>
<Selector <Selector
label="Rider" label="Rider"
isLabelHidden isLabelHidden
@@ -1573,61 +1537,51 @@ const Deliveries = () => {
isDisabled={riderListIsLoading || appId == null} isDisabled={riderListIsLoading || appId == null}
disabledMessage="Please select your zone first!" disabledMessage="Please select your zone first!"
/> />
</FilterToolbar> </div>
} </div>
tabs={
<HStack justify="between" vAlign="center" gap={2} wrap="wrap">
<StatusTabs
value={currentStatus}
onChange={(status) => {
const idx = STATUS_TABS.findIndex((t) => t.status === status);
if (idx !== -1) handleChangetab(idx);
}}
tabs={[
...STATUS_TABS.map((t) => ({ status: t.status, count: batchCounts[t.countKey] ?? 0 })),
// Only ever rendered when a status arrived that this page has
// no tab for. Its presence is the signal that the mapping in
// api.js needs extending — the rows are reachable meanwhile.
...(batchCounts.otherLength > 0
? [{ status: 'other', label: `Other (${unmappedStatuses.join(', ')})`, count: batchCounts.otherLength }]
: [])
]}
size="sm"
hasDivider={false}
/>
<HStack gap={2} vAlign="center">
<VStack maxWidth={260} width="100%" padding={0} gap={0}>
<DebounceSearchBar value={searchword} onChange={setSearchword} onDebouncedChange={setDebouncedSearch} />
</VStack>
{/* Columns menu — driven by the same `columnVisibility` slice <div
the tab uses for select/action, so there is one source of style={{
truth for "is this column showing" (same pattern as display: 'flex',
orders.js). Only rendered for the desktop table. */} alignItems: 'center',
{!isMobile && ( gap: '10px',
<DropdownMenu flex: '0 1 auto',
button={{ label: 'Columns', variant: 'secondary', size: 'sm', icon: <MdViewColumn size={16} /> }} minWidth: 260,
menuWidth={200} justifyContent: 'flex-end'
placement="below" }}
> >
{table <div style={{ width: '100%', minWidth: 180, maxWidth: 260 }}>
.getAllLeafColumns() <DebounceSearchBar value={searchword} onChange={setSearchword} onDebouncedChange={setDebouncedSearch} />
.filter((column) => column.getCanHide()) </div>
.map((column) => (
<DropdownMenuCheckboxItem {/* Columns menu — driven by the same `columnVisibility` slice
key={column.id} the tab uses for select/action, so there is one source of
label={column.columnDef.meta?.label ?? column.id} truth for "is this column showing" (same pattern as
value={column.getIsVisible()} orders.js). Only rendered for the desktop table. */}
onChange={(checked) => column.toggleVisibility(checked)} {!isMobile && (
hasCloseOnSelect={false} <DropdownMenu
/> button={{ label: 'Columns', variant: 'secondary', size: 'sm', icon: <MdViewColumn size={16} /> }}
))} menuWidth={200}
</DropdownMenu> placement="below"
)} >
</HStack> {table
</HStack> .getAllLeafColumns()
} .filter((column) => column.getCanHide())
> .map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
label={column.columnDef.meta?.label ?? column.id}
value={column.getIsVisible()}
onChange={(checked) => column.toggleVisibility(checked)}
hasCloseOnSelect={false}
/>
))}
</DropdownMenu>
)}
</div>
</div>
<DataCard>
{isMobile ? ( {isMobile ? (
<MobileCardList scroll> <MobileCardList scroll>
{filteredRows.length === 0 && !loading1 && !countSourceLoading && ( {filteredRows.length === 0 && !loading1 && !countSourceLoading && (
@@ -1815,9 +1769,11 @@ const Deliveries = () => {
Widths come from header.getSize(), the columnSizing slice Widths come from header.getSize(), the columnSizing slice
the drag handles write to. */} the drag handles write to. */}
<Table <Table
density="compact" density="balanced"
dividers="none" dividers="rows"
hasHover hasHover
verticalAlign="middle"
aria-label="Deliveries"
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }} style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
> >
<TableHeader> <TableHeader>
@@ -1850,11 +1806,11 @@ const Deliveries = () => {
onKeyDown={ onKeyDown={
canSort canSort
? (e) => { ? (e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.preventDefault();
header.column.toggleSorting(); header.column.toggleSorting();
}
} }
}
: undefined : undefined
} }
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined} style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
@@ -2082,8 +2038,7 @@ const Deliveries = () => {
onClick={() => { onClick={() => {
setLoading1(true); setLoading1(true);
logger.info( logger.info(
`Initiating rider assignment change for order ID ${selectedRow?.orderid} to rider: ${ `Initiating rider assignment change for order ID ${selectedRow?.orderid} to rider: ${selectedRider?.displayname || selectedRider?.authname
selectedRider?.displayname || selectedRider?.authname
}` }`
); );
changeRiderMutation.mutate({ selectedRider, selectedRow }); changeRiderMutation.mutate({ selectedRider, selectedRow });
@@ -2107,6 +2062,9 @@ const Deliveries = () => {
open={open} open={open}
toggle={() => setOpen(!open)} toggle={() => setOpen(!open)}
id="daterange1" id="daterange1"
// So the fields open showing the window already in force, rather
// than blank on an actively filtered table.
value={{ startDate: startdate || undefined, endDate: enddate || undefined }}
onChange={(range) => { onChange={(range) => {
if (range.label === 'All') { if (range.label === 'All') {
setStartdate(''); setStartdate('');

View File

@@ -128,7 +128,7 @@ const ActiveSection = ({
} }
// Rider delivered → reached customer location // Rider delivered → reached customer location
if (old.status === 'active' && cur.status === 'delivered') { if (old.status === 'active' && cur.status === 'delivered') {
OpenToast(`📍 ${cur.riderName} has reached ${cur.customer}'s location`, 'info', 3000); OpenToast(`${cur.riderName} has reached ${cur.customer}'s location`, 'info', 3000);
} }
}); });
} }

View File

@@ -492,11 +492,9 @@ export default function ProfitabilitySection({
const slotKms = activeEnriched.reduce((s, r) => s + r._m.kms, 0); const slotKms = activeEnriched.reduce((s, r) => s + r._m.kms, 0);
const profitCount = activeEnriched.filter((r) => r._m.net >= 0).length; const profitCount = activeEnriched.filter((r) => r._m.net >= 0).length;
const lossCount = activeEnriched.length - profitCount; const lossCount = activeEnriched.length - profitCount;
const totalOrders = enriched.reduce((s, r) => s + (r._m.orders?.length ?? 0), 0);
const dailyIsProfit = totalDailyProfit >= 0; const dailyIsProfit = totalDailyProfit >= 0;
const slotIsProfit = slotNet >= 0; const slotIsProfit = slotNet >= 0;
const slotMargin = slotRevenue > 0 ? (slotNet / slotRevenue) * 100 : 0;
// Sorted loss-first so the riders needing attention are at the top. // 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]); const sorted = useMemo(() => [...activeEnriched].sort((a, b) => a._m.net - b._m.net), [activeEnriched]);
@@ -518,42 +516,36 @@ export default function ProfitabilitySection({
value={rupees(Math.abs(totalDailyProfit))} value={rupees(Math.abs(totalDailyProfit))}
icon={dailyIsProfit ? <MdTrendingUp size={20} /> : <MdTrendingDown size={20} />} icon={dailyIsProfit ? <MdTrendingUp size={20} /> : <MdTrendingDown size={20} />}
color={moneyColor(dailyIsProfit)} color={moneyColor(dailyIsProfit)}
caption="All slots today"
/> />
<StatCard <StatCard
title="Riders active" title="Riders active"
value={activeEnriched.length} value={activeEnriched.length}
icon={<MdPeopleAlt size={20} />} icon={<MdPeopleAlt size={20} />}
color={STATUS.info} color={STATUS.info}
caption={`${profitCount} in profit · ${lossCount} at loss`}
/> />
<StatCard <StatCard
title="Total distance" title="Total distance"
value={`${slotKms.toFixed(1)} km`} value={`${slotKms.toFixed(1)} km`}
icon={<MdStraighten size={20} />} icon={<MdStraighten size={20} />}
color={STATUS.picked} color={STATUS.picked}
caption="Cumulative travel distance"
/> />
<StatCard <StatCard
title="Slot revenue" title="Slot revenue"
value={rupees(slotRevenue)} value={rupees(slotRevenue)}
icon={<MdAccountBalanceWallet size={20} />} icon={<MdAccountBalanceWallet size={20} />}
color={STATUS.success} color={STATUS.success}
caption={`From ${totalOrders} order${totalOrders !== 1 ? 's' : ''}`}
/> />
<StatCard <StatCard
title="Slot cost" title="Slot cost"
value={rupees(slotCost)} value={rupees(slotCost)}
icon={<MdPayments size={20} />} icon={<MdPayments size={20} />}
color={STATUS.error} color={STATUS.error}
caption="Fixed + variable"
/> />
<StatCard <StatCard
title="Slot net" title="Slot net"
value={`${slotIsProfit ? '+' : ''}${rupees(slotNet)}`} value={`${slotIsProfit ? '+' : ''}${rupees(slotNet)}`}
icon={<MdOutlineSavings size={20} />} icon={<MdOutlineSavings size={20} />}
color={moneyColor(slotIsProfit)} color={moneyColor(slotIsProfit)}
caption={`${slotMargin >= 0 ? '+' : ''}${slotMargin.toFixed(0)}% margin`}
/> />
</KpiGrid> </KpiGrid>

View File

@@ -29,6 +29,7 @@ import { TextInput } from '@astryxdesign/core/TextInput';
import { TextArea } from '@astryxdesign/core/TextArea'; import { TextArea } from '@astryxdesign/core/TextArea';
import { Selector } from '@astryxdesign/core/Selector'; import { Selector } from '@astryxdesign/core/Selector';
import { Badge } from '@astryxdesign/core/Badge'; import { Badge } from '@astryxdesign/core/Badge';
import { Tooltip } from '@astryxdesign/core/Tooltip';
import { Pagination } from '@astryxdesign/core/Pagination'; import { Pagination } from '@astryxdesign/core/Pagination';
import { EmptyState } from '@astryxdesign/core/EmptyState'; import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
@@ -38,6 +39,7 @@ import TableLoader from 'components/nearle_components/TableLoader';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar'; import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import PageHeader from 'components/nearle_components/PageHeader'; import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard'; import StatCard from 'components/nearle_components/StatCard';
import StatusBadge from 'components/nearle_components/StatusBadge';
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout'; import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
import { getExceptions, createException, updateExceptionStatus, getHubs } from 'pages/api/doormileApi'; import { getExceptions, createException, updateExceptionStatus, getHubs } from 'pages/api/doormileApi';
import { DT } from 'themes/dt/tokens'; import { DT } from 'themes/dt/tokens';
@@ -189,29 +191,45 @@ const Exceptions = () => {
{ {
id: 'consignment', id: 'consignment',
accessorKey: 'consignmentid', accessorKey: 'consignmentid',
size: 170, size: 200,
minSize: 130, minSize: 150,
meta: { label: 'Consignment' }, meta: { label: 'Consignment & Hub' },
header: () => <SectionLabel>Consignment</SectionLabel>, header: () => <SectionLabel>Consignment & Hub</SectionLabel>,
cell: ({ getValue }) => <Text weight="semibold">{getValue()}</Text> cell: ({ row }) => {
}, const hub = hubMap.get(row.original.hubid)?.hubname || (row.original.hubid ? `Hub #${row.original.hubid}` : '—');
{ return (
id: 'hub', <VStack gap={0.5} padding={0} minWidth={0}>
accessorFn: (row) => hubMap.get(row.hubid)?.hubname || (row.hubid ? `Hub #${row.hubid}` : '—'), <Text weight="bold" style={{ fontSize: 13, color: DT.textPrimary }}>
size: 150, #{row.original.consignmentid}
minSize: 120, </Text>
meta: { label: 'Hub' }, <Text type="supporting" color="secondary" style={{ fontSize: 11 }}>
header: () => <SectionLabel>Hub</SectionLabel>, {hub}
cell: ({ getValue }) => <Text>{getValue()}</Text> </Text>
</VStack>
);
}
}, },
{ {
id: 'type', id: 'type',
accessorKey: 'exceptiontype', accessorKey: 'exceptiontype',
size: 150, size: 280,
minSize: 120, minSize: 200,
meta: { label: 'Type' }, meta: { label: 'Exception Details' },
header: () => <SectionLabel>Type</SectionLabel>, header: () => <SectionLabel>Exception Details</SectionLabel>,
cell: ({ getValue }) => <Text>{getValue()}</Text> cell: ({ row }) => (
<VStack gap={0.5} padding={0} minWidth={0}>
<Text weight="semibold" maxLines={1} style={{ fontSize: 12 }}>
{row.original.exceptiontype || 'Exception'}
</Text>
{row.original.description && (
<Tooltip content={row.original.description}>
<Text type="supporting" color="secondary" maxLines={1} style={{ fontSize: 11 }}>
{row.original.description}
</Text>
</Tooltip>
)}
</VStack>
)
}, },
{ {
id: 'severity', id: 'severity',
@@ -222,19 +240,6 @@ const Exceptions = () => {
header: () => <SectionLabel>Severity</SectionLabel>, header: () => <SectionLabel>Severity</SectionLabel>,
cell: ({ getValue }) => <Badge variant={SEVERITY_BADGE[getValue()] || 'neutral'} label={getValue()} /> cell: ({ getValue }) => <Badge variant={SEVERITY_BADGE[getValue()] || 'neutral'} label={getValue()} />
}, },
{
id: 'description',
accessorKey: 'description',
size: 260,
minSize: 180,
meta: { label: 'Description' },
header: () => <SectionLabel>Description</SectionLabel>,
cell: ({ getValue }) => (
<Text style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={getValue()}>
{getValue()}
</Text>
)
},
{ {
id: 'status', id: 'status',
accessorFn: (row) => row.status || 'Open', accessorFn: (row) => row.status || 'Open',
@@ -242,15 +247,15 @@ const Exceptions = () => {
minSize: 110, minSize: 110,
meta: { label: 'Status' }, meta: { label: 'Status' },
header: () => <SectionLabel>Status</SectionLabel>, header: () => <SectionLabel>Status</SectionLabel>,
cell: ({ getValue }) => <Text>{getValue()}</Text> cell: ({ getValue }) => <StatusBadge status={getValue()} />
}, },
{ {
id: 'actions', id: 'actions',
size: 140, size: 90,
minSize: 110, minSize: 80,
enableSorting: false, enableSorting: false,
enableResizing: false, enableResizing: false,
meta: { label: 'Actions' }, meta: { label: 'Actions', align: 'right' },
header: () => <SectionLabel>Actions</SectionLabel>, header: () => <SectionLabel>Actions</SectionLabel>,
cell: ({ row }) => cell: ({ row }) =>
!['Resolved', 'Closed'].includes(row.original.status) ? ( !['Resolved', 'Closed'].includes(row.original.status) ? (
@@ -305,10 +310,34 @@ const Exceptions = () => {
/> />
<KpiGrid> <KpiGrid>
<StatCard title="Total" value={stats.total} icon={<MdWarningAmber size={20} />} color={BRAND} /> <StatCard
<StatCard title="Open" value={stats.open} icon={<MdWarningAmber size={20} />} color="#f59e0b" /> title="Total"
<StatCard title="Critical" value={stats.critical} icon={<MdWarningAmber size={20} />} color="#ef4444" /> value={stats.total}
<StatCard title="Resolved" value={stats.resolved} icon={<MdCheckCircleOutline size={20} />} color="#10b981" /> meta={`${stats.total} logged`}
icon={<MdWarningAmber size={20} />}
color={BRAND}
/>
<StatCard
title="Open"
value={stats.open}
meta={`${stats.total > 0 ? Math.round((stats.open / stats.total) * 100) : 0}% of ${stats.total}`}
icon={<MdWarningAmber size={20} />}
color="#f59e0b"
/>
<StatCard
title="Critical"
value={stats.critical}
meta={`${stats.total > 0 ? Math.round((stats.critical / stats.total) * 100) : 0}% of ${stats.total}`}
icon={<MdWarningAmber size={20} />}
color="#ef4444"
/>
<StatCard
title="Resolved"
value={stats.resolved}
meta={`${stats.total > 0 ? Math.round((stats.resolved / stats.total) * 100) : 0}% of ${stats.total}`}
icon={<MdCheckCircleOutline size={20} />}
color="#10b981"
/>
</KpiGrid> </KpiGrid>
<DataCard <DataCard

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { import {
MdOutlineWarehouse, MdOutlineWarehouse,
MdStorefront,
MdEdit, MdEdit,
MdDeleteOutline, MdDeleteOutline,
MdAdd, MdAdd,
@@ -27,6 +28,7 @@ import {
createPaginatedRowModel createPaginatedRowModel
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import { Tooltip } from '@astryxdesign/core/Tooltip';
import { Grid } from '@astryxdesign/core/Grid'; import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
@@ -112,6 +114,7 @@ const Hubs = () => {
const [debouncedSearch, setDebouncedSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState('');
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(emptyForm); const [form, setForm] = useState(emptyForm);
const [activeFilter, setActiveFilter] = useState('all');
// Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's // Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's
// /Tenants.js's slices. // /Tenants.js's slices.
const [sorting, setSorting] = useState([]); const [sorting, setSorting] = useState([]);
@@ -121,12 +124,20 @@ const Hubs = () => {
const { data: hubs = [], isLoading } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs }); const { data: hubs = [], isLoading } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs });
const rows = useMemo(() => { const rows = useMemo(() => {
if (!debouncedSearch) return hubs; let list = hubs;
if (activeFilter === 'sorting_center') {
list = list.filter((h) => h.hubtype === 'sorting_center');
} else if (activeFilter === 'delivery_hub') {
list = list.filter((h) => h.hubtype === 'delivery_hub');
} else if (activeFilter === 'active') {
list = list.filter((h) => String(h.status || '').toLowerCase() === 'active');
}
if (!debouncedSearch) return list;
const q = debouncedSearch.toLowerCase().trim(); const q = debouncedSearch.toLowerCase().trim();
return hubs.filter((row) => return list.filter((row) =>
[row.hubname, row.hubtype, row.address, row.pincode].filter(Boolean).some((f) => String(f).toLowerCase().includes(q)) [row.hubname, row.hubtype, row.address, row.pincode].filter(Boolean).some((f) => String(f).toLowerCase().includes(q))
); );
}, [hubs, debouncedSearch]); }, [hubs, activeFilter, debouncedSearch]);
const stats = useMemo(() => { const stats = useMemo(() => {
const total = hubs.length; const total = hubs.length;
@@ -239,47 +250,45 @@ const Hubs = () => {
{ {
id: 'hubname', id: 'hubname',
accessorKey: 'hubname', accessorKey: 'hubname',
size: 220, size: 240,
minSize: 160, minSize: 180,
meta: { label: 'Hub Name' }, meta: { label: 'Hub' },
header: () => <SectionLabel>Hub Name</SectionLabel>, header: () => <SectionLabel>Hub</SectionLabel>,
cell: ({ getValue }) => <Text weight="bold">{getValue()}</Text> cell: ({ row }) => (
}, <HStack gap={1.5} vAlign="center">
{ <AccentAvatar color={BRAND} size={28}>
id: 'hubtype', <MdStorefront size={14} />
accessorFn: (row) => (row.hubtype === 'sorting_center' ? 'Sorting Center' : 'Delivery Hub'), </AccentAvatar>
size: 150, <VStack gap={0} padding={0} minWidth={0}>
minSize: 120, <Text weight="bold" maxLines={1} style={{ fontSize: 13, color: DT.textPrimary }}>
meta: { label: 'Type' }, {row.original.hubname}
header: () => <SectionLabel>Type</SectionLabel>, </Text>
cell: ({ getValue }) => getValue() <Text type="supporting" color="secondary" style={{ fontSize: 11 }}>
}, {row.original.hubtype === 'sorting_center' ? 'Sorting Center' : 'Delivery Hub'}
{ </Text>
id: 'city', </VStack>
accessorKey: 'applocationid', </HStack>
size: 100, )
minSize: 80,
meta: { label: 'City' },
header: () => <SectionLabel>City</SectionLabel>,
cell: ({ getValue }) => getValue() ?? '—'
}, },
{ {
id: 'address', id: 'address',
accessorKey: 'address', accessorKey: 'address',
size: 240, size: 320,
minSize: 160, minSize: 240,
meta: { label: 'Address' }, meta: { label: 'Address & Location' },
header: () => <SectionLabel>Address</SectionLabel>, header: () => <SectionLabel>Address & Location</SectionLabel>,
cell: ({ getValue }) => getValue() || '—' cell: ({ row }) => (
}, <VStack gap={0.5} padding={0} minWidth={0}>
{ <Tooltip content={row.original.address || ''}>
id: 'pincode', <Text weight="medium" maxLines={1} style={{ fontSize: 12 }}>
accessorKey: 'pincode', {row.original.address || '—'}
size: 110, </Text>
minSize: 90, </Tooltip>
meta: { label: 'Pincode' }, <Text type="supporting" color="secondary" style={{ fontSize: 11 }}>
header: () => <SectionLabel>Pincode</SectionLabel>, {[row.original.pincode, row.original.applocationid ? `City #${row.original.applocationid}` : null].filter(Boolean).join(' · ') || '—'}
cell: ({ getValue }) => getValue() || '—' </Text>
</VStack>
)
}, },
{ {
id: 'status', id: 'status',
@@ -292,8 +301,8 @@ const Hubs = () => {
}, },
{ {
id: 'actions', id: 'actions',
size: 120, size: 100,
minSize: 100, minSize: 90,
enableSorting: false, enableSorting: false,
enableResizing: false, enableResizing: false,
meta: { label: 'Actions', align: 'right' }, meta: { label: 'Actions', align: 'right' },
@@ -320,7 +329,6 @@ const Hubs = () => {
) )
} }
], ],
// eslint-disable-next-line react-hooks/exhaustive-deps
[pagination.pageIndex, pagination.pageSize] [pagination.pageIndex, pagination.pageSize]
); );
@@ -355,10 +363,46 @@ const Hubs = () => {
/> />
<KpiGrid> <KpiGrid>
<StatCard title="Total Hubs" value={stats.total} icon={<MdOutlineWarehouse size={20} />} color={BRAND} /> <StatCard
<StatCard title="Sorting Centers" value={stats.sorting} icon={<MdOutlineWarehouse size={20} />} color={STATUS.info} /> title="Total Hubs"
<StatCard title="Delivery Hubs" value={stats.delivery} icon={<MdPlace size={20} />} color={STATUS.delivered} /> value={stats.total}
<StatCard title="Active Hubs" value={stats.active} icon={<MdOutlineCheckCircle size={20} />} color={STATUS.active} /> meta={`${stats.total} hubs`}
icon={<MdOutlineWarehouse size={20} />}
color={BRAND}
clickable
isSelected={activeFilter === 'all'}
onClick={() => setActiveFilter('all')}
/>
<StatCard
title="Sorting Centers"
value={stats.sorting}
meta={`${stats.total > 0 ? Math.round((stats.sorting / stats.total) * 100) : 0}% of ${stats.total}`}
icon={<MdOutlineWarehouse size={20} />}
color={STATUS.info}
clickable
isSelected={activeFilter === 'sorting_center'}
onClick={() => setActiveFilter((prev) => (prev === 'sorting_center' ? 'all' : 'sorting_center'))}
/>
<StatCard
title="Delivery Hubs"
value={stats.delivery}
meta={`${stats.total > 0 ? Math.round((stats.delivery / stats.total) * 100) : 0}% of ${stats.total}`}
icon={<MdPlace size={20} />}
color={STATUS.delivered}
clickable
isSelected={activeFilter === 'delivery_hub'}
onClick={() => setActiveFilter((prev) => (prev === 'delivery_hub' ? 'all' : 'delivery_hub'))}
/>
<StatCard
title="Active Hubs"
value={stats.active}
meta={`${stats.total > 0 ? Math.round((stats.active / stats.total) * 100) : 0}% of ${stats.total}`}
icon={<MdOutlineCheckCircle size={20} />}
color={STATUS.active}
clickable
isSelected={activeFilter === 'active'}
onClick={() => setActiveFilter((prev) => (prev === 'active' ? 'all' : 'active'))}
/>
</KpiGrid> </KpiGrid>
<DataCard <DataCard

View File

@@ -35,8 +35,6 @@ import {
MdLocalShipping, MdLocalShipping,
MdClear, MdClear,
MdNotes, MdNotes,
MdMyLocation,
MdOutlineFlag,
MdFlashOn, MdFlashOn,
MdCancel, MdCancel,
MdStraighten, MdStraighten,
@@ -65,6 +63,7 @@ import TableLoader from 'components/nearle_components/TableLoader';
import PageHeader from 'components/nearle_components/PageHeader'; import PageHeader from 'components/nearle_components/PageHeader';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar'; import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import { PageShell, DataCard } from 'components/nearle_components/PageLayout'; import { PageShell, DataCard } from 'components/nearle_components/PageLayout';
import { useIsNarrowViewport } from 'hooks/useIsNarrowViewport';
import { useQuery, useMutation } from '@tanstack/react-query'; import { useQuery, useMutation } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { fetchPercentageData, createAutomationDeliveries, getallriders, buildMilerLookup, notifyRider } from '../../api/api'; import { fetchPercentageData, createAutomationDeliveries, getallriders, buildMilerLookup, notifyRider } from '../../api/api';
@@ -298,6 +297,7 @@ PlaceCell.propTypes = {
const Orders = () => { const Orders = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const isMobile = useIsNarrowViewport();
const tid = localStorage.getItem('tenantid'); const tid = localStorage.getItem('tenantid');
const [tabstatus, setTabstatus] = useState('Pending'); const [tabstatus, setTabstatus] = useState('Pending');
const [currentStatus, setCurrentStatus] = useState('pending_pickup'); const [currentStatus, setCurrentStatus] = useState('pending_pickup');
@@ -586,44 +586,63 @@ const Orders = () => {
} }
}, },
{ {
id: 'pickup', id: 'route',
accessorKey: 'pickupaddress', accessorFn: (row) => `${row.pickupaddress || ''} ${row.deliveryaddress || ''}`,
size: 320, size: 340,
minSize: 260, minSize: 260,
meta: { label: 'Pickup' }, meta: { label: 'Route (Pickup → Drop)' },
header: () => <SectionLabel>Pickup</SectionLabel>, header: () => <SectionLabel>Route (Pickup → Drop)</SectionLabel>,
cell: ({ getValue, row }) => ( cell: ({ row }) => {
<PlaceCell icon={<MdMyLocation size={11} />} primary={getValue() || '—'} secondary={row.original.pickuppincode || ''} /> const r = row.original;
) const customer = customerMap?.get(r.appcustomerid);
}, const dropName = customer?.firstname || customer?.name || (r.appcustomerid ? `Customer #${r.appcustomerid}` : 'Drop Customer');
{ const routeTooltip = (
id: 'drop', <div style={{ textAlign: 'left', minWidth: 200, maxWidth: 300, whiteSpace: 'normal', color: '#fff' }}>
// The customer join lives in the accessor, so the cell only renders. <div style={{ display: 'flex', gap: '8px', alignItems: 'flex-start', marginBottom: '8px' }}>
accessorFn: (row) => { <img src="/pickup-icon.png" alt="Pickup" style={{ width: 18, height: 18, marginTop: 2, flexShrink: 0 }} />
const customer = customerMap?.get(row.appcustomerid); <div style={{ display: 'flex', flexDirection: 'column' }}>
return customer?.firstname || customer?.name || `Customer #${row.appcustomerid ?? '—'}`; <span style={{ fontWeight: 'bold', fontSize: '12px' }}>Pickup</span>
}, <span style={{ fontSize: '11px', opacity: 0.9 }}>{r.pickupaddress || 'No address'} {r.pickuppincode ? `(${r.pickuppincode})` : ''}</span>
size: 360, </div>
minSize: 280, </div>
meta: { label: 'Drop' }, <div style={{ display: 'flex', gap: '8px', alignItems: 'flex-start' }}>
header: () => <SectionLabel>Drop</SectionLabel>, <img src="/drop-icon.png" alt="Drop" style={{ width: 18, height: 18, marginTop: 2, flexShrink: 0 }} />
cell: ({ getValue, row }) => { <div style={{ display: 'flex', flexDirection: 'column' }}>
const customer = customerMap?.get(row.original.appcustomerid); <span style={{ fontWeight: 'bold', fontSize: '12px' }}>Drop</span>
<span style={{ fontSize: '11px', opacity: 0.9 }}>{dropName} {customer?.phone || customer?.contactno ? `(${customer.phone || customer.contactno})` : ''}</span>
<span style={{ fontSize: '11px', opacity: 0.9 }}>{r.deliveryaddress || 'No address'}</span>
</div>
</div>
</div>
);
return ( return (
<PlaceCell icon={<MdOutlineFlag size={11} />} primary={getValue()} secondary={customer?.phone || customer?.contactno || ''}> <Tooltip content={routeTooltip}>
<HStack gap={1} vAlign="center" minWidth={0}> <VStack gap={0.5} padding={0} minWidth={0} style={{ cursor: 'pointer' }}>
<Text type="supporting" color="secondary" maxLines={1}> <HStack gap={1} vAlign="center" minWidth={0}>
{row.original.deliveryaddress || '—'} <img src="/pickup-icon.png" alt="Pickup" style={{ width: 16, height: 16, flexShrink: 0 }} />
</Text> <Text weight="semibold" maxLines={1} style={{ fontSize: 12, flexShrink: 0 }}>
{/* Parcel count moved out of its own 64px column — it was "1" Pickup
on every row. It belongs next to the delivery it describes, </Text>
and only shows when it is not 1, which is the one thing <Text type="supporting" color="secondary" maxLines={1} style={{ fontSize: 11 }}>
Astryx's Badge guidance says a badge IS for. */} {r.pickupaddress ? `· ${r.pickupaddress}` : '—'}
{(row.original.parcels?.length || 0) > 1 && ( </Text>
<Badge variant="neutral" icon={<MdInventory2 size={11} />} label={`${row.original.parcels.length} parcels`} /> </HStack>
)}
</HStack> <HStack gap={1} vAlign="center" minWidth={0}>
</PlaceCell> <img src="/drop-icon.png" alt="Drop" style={{ width: 17, height: 17, flexShrink: 0 }} />
<Text weight="semibold" maxLines={1} style={{ fontSize: 12, flexShrink: 0 }}>
{dropName}
</Text>
<Text type="supporting" color="secondary" maxLines={1} style={{ fontSize: 11 }}>
{r.deliveryaddress ? `· ${r.deliveryaddress}` : ''}
</Text>
{(r.parcels?.length || 0) > 1 && (
<Badge variant="neutral" icon={<MdInventory2 size={10} />} label={`${r.parcels.length} parcels`} />
)}
</HStack>
</VStack>
</Tooltip>
); );
} }
}, },
@@ -927,33 +946,33 @@ const Orders = () => {
viewport, at any window size, with no viewport arithmetic anywhere on viewport, at any window size, with no viewport arithmetic anywhere on
this page. See PageLayout.js for why one `:has()` rule is needed to this page. See PageLayout.js for why one `:has()` rule is needed to
make that flex chain resolve. */} make that flex chain resolve. */}
<PageShell fill> <PageShell>
<PageHeader title="Orders" subtitle={`Live · ${locoName} · ${datestatus}`} live /> <PageHeader title="Orders" subtitle={`Live · ${locoName} · ${datestatus}`} live />
<DataCard {/* Status segment cards at the top of the page */}
fill <StatusTabs
tabs={ value={currentStatus}
<HStack justify="between" vAlign="center" gap={2} wrap="wrap"> onChange={handleChangetab}
{/* The shared status strip, same component the other list pages hasDivider={false}
use, so tab chrome — selected indicator, hover, focus ring, // The status names carry their own meaning to the people who live in
count badge, arrow-key navigation — is identical across the // this page all day — "Pending" does not need "awaiting rider" spelled
console instead of re-declared here. Labels stay this page's // out underneath it, and eight glossed labels in a row crowd the
own ("Assigned", not the registry's "Accepted") because the // number each card exists to show. Scoped to Orders: the hints stay
Orders tabs track the OPERATOR's workflow. */} // on the other list pages, which the same registry feeds.
<StatusTabs hideHint
value={currentStatus} tabs={ORDERS_STATUS_TABS.map((t) => ({
onChange={handleChangetab} status: t.status,
hasDivider={false} label: t.label,
tabs={ORDERS_STATUS_TABS.map((t) => ({ count: t.statuses.reduce((sum, s) => sum + (statusCounts[s] ?? 0), 0)
status: t.status, }))}
label: t.label, />
count: t.statuses.reduce((sum, s) => sum + (statusCounts[s] ?? 0), 0)
}))}
/>
{/* Search + Columns menu share the tab row's right side, so the <DataCard
whole toolbar — filter tabs, search, column visibility — tabs={
reads as one control strip instead of two stacked rows. */} <HStack justify="between" vAlign="center" gap={2} wrap="wrap" style={{ width: '100%' }}>
<Text weight="medium" color="secondary">
{filteredRows.length} {filteredRows.length === 1 ? 'order' : 'orders'}
</Text>
<HStack gap={2} vAlign="center"> <HStack gap={2} vAlign="center">
<VStack maxWidth={260} width="100%" padding={0} gap={0}> <VStack maxWidth={260} width="100%" padding={0} gap={0}>
<DebounceSearchBar <DebounceSearchBar
@@ -968,24 +987,26 @@ const Orders = () => {
the tab uses for the checkbox column, so there is one the tab uses for the checkbox column, so there is one
source of truth for "is this column showing". `getCanHide()` source of truth for "is this column showing". `getCanHide()`
is what keeps the checkbox column out of the list. */} is what keeps the checkbox column out of the list. */}
<DropdownMenu {!isMobile && (
button={{ label: 'Columns', variant: 'secondary', size: 'sm', icon: <MdViewColumn size={16} /> }} <DropdownMenu
menuWidth={200} button={{ label: 'Columns', variant: 'secondary', size: 'sm', icon: <MdViewColumn size={16} /> }}
placement="below" menuWidth={200}
> placement="below"
{table >
.getAllLeafColumns() {table
.filter((column) => column.getCanHide()) .getAllLeafColumns()
.map((column) => ( .filter((column) => column.getCanHide())
<DropdownMenuCheckboxItem .map((column) => (
key={column.id} <DropdownMenuCheckboxItem
label={column.columnDef.meta?.label ?? column.id} key={column.id}
value={column.getIsVisible()} label={column.columnDef.meta?.label ?? column.id}
onChange={(checked) => column.toggleVisibility(checked)} value={column.getIsVisible()}
hasCloseOnSelect={false} onChange={(checked) => column.toggleVisibility(checked)}
/> hasCloseOnSelect={false}
))} />
</DropdownMenu> ))}
</DropdownMenu>
)}
</HStack> </HStack>
</HStack> </HStack>
} }

View File

@@ -12,10 +12,6 @@ import {
MdCurrencyRupee, MdCurrencyRupee,
MdMap, MdMap,
MdNoteAlt, MdNoteAlt,
MdOutlineLocalShipping,
MdOutlineCheckCircle,
MdOutlinePendingActions,
MdOutlineCancel,
MdArrowUpward, MdArrowUpward,
MdArrowDownward, MdArrowDownward,
MdUnfoldMore MdUnfoldMore
@@ -67,8 +63,7 @@ import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import LoaderWithImage from 'components/nearle_components/LoaderWithImage'; import LoaderWithImage from 'components/nearle_components/LoaderWithImage';
import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete'; import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete';
import PageHeader from 'components/nearle_components/PageHeader'; import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard'; import { PageShell, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
import StatusBadge from 'components/nearle_components/StatusBadge'; import StatusBadge from 'components/nearle_components/StatusBadge';
import StatusTabs from 'components/nearle_components/StatusTabs'; import StatusTabs from 'components/nearle_components/StatusTabs';
import TableLoader from 'components/nearle_components/TableLoader'; import TableLoader from 'components/nearle_components/TableLoader';
@@ -341,10 +336,6 @@ export default function OrdersDetails() {
}); });
return c; return c;
}, [allRows]); }, [allRows]);
const total = statusCountByKey.all;
const pendingLenght = statusCountByKey.pending;
const deliveredLenght = statusCountByKey.delivered;
const cancelLenght = statusCountByKey.cancelled;
// The scroll-to-reveal sentinel + IntersectionObserver that used to live // The scroll-to-reveal sentinel + IntersectionObserver that used to live
// here was already dead: the unconditional drain effect above (`if // here was already dead: the unconditional drain effect above (`if
@@ -476,13 +467,6 @@ export default function OrdersDetails() {
} }
}, [errormessage]); }, [errormessage]);
const KPI_META = [
{ key: 'total', label: 'Total Orders', color: BRAND, icon: MdOutlineLocalShipping, value: total },
{ key: 'delivered', label: 'Delivered', color: STATUS.delivered, icon: MdOutlineCheckCircle, value: deliveredLenght },
{ key: 'pending', label: 'Pending', color: STATUS.pending, icon: MdOutlinePendingActions, value: pendingLenght },
{ key: 'cancelled', label: 'Cancelled', color: STATUS.cancelled, icon: MdOutlineCancel, value: cancelLenght }
];
// A tab/zone/tenant/location/rider/date/search change re-scopes the list, // A tab/zone/tenant/location/rider/date/search change re-scopes the list,
// so page 1 is the only page that still means anything — same reset // so page 1 is the only page that still means anything — same reset
// orders.js/riders.js do on their own tables. // orders.js/riders.js do on their own tables.
@@ -839,7 +823,7 @@ export default function OrdersDetails() {
Export. They used to be split across the header and a separate filter Export. They used to be split across the header and a separate filter
box below, which is why Export kept wrapping onto its own line and box below, which is why Export kept wrapping onto its own line and
left that box half empty. */} left that box half empty. */}
<PageShell fill> <PageShell>
{/* Only the primary action lives in the header. The zone picker and {/* Only the primary action lives in the header. The zone picker and
date range were up here too and the zone picker OVERLAPPED the date date range were up here too and the zone picker OVERLAPPED the date
button — LocationAutocomplete brings its own width and ignores the button — LocationAutocomplete brings its own width and ignores the
@@ -853,25 +837,21 @@ export default function OrdersDetails() {
action={<Button label="Export" variant="primary" icon={<MdFileDownload size={16} />} onClick={() => setReportDialog(true)} />} action={<Button label="Export" variant="primary" icon={<MdFileDownload size={16} />} onClick={() => setReportDialog(true)} />}
/> />
<KpiGrid> {/* Status segment cards at the top of the page */}
{KPI_META.map((item) => { <StatusTabs
const Icon = item.icon; value={currentStatus}
return ( onChange={setCurrentStatus}
<StatCard tabs={STATUS_TABS.map((key) => ({ status: key, count: statusCountByKey[key] ?? 0 }))}
key={item.key} size="sm"
title={item.label} hasDivider={false}
value={item.value ?? 0} // Same as the Orders strip: the status names stand on their own, and
icon={<Icon size={20} />} // nine glossed labels in a row bury the counts. Set here rather than
color={item.color} // stripped from the shared registry, which still feeds Deliveries.
loading={fetchDeliveriesIsLoading} hideHint
/> />
);
})}
</KpiGrid>
{/* Toolbar: Zone · Tenant · Location · Rider · Date · Search. */} {/* Toolbar: Zone · Tenant · Location · Rider · Date · Search. */}
<DataCard <DataCard
fill
toolbar={ toolbar={
<FilterToolbar max={5} minWidth={200}> <FilterToolbar max={5} minWidth={200}>
<LocationAutocomplete <LocationAutocomplete
@@ -963,14 +943,10 @@ export default function OrdersDetails() {
</FilterToolbar> </FilterToolbar>
} }
tabs={ tabs={
<HStack justify="between" vAlign="center" gap={2} wrap="wrap"> <HStack justify="between" vAlign="center" gap={2} wrap="wrap" style={{ width: '100%' }}>
<StatusTabs <Text weight="medium" color="secondary">
value={currentStatus} {rows.length} {rows.length === 1 ? 'order' : 'orders'}
onChange={setCurrentStatus} </Text>
tabs={STATUS_TABS.map((key) => ({ status: key, count: statusCountByKey[key] ?? 0 }))}
size="sm"
hasDivider={false}
/>
<VStack maxWidth={260} width="100%" padding={0} gap={0}> <VStack maxWidth={260} width="100%" padding={0} gap={0}>
<DebounceSearchBar <DebounceSearchBar
value={searchword} value={searchword}

View File

@@ -311,9 +311,9 @@ export default function OrdersReport() {
if (isErrorReports) console.warn('ordersSummary error:', reportsError?.message); if (isErrorReports) console.warn('ordersSummary error:', reportsError?.message);
const KPI_META = [ const KPI_META = [
{ key: 'orders', label: 'Total Orders', color: BRAND, icon: MdOutlineLocalShipping, value: stats.totalOrders }, { key: 'orders', label: 'Total Orders', color: BRAND, icon: MdOutlineLocalShipping, value: stats.totalOrders, meta: `${stats.totalOrders} orders` },
{ key: 'pending', label: 'Orders Pending', color: '#f59e0b', icon: MdOutlinePendingActions, value: stats.orderPend }, { key: 'pending', label: 'Orders Pending', color: '#f59e0b', icon: MdOutlinePendingActions, value: stats.orderPend, meta: stats.totalOrders > 0 ? `${Math.round((stats.orderPend / stats.totalOrders) * 100)}% of total` : null },
{ key: 'completed', label: 'Orders Completed', color: '#10b981', icon: MdOutlineCheckCircle, value: stats.orderComplete }, { key: 'completed', label: 'Orders Completed', color: '#10b981', icon: MdOutlineCheckCircle, value: stats.orderComplete, meta: stats.totalOrders > 0 ? `${Math.round((stats.orderComplete / stats.totalOrders) * 100)}% of total` : null },
{ key: 'amount', label: 'Total Amount', color: '#0ea5e9', icon: MdOutlineCurrencyRupee, value: formatNumberToRupees(stats.amount) } { key: 'amount', label: 'Total Amount', color: '#0ea5e9', icon: MdOutlineCurrencyRupee, value: formatNumberToRupees(stats.amount) }
]; ];
@@ -674,7 +674,9 @@ export default function OrdersReport() {
<StatCard <StatCard
key={item.key} key={item.key}
title={item.label} title={item.label}
range={item.range}
value={item.value ?? 0} value={item.value ?? 0}
meta={item.meta}
icon={<Icon size={20} />} icon={<Icon size={20} />}
color={item.color} color={item.color}
loading={isLoadingReports} loading={isLoadingReports}

View File

@@ -256,9 +256,9 @@ export default function RidersSummary() {
}, [rows]); }, [rows]);
const KPI_META = [ const KPI_META = [
{ key: 'riders', label: 'Active Riders', color: BRAND, icon: MdDirectionsBike, value: stats.riders }, { key: 'riders', label: 'Active Riders', color: BRAND, icon: MdDirectionsBike, value: stats.riders, meta: `${stats.riders} riders` },
{ key: 'orders', label: 'Total Orders', color: '#0ea5e9', icon: MdOutlineLocalShipping, value: stats.orders }, { key: 'orders', label: 'Total Orders', color: '#0ea5e9', icon: MdOutlineLocalShipping, value: stats.orders, meta: `${stats.orders} orders` },
{ key: 'delivered', label: 'Delivered', color: '#10b981', icon: MdOutlineCheckCircle, value: stats.delivered }, { key: 'delivered', label: 'Delivered', color: '#10b981', icon: MdOutlineCheckCircle, value: stats.delivered, meta: stats.orders > 0 ? `${Math.round((stats.delivered / stats.orders) * 100)}% of orders` : null },
{ key: 'amount', label: 'Total Amount', color: '#f59e0b', icon: MdOutlineCurrencyRupee, value: formatNumberToRupees(total) } { key: 'amount', label: 'Total Amount', color: '#f59e0b', icon: MdOutlineCurrencyRupee, value: formatNumberToRupees(total) }
]; ];
@@ -523,7 +523,9 @@ export default function RidersSummary() {
<StatCard <StatCard
key={item.key} key={item.key}
title={item.label} title={item.label}
range={item.range}
value={item.value ?? 0} value={item.value ?? 0}
meta={item.meta}
icon={<Icon size={20} />} icon={<Icon size={20} />}
color={item.color} color={item.color}
loading={isLoadingReports} loading={isLoadingReports}

View File

@@ -42,6 +42,7 @@ import {
MdArrowForward, MdArrowForward,
MdDelete, MdDelete,
MdBlock, MdBlock,
MdDirectionsBike,
MdDirectionsCar MdDirectionsCar
} from 'react-icons/md'; } from 'react-icons/md';
@@ -591,19 +592,24 @@ const Riders = () => {
{ {
id: 'rider', id: 'rider',
accessorFn: (row) => row.displayname || row.authname || '—', accessorFn: (row) => row.displayname || row.authname || '—',
size: 200, size: 220,
minSize: 160, minSize: 180,
meta: { label: 'Rider' }, meta: { label: 'Rider' },
header: () => <SectionLabel>Rider</SectionLabel>, header: () => <SectionLabel>Rider</SectionLabel>,
cell: ({ getValue, row }) => ( cell: ({ getValue, row }) => (
<VStack gap={0} padding={0} minWidth={0}> <HStack gap={1.5} vAlign="center">
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}> <AccentAvatar color="#8b5cf6" size={28}>
{getValue()} <MdDirectionsBike size={14} />
</Text> </AccentAvatar>
<Text type="supporting" color="secondary"> <VStack gap={0} padding={0} minWidth={0}>
{row.original.phone || '—'} <Text weight="bold" maxLines={1} style={{ fontSize: 13, color: DT.textPrimary }}>
</Text> {getValue()}
</VStack> </Text>
<Text type="supporting" color="secondary" style={{ fontSize: 11 }}>
{row.original.phone || '—'}
</Text>
</VStack>
</HStack>
) )
}, },
{ {
@@ -615,13 +621,15 @@ const Riders = () => {
header: () => <SectionLabel>Address</SectionLabel>, header: () => <SectionLabel>Address</SectionLabel>,
cell: ({ row }) => ( cell: ({ row }) => (
<Tooltip content={row.original.address || ''}> <Tooltip content={row.original.address || ''}>
<VStack gap={0} padding={0}> <VStack gap={0.5} padding={0}>
<Text type="supporting" weight="bold" color="secondary" maxLines={1}> <Text weight="medium" maxLines={1} style={{ fontSize: 12 }}>
{row.original.suburb || (row.original.address ? row.original.address.slice(0, 20) + '…' : '—')} {row.original.suburb || row.original.address || '—'}
</Text>
<Text type="supporting" color="secondary" maxLines={1}>
{row.original.city || ''}
</Text> </Text>
{row.original.city && (
<Text type="supporting" color="secondary" maxLines={1} style={{ fontSize: 11 }}>
{row.original.city}
</Text>
)}
</VStack> </VStack>
</Tooltip> </Tooltip>
) )
@@ -772,11 +780,23 @@ const Riders = () => {
<KpiGrid> <KpiGrid>
{KPI_META(allRidersSummary).map((item) => { {KPI_META(allRidersSummary).map((item) => {
const Icon = item.icon; const Icon = item.icon;
const total = allRidersSummary?.total ?? 0;
const share =
item.key === 'total'
? `${item.value} riders`
: total > 0
? `${Math.round((item.value / total) * 100)}% of ${total}`
: null;
// Gloss lines dropped, matching the Orders KPI strip: "Active"
// does not need "on duty & free" underneath it for people who run
// this page all day, and the second line competes with the number
// the card exists to show.
return ( return (
<StatCard <StatCard
key={item.key} key={item.key}
title={item.label} title={item.label}
value={item.value ?? 0} value={item.value ?? 0}
meta={share}
icon={<Icon size={20} />} icon={<Icon size={20} />}
color={item.color} color={item.color}
loading={riderSummarysLoading} loading={riderSummarysLoading}

View File

@@ -45,6 +45,7 @@ import Loader from 'components/Loader';
import TableLoader from 'components/nearle_components/TableLoader'; import TableLoader from 'components/nearle_components/TableLoader';
import PageHeader from 'components/nearle_components/PageHeader'; import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard'; import StatCard from 'components/nearle_components/StatCard';
import StatusBadge from 'components/nearle_components/StatusBadge';
import { PageShell, KpiGrid, DataCard } from 'components/nearle_components/PageLayout'; import { PageShell, KpiGrid, DataCard } from 'components/nearle_components/PageLayout';
import { import {
getTripsheets, getTripsheets,
@@ -207,35 +208,41 @@ const Tripsheets = () => {
) )
}, },
{ {
id: 'sourceHub', id: 'route',
accessorFn: (row) => hubMap.get(row.sourcehubid)?.hubname || (row.sourcehubid ? `Hub #${row.sourcehubid}` : '—'), accessorFn: (row) => `${hubMap.get(row.sourcehubid)?.hubname || ''} ${hubMap.get(row.destinationhubid)?.hubname || ''}`,
size: 220, size: 320,
minSize: 160, minSize: 240,
meta: { label: 'Source Hub' }, meta: { label: 'Route (Source → Destination)' },
header: () => <SectionLabel>Source Hub</SectionLabel>, header: () => <SectionLabel>Route (Source → Destination)</SectionLabel>,
cell: ({ getValue }) => <Text weight="semibold">{getValue()}</Text> cell: ({ row }) => {
}, const src = hubMap.get(row.original.sourcehubid)?.hubname || (row.original.sourcehubid ? `Hub #${row.original.sourcehubid}` : '—');
{ const dest = hubMap.get(row.original.destinationhubid)?.hubname || (row.original.destinationhubid ? `Hub #${row.original.destinationhubid}` : '—');
id: 'destinationHub', return (
accessorFn: (row) => hubMap.get(row.destinationhubid)?.hubname || (row.destinationhubid ? `Hub #${row.destinationhubid}` : '—'), <VStack gap={0.5} padding={0} minWidth={0}>
size: 220, <HStack gap={1} vAlign="center" minWidth={0}>
minSize: 160, <img src="/pickup-icon.png" alt="Pickup" style={{ width: 20, height: 20, flexShrink: 0 }} />
meta: { label: 'Destination Hub' }, <Text weight="semibold" maxLines={1} style={{ fontSize: 12 }}>
header: () => <SectionLabel>Destination Hub</SectionLabel>, {src}
cell: ({ getValue }) => <Text weight="semibold">{getValue()}</Text> </Text>
</HStack>
<HStack gap={1} vAlign="center" minWidth={0}>
<img src="/drop-icon.png" alt="Drop" style={{ width: 20, height: 20, flexShrink: 0 }} />
<Text type="supporting" color="secondary" maxLines={1} style={{ fontSize: 12 }}>
{dest}
</Text>
</HStack>
</VStack>
);
}
}, },
{ {
id: 'status', id: 'status',
accessorKey: 'status', accessorKey: 'status',
size: 160, size: 140,
minSize: 120, minSize: 110,
meta: { label: 'Status' }, meta: { label: 'Status' },
header: () => <SectionLabel>Status</SectionLabel>, header: () => <SectionLabel>Status</SectionLabel>,
cell: ({ getValue }) => ( cell: ({ getValue }) => <StatusBadge status={getValue()} />
<Text type="supporting" color="secondary">
{getValue() || '—'}
</Text>
)
}, },
{ {
id: 'actions', id: 'actions',

View File

@@ -6,6 +6,7 @@ import {
MdAdd, MdAdd,
MdBatteryStd, MdBatteryStd,
MdBatteryFull, MdBatteryFull,
MdBatteryChargingFull,
MdArrowUpward, MdArrowUpward,
MdArrowDownward, MdArrowDownward,
MdUnfoldMore MdUnfoldMore
@@ -45,15 +46,16 @@ import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } f
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import Loader from 'components/Loader'; import Loader from 'components/Loader';
import TableLoader from 'components/nearle_components/TableLoader';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar'; import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import PageHeader from 'components/nearle_components/PageHeader'; import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard'; import StatCard from 'components/nearle_components/StatCard';
import TableLoader from 'components/nearle_components/TableLoader'; import StatusBadge from 'components/nearle_components/StatusBadge';
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout'; import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
import { getVehicles, createVehicle, updateVehicle, deleteVehicle, getPartners } from 'pages/api/doormileApi'; import { getVehicles, createVehicle, updateVehicle, deleteVehicle, getPartners } from 'pages/api/doormileApi';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import { DT, STATUS } from 'themes/dt/tokens'; import { DT, STATUS } from 'themes/dt/tokens';
import { TableScroll } from 'themes/dt/primitives'; import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
const BRAND = DT.brand; const BRAND = DT.brand;
const VEHICLE_TYPE_OPTIONS = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'].map((v) => ({ value: v, label: v })); const VEHICLE_TYPE_OPTIONS = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'].map((v) => ({ value: v, label: v }));
@@ -103,6 +105,7 @@ const Vehicles = () => {
const [debouncedSearch, setDebouncedSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState('');
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(emptyForm); const [form, setForm] = useState(emptyForm);
const [activeFilter, setActiveFilter] = useState('all');
// Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's // Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's
// slices. // slices.
const [sorting, setSorting] = useState([]); const [sorting, setSorting] = useState([]);
@@ -114,12 +117,20 @@ const Vehicles = () => {
const partnerMap = useMemo(() => new Map((partners || []).map((p) => [p.partnerid, p])), [partners]); const partnerMap = useMemo(() => new Map((partners || []).map((p) => [p.partnerid, p])), [partners]);
const rows = useMemo(() => { const rows = useMemo(() => {
if (!debouncedSearch) return vehicles; let list = vehicles;
if (activeFilter === 'available') {
list = list.filter((v) => String(v.status).toLowerCase() === 'available');
} else if (activeFilter === 'lowBattery') {
list = list.filter((v) => Number(v.batterypercentage) > 0 && Number(v.batterypercentage) < 20);
} else if (activeFilter === 'fullBattery') {
list = list.filter((v) => Number(v.batterypercentage) >= 80);
}
if (!debouncedSearch) return list;
const q = debouncedSearch.toLowerCase().trim(); const q = debouncedSearch.toLowerCase().trim();
return vehicles.filter((row) => return list.filter((row) =>
[row.vehicleno, row.vehicletype, row.status].filter(Boolean).some((f) => String(f).toLowerCase().includes(q)) [row.vehicleno, row.vehicletype, row.status].filter(Boolean).some((f) => String(f).toLowerCase().includes(q))
); );
}, [vehicles, debouncedSearch]); }, [vehicles, activeFilter, debouncedSearch]);
const stats = useMemo(() => { const stats = useMemo(() => {
const total = vehicles.length; const total = vehicles.length;
@@ -234,20 +245,25 @@ const Vehicles = () => {
{ {
id: 'vehicleno', id: 'vehicleno',
accessorKey: 'vehicleno', accessorKey: 'vehicleno',
size: 160, size: 200,
minSize: 130, minSize: 160,
meta: { label: 'Vehicle No' }, meta: { label: 'Vehicle' },
header: () => <SectionLabel>Vehicle No</SectionLabel>, header: () => <SectionLabel>Vehicle</SectionLabel>,
cell: ({ getValue }) => <Text weight="bold">{getValue()}</Text> cell: ({ row }) => (
}, <HStack gap={1.5} vAlign="center">
{ <AccentAvatar color={BRAND} size={28}>
id: 'vehicletype', <MdDirectionsCar size={14} />
accessorKey: 'vehicletype', </AccentAvatar>
size: 130, <VStack gap={0} padding={0} minWidth={0}>
minSize: 100, <Text weight="bold" maxLines={1} style={{ fontSize: 13, color: DT.textPrimary }}>
meta: { label: 'Type' }, {row.original.vehicleno}
header: () => <SectionLabel>Type</SectionLabel>, </Text>
cell: ({ getValue }) => getValue() || '—' <Text type="supporting" color="secondary" style={{ fontSize: 11 }}>
{row.original.vehicletype || 'Vehicle'}
</Text>
</VStack>
</HStack>
)
}, },
{ {
id: 'partner', id: 'partner',
@@ -256,25 +272,41 @@ const Vehicles = () => {
minSize: 140, minSize: 140,
meta: { label: 'Partner' }, meta: { label: 'Partner' },
header: () => <SectionLabel>Partner</SectionLabel>, header: () => <SectionLabel>Partner</SectionLabel>,
cell: ({ getValue }) => getValue() || '—' cell: ({ getValue, row }) => (
<VStack gap={0} padding={0} minWidth={0}>
<Text weight="semibold" maxLines={1} style={{ fontSize: 12 }}>
{getValue() || '—'}
</Text>
{row.original.partnerid && (
<Text type="supporting" color="secondary" style={{ fontSize: 11 }}>
ID #{row.original.partnerid}
</Text>
)}
</VStack>
)
}, },
{ {
id: 'maxweight', id: 'specs',
accessorKey: 'maxweight', accessorFn: (row) => `${row.maxweight || ''} ${row.batterypercentage || ''}`,
size: 130, size: 160,
minSize: 100, minSize: 130,
meta: { label: 'Max Weight' }, meta: { label: 'Weight & Battery' },
header: () => <SectionLabel>Max Weight</SectionLabel>, header: () => <SectionLabel>Weight & Battery</SectionLabel>,
cell: ({ getValue }) => getValue() ?? '—' cell: ({ row }) => (
}, <VStack gap={0.5} padding={0} minWidth={0}>
{ <Text weight="medium" style={{ fontSize: 12 }}>
id: 'battery', {row.original.maxweight != null ? `${row.original.maxweight} kg max` : '—'}
accessorKey: 'batterypercentage', </Text>
size: 110, {row.original.batterypercentage != null && (
minSize: 90, <HStack gap={0.5} vAlign="center">
meta: { label: 'Battery' }, <MdBatteryChargingFull size={12} color="#10b981" />
header: () => <SectionLabel>Battery</SectionLabel>, <Text type="supporting" color="secondary" style={{ fontSize: 11 }}>
cell: ({ getValue }) => (getValue() != null ? `${getValue()}%` : '—') {row.original.batterypercentage}% battery
</Text>
</HStack>
)}
</VStack>
)
}, },
{ {
id: 'status', id: 'status',
@@ -283,12 +315,12 @@ const Vehicles = () => {
minSize: 100, minSize: 100,
meta: { label: 'Status' }, meta: { label: 'Status' },
header: () => <SectionLabel>Status</SectionLabel>, header: () => <SectionLabel>Status</SectionLabel>,
cell: ({ getValue }) => getValue() || '—' cell: ({ getValue }) => <StatusBadge status={getValue()} />
}, },
{ {
id: 'actions', id: 'actions',
size: 120, size: 100,
minSize: 100, minSize: 90,
enableSorting: false, enableSorting: false,
enableResizing: false, enableResizing: false,
meta: { label: 'Actions', align: 'right' }, meta: { label: 'Actions', align: 'right' },
@@ -315,7 +347,6 @@ const Vehicles = () => {
) )
} }
], ],
// eslint-disable-next-line react-hooks/exhaustive-deps
[partnerMap, pagination.pageIndex, pagination.pageSize] [partnerMap, pagination.pageIndex, pagination.pageSize]
); );
@@ -354,10 +385,46 @@ const Vehicles = () => {
/> />
<KpiGrid> <KpiGrid>
<StatCard title="Total Vehicles" value={stats.total} icon={<MdDirectionsCar size={20} />} color={BRAND} /> <StatCard
<StatCard title="Available" value={stats.active} icon={<MdDirectionsCar size={20} />} color={STATUS.delivered} /> title="Total Vehicles"
<StatCard title="Low Battery" value={stats.lowBattery} icon={<MdBatteryStd size={20} />} color={STATUS.pending} /> value={stats.total}
<StatCard title="Full Battery" value={stats.fullBattery} icon={<MdBatteryFull size={20} />} color={STATUS.active} /> meta={`${stats.total} vehicles`}
icon={<MdDirectionsCar size={20} />}
color={BRAND}
clickable
isSelected={activeFilter === 'all'}
onClick={() => setActiveFilter('all')}
/>
<StatCard
title="Available"
value={stats.active}
meta={`${stats.total > 0 ? Math.round((stats.active / stats.total) * 100) : 0}% of ${stats.total}`}
icon={<MdDirectionsCar size={20} />}
color={STATUS.delivered}
clickable
isSelected={activeFilter === 'available'}
onClick={() => setActiveFilter((prev) => (prev === 'available' ? 'all' : 'available'))}
/>
<StatCard
title="Low Battery"
value={stats.lowBattery}
meta={`${stats.total > 0 ? Math.round((stats.lowBattery / stats.total) * 100) : 0}% of ${stats.total}`}
icon={<MdBatteryStd size={20} />}
color={STATUS.pending}
clickable
isSelected={activeFilter === 'lowBattery'}
onClick={() => setActiveFilter((prev) => (prev === 'lowBattery' ? 'all' : 'lowBattery'))}
/>
<StatCard
title="Full Battery"
value={stats.fullBattery}
meta={`${stats.total > 0 ? Math.round((stats.fullBattery / stats.total) * 100) : 0}% of ${stats.total}`}
icon={<MdBatteryFull size={20} />}
color={STATUS.active}
clickable
isSelected={activeFilter === 'fullBattery'}
onClick={() => setActiveFilter((prev) => (prev === 'fullBattery' ? 'all' : 'fullBattery'))}
/>
</KpiGrid> </KpiGrid>
<DataCard <DataCard

View File

@@ -93,6 +93,8 @@ export const doormileTheme = defineTheme({
}, },
components: { components: {
card: { base: { borderRadius: `${DT.radiusCard}px` } }, card: { base: { borderRadius: `${DT.radiusCard}px` } },
'text-input': { base: { borderRadius: `${DT.radiusField}px` } } 'text-input': { base: { borderRadius: `${DT.radiusField}px` } },
selector: { base: { borderRadius: `${DT.radiusField}px` } },
button: { base: { borderRadius: `${DT.radiusField}px` } }
} }
}); });

View File

@@ -26,6 +26,9 @@
// dot — Astryx <StatusDot variant>, which only has five values, so // dot — Astryx <StatusDot variant>, which only has five values, so
// several lifecycle states collapse onto `accent` here. // several lifecycle states collapse onto `accent` here.
// icon — react-icons component (not an element) so callers size it. // icon — react-icons component (not an element) so callers size it.
// hint — a two-or-three word definition of the state, for surfaces that
// show the name and its meaning together (SegmentCard's `range`).
// Deliberately terse: it sits beside the label, not under it.
// ============================================================================ // ============================================================================
import { import {
MdHourglassEmpty, MdHourglassEmpty,
@@ -41,16 +44,16 @@ import {
} from 'react-icons/md'; } from 'react-icons/md';
export const STATUS_META = { export const STATUS_META = {
all: { label: 'All', color: '#000000', badge: 'neutral', dot: 'neutral', icon: MdList }, all: { label: 'All', hint: 'every status', color: '#000000', badge: 'neutral', dot: 'neutral', icon: MdList },
pending: { label: 'Pending', color: '#f59e0b', badge: 'yellow', dot: 'warning', icon: MdHourglassEmpty }, pending: { label: 'Pending', hint: 'awaiting rider', color: '#f59e0b', badge: 'yellow', dot: 'warning', icon: MdHourglassEmpty },
accepted: { label: 'Accepted', color: '#6366f1', badge: 'blue', dot: 'accent', icon: MdPersonPin }, accepted: { label: 'Accepted', hint: 'rider assigned', color: '#6366f1', badge: 'blue', dot: 'accent', icon: MdPersonPin },
arrived: { label: 'Arrived', color: '#06b6d4', badge: 'cyan', dot: 'accent', icon: MdLocationOn }, arrived: { label: 'Arrived', hint: 'at pickup', color: '#06b6d4', badge: 'cyan', dot: 'accent', icon: MdLocationOn },
picked: { label: 'Picked', color: '#8b5cf6', badge: 'purple', dot: 'accent', icon: MdInventory2 }, picked: { label: 'Picked', hint: 'parcel collected', color: '#8b5cf6', badge: 'purple', dot: 'accent', icon: MdInventory2 },
active: { label: 'Active', color: '#14b8a6', badge: 'teal', dot: 'success', icon: MdRoute }, active: { label: 'Active', hint: 'in transit', color: '#14b8a6', badge: 'teal', dot: 'success', icon: MdRoute },
skipped: { label: 'Skipped', color: '#f97316', badge: 'orange', dot: 'warning', icon: MdSkipNext }, skipped: { label: 'Skipped', hint: 'attempt failed', color: '#f97316', badge: 'orange', dot: 'warning', icon: MdSkipNext },
delivered: { label: 'Delivered', color: '#10b981', badge: 'green', dot: 'success', icon: MdCheckCircle }, delivered: { label: 'Delivered', hint: 'completed', color: '#10b981', badge: 'green', dot: 'success', icon: MdCheckCircle },
cancelled: { label: 'Cancelled', color: '#ef4444', badge: 'error', dot: 'error', icon: MdCancel }, cancelled: { label: 'Cancelled', hint: 'not fulfilled', color: '#ef4444', badge: 'error', dot: 'error', icon: MdCancel },
inactive: { label: 'Inactive', color: '#ef4444', badge: 'red', dot: 'error', icon: MdCancel } inactive: { label: 'Inactive', hint: 'not in service', color: '#ef4444', badge: 'red', dot: 'error', icon: MdCancel }
}; };
// Raw backend enums that render as one of the states above. Kept separate from // Raw backend enums that render as one of the states above. Kept separate from

View File

@@ -38,11 +38,11 @@ export const DT = {
// shaped like pills or circles (status chips, avatars, dots). Squaring those // shaped like pills or circles (status chips, avatars, dots). Squaring those
// doesn't make them consistent, it makes them look broken. // doesn't make them consistent, it makes them look broken.
radiusPill: 999, radiusPill: 999,
radiusPage: 20, radiusPage: 16,
radiusCard: 16, radiusCard: 12,
radiusInner: 12, radiusInner: 6,
radiusField: 12, radiusField: 6,
radiusSm: 8, radiusSm: 4,
// ---- Elevation ----------------------------------------------------- // ---- Elevation -----------------------------------------------------
// Restrained, low-contrast — corporate (Linear/Stripe), not flashy. // Restrained, low-contrast — corporate (Linear/Stripe), not flashy.