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:
BIN
public/drop-icon.png
Normal file
BIN
public/drop-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 609 KiB |
BIN
public/pickup-icon.png
Normal file
BIN
public/pickup-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 810 KiB |
@@ -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() },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
101
src/components/nearle_components/SegmentCard.js
Normal file
101
src/components/nearle_components/SegmentCard.js
Normal 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;
|
||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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` } }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user