diff --git a/public/drop-icon.png b/public/drop-icon.png new file mode 100644 index 0000000..0ed31ad Binary files /dev/null and b/public/drop-icon.png differ diff --git a/public/pickup-icon.png b/public/pickup-icon.png new file mode 100644 index 0000000..1f8d416 Binary files /dev/null and b/public/pickup-icon.png differ diff --git a/src/components/DateFilterDialog.js b/src/components/DateFilterDialog.js index 50337c2..07950ec 100644 --- a/src/components/DateFilterDialog.js +++ b/src/components/DateFilterDialog.js @@ -29,6 +29,9 @@ const DateFilterDialog = ({ open, onClose, onSelect }) => { open={open} toggle={onClose} 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} definedRanges={[ { label: 'Today', startDate: new Date(), endDate: new Date() }, diff --git a/src/components/nearle_components/DateRangePicker.js b/src/components/nearle_components/DateRangePicker.js index 10c468c..119ec96 100644 --- a/src/components/nearle_components/DateRangePicker.js +++ b/src/components/nearle_components/DateRangePicker.js @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import dayjs from 'dayjs'; 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 // DatePicker spoke dayjs objects. The conversion happens here so the // `onChange` contract (native Date objects) is unchanged for callers. -export const DateRangePicker = ({ onChange, definedRanges = [] }) => { - const [customStart, setCustomStart] = useState(undefined); - const [customEnd, setCustomEnd] = useState(undefined); +const toISO = (d) => (d ? dayjs(d).format('YYYY-MM-DD') : 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) => { + setCustomStart(toISO(range.startDate)); + setCustomEnd(toISO(range.endDate)); onChange({ startDate: range.startDate, endDate: range.endDate, label: range.label }); }; const pickCustom = (nextStart, nextEnd) => { - if (nextStart && nextEnd) { - onChange({ startDate: dayjs(nextStart).toDate(), endDate: dayjs(nextEnd).toDate(), label: undefined }); - } + if (!nextStart || !nextEnd) return; + + // 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 ( @@ -51,6 +76,7 @@ export const DateRangePicker = ({ onChange, definedRanges = [] }) => { { setCustomStart(v); @@ -75,7 +101,13 @@ export const DateRangePicker = ({ onChange, definedRanges = [] }) => { DateRangePicker.propTypes = { 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; diff --git a/src/components/nearle_components/PageLayout.js b/src/components/nearle_components/PageLayout.js index 54cceea..2bded21 100644 --- a/src/components/nearle_components/PageLayout.js +++ b/src/components/nearle_components/PageLayout.js @@ -109,12 +109,18 @@ PageShell.propTypes = { // // 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; return ( - + {children} ); @@ -124,7 +130,9 @@ KpiGrid.propTypes = { children: PropTypes.node, minWidth: 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 }) { return ( - + // `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. + {children} diff --git a/src/components/nearle_components/SegmentCard.js b/src/components/nearle_components/SegmentCard.js new file mode 100644 index 0000000..48af1cc --- /dev/null +++ b/src/components/nearle_components/SegmentCard.js @@ -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 ( + + ); +}; + +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={}. */ + 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; diff --git a/src/components/nearle_components/StatCard.js b/src/components/nearle_components/StatCard.js index 28422b5..897b49c 100644 --- a/src/components/nearle_components/StatCard.js +++ b/src/components/nearle_components/StatCard.js @@ -1,32 +1,18 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Card } from '@astryxdesign/core/Card'; import { Skeleton } from '@astryxdesign/core/Skeleton'; - import { DT, STATUS } from 'themes/dt/tokens'; // ==============================|| STAT / KPI CARD ||============================== // // -// Ultra-compact, dense, space-efficient enterprise KPI component. -// Shared across Deliveries, Orders, Pricing, Tenants, Riders, Customers, Hubs, -// Vehicles, Tripsheets, Exceptions, App Users, Reports and Dispatch. -// -// --------------------------------------------------------------------------- -// ULTRA-COMPACT HORIZONTAL COMPOSITION (~48px target height) -// --------------------------------------------------------------------------- -// 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; +// Replica of the Orders page KPI / SegmentCard design across all console pages. +// Structure: +// - 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 +// - Rule: inset 2px accent line along the bottom +// - Active state: full border in the accent colour when selected const VARIANTS = { neutral: STATUS.muted, @@ -39,186 +25,80 @@ const VARIANTS = { export default function StatCard({ title, + label, value, icon, color, variant = 'brand', caption, + meta, + range, loading = false, onClick, clickable, + isSelected = false, className = '', style = {} }) { 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 ( - { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onClick(e); - } - } - : undefined - } + disabled={!isInteractive} + aria-pressed={isInteractive ? isSelected : undefined} style={{ - position: 'relative', - display: 'flex', - 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%', + borderColor: isSelected ? accent : DT.borderSubtle, + cursor: isInteractive ? 'pointer' : 'default', ...style }} > -
+ + + {displayLabel} + {displayRange && {displayRange}} + {icon && ( -
+
+
)} + -
-
- {loading ? ( - - ) : ( - - {value} - - )} + {loading ? ( + + ) : ( + + {value} + + )} - - {title} - -
+ {meta && {meta}} - {caption && ( - - {caption} - - )} -
-
-
+
); } @@ -66,6 +100,10 @@ StatusTabs.propTypes = { isUrgent: PropTypes.bool }) ).isRequired, + /** Accepted for call-site compatibility with the previous TabList API. */ 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 }; diff --git a/src/globalPolish.css b/src/globalPolish.css index 7880383..f940228 100644 --- a/src/globalPolish.css +++ b/src/globalPolish.css @@ -157,6 +157,188 @@ body { 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). diff --git a/src/layout/MainLayout/AppSideNav.js b/src/layout/MainLayout/AppSideNav.js index bc1ad51..44df06d 100644 --- a/src/layout/MainLayout/AppSideNav.js +++ b/src/layout/MainLayout/AppSideNav.js @@ -54,8 +54,17 @@ const AppSideNav = ({ isCollapsed, onCollapsedChange }) => { collapsible={{ isCollapsed, onCollapsedChange, buttonLabel: 'Collapse navigation' }} style={{ backgroundColor: DT.surface, - borderRight: `1px solid ${DT.borderSubtle}`, - boxShadow: '1px 0 3px rgba(15, 23, 42, 0.03)', + // One hairline, and nothing else. This edge previously carried a + // 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', paddingInline: '8px', 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. Setting the font-size is what actually scales the mark. */ .doormile-side-nav .astryx-side-nav-item[aria-label] .astryx-icon { - font-size: 20px !important; - width: 20px !important; - height: 20px !important; + font-size: 18px !important; + width: 18px !important; + height: 18px !important; margin: 0 !important; display: flex; align-items: center; @@ -115,7 +124,7 @@ const AppSideNav = ({ isCollapsed, onCollapsedChange }) => { to sit comfortably beside it. Still bumped off the inherited 14px, which was undersized against a 14px label. */ .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 { width: 1em; diff --git a/src/pages/nearle/appUsers/appUsers.js b/src/pages/nearle/appUsers/appUsers.js index 0077fb3..3197090 100644 --- a/src/pages/nearle/appUsers/appUsers.js +++ b/src/pages/nearle/appUsers/appUsers.js @@ -1,5 +1,5 @@ -import { useEffect, useMemo, useState } from 'react'; -import { MdOutlineAdminPanelSettings, MdEdit, MdDeleteOutline, MdAdd, MdArrowUpward, MdArrowDownward, MdUnfoldMore } from 'react-icons/md'; +import React, { useEffect, useMemo, useState } from 'react'; +import { MdOutlineAdminPanelSettings, MdPerson, MdEdit, MdDeleteOutline, MdAdd, MdArrowUpward, MdArrowDownward, MdUnfoldMore } from 'react-icons/md'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { enqueueSnackbar } from 'notistack'; @@ -30,6 +30,7 @@ import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; import { TextInput } from '@astryxdesign/core/TextInput'; import { Selector } from '@astryxdesign/core/Selector'; +import { Badge } from '@astryxdesign/core/Badge'; import { EmptyState } from '@astryxdesign/core/EmptyState'; import { Pagination } from '@astryxdesign/core/Pagination'; 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 PageHeader from 'components/nearle_components/PageHeader'; 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 { getAppUsers, createAppUser, updateAppUser, deleteAppUser } from 'pages/api/doormileApi'; import { DT } from 'themes/dt/tokens'; @@ -205,20 +207,25 @@ const AppUsers = () => { { id: 'name', accessorKey: 'first_name', - size: 200, - minSize: 150, - meta: { label: 'Name' }, - header: () => Name, - cell: ({ getValue }) => {getValue()} - }, - { - id: 'email', - accessorKey: 'email', - size: 220, - minSize: 160, - meta: { label: 'Email' }, - header: () => Email, - cell: ({ getValue }) => getValue() + size: 260, + minSize: 200, + meta: { label: 'User' }, + header: () => User, + cell: ({ row }) => ( + + + + + + + {row.original.first_name} {row.original.last_name || ''} + + + {row.original.email} + + + + ) }, { id: 'phone', @@ -227,16 +234,20 @@ const AppUsers = () => { minSize: 110, meta: { label: 'Phone' }, header: () => Phone, - cell: ({ getValue }) => getValue() || '—' + cell: ({ getValue }) => ( + + {getValue() || '—'} + + ) }, { id: 'role', accessorFn: (row) => roleLabel(row.role), - size: 130, - minSize: 100, + size: 140, + minSize: 110, meta: { label: 'Role' }, header: () => Role, - cell: ({ getValue }) => getValue() + cell: ({ getValue }) => }, { id: 'status', @@ -245,7 +256,7 @@ const AppUsers = () => { minSize: 100, meta: { label: 'Status' }, header: () => Status, - cell: ({ getValue }) => getValue() || '—' + cell: ({ getValue }) => }, { id: 'actions', @@ -312,8 +323,20 @@ const AppUsers = () => { /> - } color={BRAND} /> - } color="#6366f1" /> + } + color={BRAND} + /> + 0 ? Math.round((stats.admins / stats.total) * 100) : 0}% of ${stats.total}`} + icon={} + color="#6366f1" + /> { if (!options.length) { return ( + {/* 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`. */} {step.id === 'pickuplocationid' ? '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.'} + } + onClick={() => deleteConversation(c.id)} + /> + + )) + )} + + ) : hasThread ? ( {messages.map((m) => ( { conversation the strip drops to this page's four primary suggestions — twenty chips permanently above the composer would claim ~260px of a panel whose whole job is the conversation. */} - {suggestionItems.length > 0 && ( + {view === 'chat' && suggestionItems.length > 0 && ( {hasThread ? (nextSteps.length > 0 ? 'Next steps' : 'Ask something else') : 'Suggested questions'} diff --git a/src/pages/nearle/assistant/DoormileAI/AIRowsStep.js b/src/pages/nearle/assistant/DoormileAI/AIRowsStep.js index c458ae2..d6d66b4 100644 --- a/src/pages/nearle/assistant/DoormileAI/AIRowsStep.js +++ b/src/pages/nearle/assistant/DoormileAI/AIRowsStep.js @@ -100,8 +100,12 @@ const AIRowsStep = ({ onSubmit, onCancel, isBusy }) => { )} - {parsed.errors.slice(0, 5).map((e) => ( - + {/* Keyed by line AND index: one row can fail several validations, so + `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) => ( + Line {e.line}: {e.reason} ))} diff --git a/src/pages/nearle/assistant/DoormileAI/pageContext.js b/src/pages/nearle/assistant/DoormileAI/pageContext.js index 61cbce8..dbb3ca1 100644 --- a/src/pages/nearle/assistant/DoormileAI/pageContext.js +++ b/src/pages/nearle/assistant/DoormileAI/pageContext.js @@ -65,7 +65,7 @@ export const CHIP_LABELS = { 'How many orders this week?': 'Orders this week', 'Total revenue this week': 'Revenue this week', 'How many customers do we have?': 'Customers', - 'Assign a rider': 'Assign rider', + 'Assign a rider': 'Assign order', "Repeat yesterday's orders": 'Repeat yesterday' }; @@ -81,6 +81,13 @@ const ASSIGN_TEXT = 'Assign a rider'; // blank create form. 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 = { label: 'Orders', suggestions: [ @@ -215,12 +222,14 @@ const FOLLOW_UPS = { // 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 - // 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 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'], '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?'], + [ORDER_CREATED]: [ASSIGN_TEXT, 'How many pending orders today?', 'Which orders are delayed?', 'Create an order'], // --- order volume --- "Give me today's operations summary": [ diff --git a/src/pages/nearle/assistant/assignActions.js b/src/pages/nearle/assistant/assignActions.js index 806833c..c93fe8b 100644 --- a/src/pages/nearle/assistant/assignActions.js +++ b/src/pages/nearle/assistant/assignActions.js @@ -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'; // ==============================|| 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. // It is deliberately NOT allowed to fail the assignment — the order IS // 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) { try { await notifyRider(rider.milerprofileid); + notified = true; sourceCalls.push({ name: 'notifyRider', 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) { // doormileAxios rejects with the response BODY; the status rides on // 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 -// set across riders within range and then sequences each one's stops. Its reply -// is per-booking, so a partial result is reported per row rather than as a bare -// success. -export const executeBatchAssign = async (bookingIds, maxPerRider = 5) => { +// A repeated run is the SAME drops to the SAME doors. The rider who did them +// yesterday already knows the buzzer, the gate code and which side of the +// building to park on, so re-deriving an assignment from scratch throws away +// the one piece of routing knowledge the previous day produced. +// +// 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 call = { - name: 'batchAssignBookings', - target: 'POST /hub/bookings/batch-assign', - stats: `${bookingIds.length} order${bookingIds.length === 1 ? '' : 's'}` - }; - try { - const res = await batchAssignBookings(bookingIds, maxPerRider); - const duration = `${Date.now() - started}ms`; - const results = Array.isArray(res?.results) ? res.results : null; - const assigned = Number.isFinite(res?.assigned) ? res.assigned : results?.filter((r) => r.assigned).length; + // Only rows whose source order actually had a rider. A blank one is not a + // failure — yesterday's copy was never assigned either. + const targets = (createdPairs || []) + .map(({ index, bookingid }) => ({ bookingid, mileruserid: rows?.[index]?.__previousMilerUserId ?? null })) + .filter((t) => t.mileruserid != null); - const sourceCalls = [ - { - ...call, - duration, - status: 'complete', - stats: `${assigned ?? '?'} assigned, ${res?.riderssequenced ?? 0} rider route${res?.riderssequenced === 1 ? '' : 's'} sequenced` - } - ]; + if (!targets.length) { + return { ok: true, assigned: 0, skipped: (createdPairs || []).length, failures: [], notified: 0, sourceCalls: [] }; + } - // One push per rider, not per order — ten orders to one rider is one buzz. - const { lookup } = await loadRiders().catch(() => ({ lookup: null })); - const notified = new Set(); - if (results && lookup) { - // eslint-disable-next-line no-restricted-syntax - for (const r of results) { - const rider = r.mileruserid ? lookup.byUserId.get(String(r.mileruserid)) : null; - if (rider?.milerprofileid && !notified.has(rider.milerprofileid)) { - notified.add(rider.milerprofileid); + const { lookup } = await loadRiders().catch(() => ({ lookup: null })); + const assignedRiders = new Set(); + const failures = []; + let assigned = 0; + + // Sequential on purpose. These are writes against real dispatch records, and + // firing a burst of them concurrently makes a partial failure much harder to + // report accurately — which order did not land, and to whom. + // 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 - 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` - }); } - - return { - ok: (assigned ?? 0) > 0, - assigned: assigned ?? 0, - skipped: res?.skipped ?? Math.max(0, bookingIds.length - (assigned ?? 0)), - 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 }] - }; + sourceCalls.push({ + name: 'notifyRider', + target: 'POST /admin/milers/:id/notify', + status: notified === assignedRiders.size ? 'complete' : 'error', + stats: `${notified} of ${assignedRiders.size} rider${assignedRiders.size === 1 ? '' : 's'} notified` + }); } + + return { + ok: assigned > 0, + assigned, + skipped: (createdPairs || []).length - targets.length, + failures, + notified, + riders: assignedRiders.size, + sourceCalls + }; }; diff --git a/src/pages/nearle/assistant/assignFlow.js b/src/pages/nearle/assistant/assignFlow.js index ab1bf0c..b5e0570 100644 --- a/src/pages/nearle/assistant/assignFlow.js +++ b/src/pages/nearle/assistant/assignFlow.js @@ -1,4 +1,5 @@ import { scanBookings } from './intents'; +import { getStatusMeta } from 'themes/dt/status'; import { loadRiders, riderOptions, currentAssignee, describeRider } from './assignActions'; import { advanceFlow, startFlow, answerFlowStep } from './flowEngine'; @@ -25,10 +26,56 @@ import { advanceFlow, startFlow, answerFlowStep } from './flowEngine'; export const ASSIGN_STEPS = [ { id: 'bookingno', - type: 'text', - 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. + // A SELECT, not a text field. + // + // 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, + 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) => { const needle = String(raw || '') .trim() @@ -36,14 +83,32 @@ export const ASSIGN_STEPS = [ if (!needle) return { error: 'I need an order number.' }; const scan = await scanBookings(); - const found = scan.rows.find( - (b) => - String(b.bookingno || '').toLowerCase() === needle || - String(b.bookingid) === needle || - String(b.bookingno || '') - .toLowerCase() - .includes(needle) + + // EXACT matches win across the whole list before any fuzzy one is + // considered. The old version tested all three conditions per row inside + // a single find(), so row ORDER decided the winner: a row whose + // bookingno merely CONTAINED the needle could match before the row whose + // bookingid actually equalled it. + // + // 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) { return { error: scan.truncated @@ -84,10 +149,34 @@ export const ASSIGN_STEPS = [ const { milers } = await loadRiders(); return riderOptions(milers); }, - // The whole miler record travels with the answer: the assign call needs its - // `userid` and the notification needs its `milerprofileid`, and those are - // different fields on the same record. - apply: (d, v, option) => ({ ...d, mileruserid: v, __rider: option?.record }) + // Resolved rather than taken straight from the clicked option. + // + // `apply` used to read `option?.record`, which is only populated when a + // 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 }) } ]; diff --git a/src/pages/nearle/assistant/bulkOrderActions.js b/src/pages/nearle/assistant/bulkOrderActions.js index b01a382..5e36bf7 100644 --- a/src/pages/nearle/assistant/bulkOrderActions.js +++ b/src/pages/nearle/assistant/bulkOrderActions.js @@ -202,6 +202,8 @@ export const executeCreateBulk = async (rows, shared) => { // 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. 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) { const batch = batches[b]; @@ -209,7 +211,22 @@ export const executeCreateBulk = async (rows, shared) => { try { // eslint-disable-next-line no-await-in-loop 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) { 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. if (perRow) { 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 { 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 { @@ -265,6 +290,7 @@ export const executeCreateBulk = async (rows, shared) => { // Empty when the endpoint returned no per-row array — the caller must treat // "no ids" as "cannot offer assignment", not as "nothing was created". createdIds, + createdPairs, failed: failures.length, failures, batches: batches.length, diff --git a/src/pages/nearle/assistant/repeatRuns.js b/src/pages/nearle/assistant/repeatRuns.js index 4ae1350..df41b48 100644 --- a/src/pages/nearle/assistant/repeatRuns.js +++ b/src/pages/nearle/assistant/repeatRuns.js @@ -111,6 +111,11 @@ const toRow = (booking, customer, index) => ({ itemdescription: booking.parcels?.[0]?.itemdescription || 'Order', quantity: Math.max(1, booking.parcels?.length || 1), 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: { address: booking.pickupaddress, pincode: booking.pickuppincode, diff --git a/src/pages/nearle/clients/Tenants.js b/src/pages/nearle/clients/Tenants.js index 05117f6..cbb93df 100644 --- a/src/pages/nearle/clients/Tenants.js +++ b/src/pages/nearle/clients/Tenants.js @@ -1298,33 +1298,33 @@ const Clients1 = () => { {KPI_META.map((item) => { const Icon = item.icon; const value = tenantStatusCounts[item.countKey] ?? 0; - // 'total' has no matching entry in STATUS_TABS (there's no "All" - // tab) — only the three status tiles switch tabs when clicked. + const total = tenantStatusCounts.total ?? 0; const tabIdx = STATUS_TABS.findIndex((t) => t.countKey === item.countKey); const clickable = tabIdx !== -1; + const share = + item.countKey === 'total' + ? `${value} clients` + : total > 0 + ? `${Math.round((value / total) * 100)}% of ${total}` + : null; + return ( -
} + color={item.color} + loading={getalltenantsIsLoading} + clickable={clickable} + isSelected={clickable && value0 === tabIdx} onClick={clickable ? () => handleChange(tabIdx) : undefined} - onKeyDown={ - clickable - ? (e) => { - if (e.key === 'Enter' || e.key === ' ') handleChange(tabIdx); - } - : undefined - } - style={{ cursor: clickable ? 'pointer' : 'default', height: '100%' }} - > - } - color={item.color} - loading={getalltenantsIsLoading} - /> -
+ /> ); })} diff --git a/src/pages/nearle/customers/customers.js b/src/pages/nearle/customers/customers.js index 233df8f..d1e0dc7 100644 --- a/src/pages/nearle/customers/customers.js +++ b/src/pages/nearle/customers/customers.js @@ -141,6 +141,7 @@ const Customers = () => { const [addressInput, setAddressInput] = useState(''); const [pickAddress, setPickAddress] = useState({}); const [latLng, setLatLng] = useState({ latitude: '', longitude: '' }); + const [activeFilter, setActiveFilter] = useState('all'); const [sorting, setSorting] = useState([]); const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); const [columnSizing, setColumnSizing] = useState({}); @@ -148,12 +149,18 @@ const Customers = () => { const { data: customers = [], isLoading } = useQuery({ queryKey: ['tenant-customers'], queryFn: getTenantCustomers }); 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(); - return customers.filter((row) => + return list.filter((row) => [custName(row), custPhone(row), custEmail(row)].filter(Boolean).some((f) => String(f).toLowerCase().includes(q)) ); - }, [customers, debouncedSearch]); + }, [customers, activeFilter, debouncedSearch]); const stats = useMemo(() => { const total = customers.length; @@ -389,10 +396,43 @@ const Customers = () => { /> - } color={BRAND} /> - } color="#0ea5e9" /> - } color="#8b5cf6" /> - } color="#10b981" /> + } + color={BRAND} + clickable + isSelected={activeFilter === 'all'} + onClick={() => setActiveFilter('all')} + /> + 0 ? Math.round((stats.withEmail / stats.total) * 100) : 0}% of ${stats.total}`} + icon={} + color="#0ea5e9" + clickable + isSelected={activeFilter === 'withEmail'} + onClick={() => setActiveFilter((prev) => (prev === 'withEmail' ? 'all' : 'withEmail'))} + /> + 0 ? Math.round((stats.withPhone / stats.total) * 100) : 0}% of ${stats.total}`} + icon={} + color="#8b5cf6" + clickable + isSelected={activeFilter === 'withPhone'} + onClick={() => setActiveFilter((prev) => (prev === 'withPhone' ? 'all' : 'withPhone'))} + /> + } + color="#10b981" + /> { setChangeDialogOpen(false); if (res.success) { logger.info( - `Rider changed successfully for order ID ${selectedRow?.orderid}. New Rider: ${ - selectedRider?.displayname || selectedRider?.authname + `Rider changed successfully for order ID ${selectedRow?.orderid}. New Rider: ${selectedRider?.displayname || selectedRider?.authname }` ); opentoast('Rider Changed Successfully', 'success'); @@ -773,7 +759,7 @@ const Deliveries = () => { }, { id: 'serial', - size: 56, + size: 54, minSize: 48, enableSorting: false, meta: { label: '#' }, @@ -792,18 +778,13 @@ const Deliveries = () => { id: 'status', accessorKey: 'orderstatus', size: 130, - minSize: 100, + minSize: 110, meta: { label: 'Status' }, header: () => ( Status ), - // 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 }) => row.original.statusfromconsignment ? ( { ) }, { - id: 'tenant', - accessorKey: 'tenantname', - size: 170, - minSize: 130, - meta: { label: 'Tenant' }, + id: 'orderTenant', + accessorFn: (row) => `${row.orderid} ${row.tenantname}`, + size: 260, + minSize: 210, + meta: { label: 'Order & Tenant' }, header: () => ( - Tenant + Order & Tenant ), - cell: ({ row }) => ( - - - - {row.original.tenantname} - - - {row.original.tenantsuburb} - - - {row.original.applocation} - - - - ) - }, - { - id: 'orderLocation', - accessorFn: (row) => row.orderid, - size: 240, - minSize: 180, - enableSorting: false, - meta: { label: 'Order / Location' }, - header: () => ( - - Order / Location - - ), - // 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 }) => { 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 delivered = parseDoormileTimestamp(r.deliverydate); return ( - - - {r.locationsuburb ? `${r.locationname}-(${r.locationsuburb})` : r.locationname} + + + + {r.orderid} + - - - {r.orderid} - - - Ordered {ordered.format('DD/MM/YYYY · hh:mm A')} - - - - {r.deliveryid} - - - Delivery {delivered.format('DD/MM/YYYY · hh:mm A')} - + + + + {r.tenantname} + + + + · {ordered.isValid() ? ordered.format('hh:mm A') : '—'} + + ); } }, { - id: 'pickup', - accessorKey: 'pickupcustomer', - size: 190, - minSize: 140, - meta: { label: 'Pickup' }, + id: 'route', + accessorFn: (row) => `${row.pickupcustomer} ${row.deliverycustomer}`, + size: 320, + minSize: 260, + meta: { label: 'Route (Pickup → Drop)' }, header: () => ( - Pickup + Route (Pickup → Drop) ), - // pickup — capped to the same maxWidth as drop below, with CSS ellipsis - // truncation, so one long address can't stretch this column wider than - // its neighbour. row.pickuplocation is just the full raw address (see - // fetchDeliveries in api.js), not a pre-shortened name, so the old - // `.slice(0, 14)` fallback never actually ran. Full text still - // available via the tooltip. - cell: ({ row }) => ( - - - {row.original.pickupcustomer} - - - {row.original.pickupcontactno} - - - - {row.original.pickuplocation || row.original.Pickupaddress || '—'} - + cell: ({ row }) => { + const r = row.original; + const pickupAddr = r.pickupsuburb || r.pickuplocation || r.Pickupaddress || ''; + const dropAddr = r.deliverysuburb || r.deliverylocation || r.deliveryaddress || ''; + const routeTooltip = ( +
+
+ Pickup +
+ Pickup: {r.pickupcustomer || 'Pickup'} {r.pickupcontactno ? `(${r.pickupcontactno})` : ''} + {r.Pickupaddress || r.pickuplocation || 'No address'} +
+
+
+ Drop +
+ Drop: {r.deliverycustomer || 'Drop'} {r.deliverycontactno ? `(${r.deliverycontactno})` : ''} + {r.deliveryaddress || r.deliverylocation || 'No address'} +
+
+
+ ); + + return ( + + + + Pickup + + {r.pickupcustomer || 'Pickup'} + + + {pickupAddr ? `· ${pickupAddr}` : ''} + + + + + Drop + + {r.deliverycustomer || 'Drop'} + + + {dropAddr ? `· ${dropAddr}` : ''} + + + -
- ) - }, - { - id: 'drop', - accessorKey: 'deliverycustomer', - size: 190, - minSize: 140, - meta: { label: 'Drop' }, - header: () => ( - - Drop - - ), - cell: ({ row }) => ( - - - {row.original.deliverycustomer} - - - {row.original.deliverycontactno} - - - - {row.original.deliverylocation || row.original.deliveryaddress || '—'} - - - - ) + ); + } }, { id: 'rider', accessorKey: 'ridername', - size: 180, - minSize: 140, + size: 190, + minSize: 150, sortUndefined: 'last', meta: { label: 'Rider' }, header: () => ( @@ -972,89 +909,92 @@ const Deliveries = () => { cell: ({ row }) => { const r = row.original; return r.ridername ? ( - + - + {r.ridername} - - ID #{r.userid} · {r.ridercontact || '—'} + + ID #{r.userid} {r.ridercontact ? `· ${r.ridercontact}` : ''} ) : ( - + Unassigned ); } }, { - id: 'eta', - accessorFn: (row) => row.expecteddeliverytime || undefined, - size: 100, - minSize: 90, + id: 'timingRoute', + accessorFn: (row) => row.expecteddeliverytime || row.kms, + size: 140, + minSize: 110, sortUndefined: 'last', - meta: { label: 'ETA' }, + meta: { label: 'ETA & Distance' }, header: () => ( - ETA + ETA & Distance ), - cell: ({ getValue }) => { - const v = getValue(); + cell: ({ row }) => { + const r = row.original; + const eta = r.expecteddeliverytime ? parseDoormileTimestamp(r.expecteddeliverytime).format('hh:mm A') : null; + const kms = Number(r.kms); return ( - - {v ? parseDoormileTimestamp(v).format('hh:mm A') : '—'} - + + + {eta ? `ETA ${eta}` : 'ETA —'} + + {kms ? ( + + {kms.toFixed(2)} km + + ) : ( + + — + + )} + ); } }, { - id: 'kms', - accessorFn: (row) => Number(row.kms) || undefined, - size: 110, - minSize: 90, + id: 'amountQty', + accessorFn: (row) => Number(row.deliverycharges) || Number(row.deliveryamt), + size: 130, + minSize: 100, sortUndefined: 'last', - meta: { label: 'Kms' }, + meta: { label: 'Amount & Qty' }, header: () => ( - Kms + Amount & Qty ), - cell: ({ getValue }) => ( - - - - ) - }, - { - id: 'amount', - accessorFn: (row) => Number(row.deliverycharges) || undefined, - size: 150, - minSize: 120, - sortUndefined: 'last', - meta: { label: 'Amount' }, - header: () => ( - - Amount - - ), - cell: ({ row }) => ( - - - - - ) + cell: ({ row }) => { + const r = row.original; + const amt = r.deliverycharges ?? r.deliveryamt; + return ( + + + {amt != null ? `₹ ${Number(amt).toFixed(2)}` : '—'} + + + {r.Quantity ? `${r.Quantity} items` : '1 parcel'} + + + ); + } }, { id: 'notes', accessorKey: 'notes', - size: 170, - minSize: 120, + size: 120, + minSize: 90, enableSorting: false, meta: { label: 'Notes' }, header: () => ( @@ -1069,7 +1009,7 @@ const Deliveries = () => { {v} @@ -1081,23 +1021,6 @@ const Deliveries = () => { ); } }, - { - id: 'qty', - accessorKey: 'Quantity', - size: 80, - minSize: 64, - meta: { label: 'Qty' }, - header: () => ( - - Qty - - ), - cell: ({ row }) => ( - - {row.original.Quantity || '—'} - - ) - }, { id: 'action', size: 90, @@ -1140,8 +1063,7 @@ const Deliveries = () => { } } ], - // eslint-disable-next-line react-hooks/exhaustive-deps - [tablePagination.pageIndex, tablePagination.pageSize, productCollapse] + [tablePagination, buildRowMenuItems, productCollapse] ); // Two sources feed one visibility slice: whatever the operator toggled in @@ -1343,12 +1265,12 @@ const Deliveries = () => { const errorMessage = fetchCountIsError ? `Error fetching count summary: ${fetchCountError?.message}` : ridersListIsError - ? `Error fetching riders: ${ridersListError?.message}` - : fetchtenantsIsError - ? `Error tenant list: ${fetchtenantsError?.message}` - : fetchlocationsIsError - ? `Error location list: ${fetchlocationsError?.message}` - : null; + ? `Error fetching riders: ${ridersListError?.message}` + : fetchtenantsIsError + ? `Error tenant list: ${fetchtenantsError?.message}` + : fetchlocationsIsError + ? `Error location list: ${fetchlocationsError?.message}` + : null; if (errorMessage) { OpenToast(errorMessage, 'error', 2000); @@ -1366,14 +1288,33 @@ const Deliveries = () => { }); // Trims the redundant " Batch" off BATCH_OPTIONS' own label (which stays - // full elsewhere, e.g. the empty-state message) — "Afternoon Batch · 89" - // was overflowing/wrapping inside the selector's compact trigger, squeezed - // to a 200px FilterToolbar track alongside four other filters. + // full elsewhere, e.g. the empty-state message) — "Afternoon Batch" was + // overflowing/wrapping inside the selector's compact trigger, squeezed to a + // 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) => ({ 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 ( + + {option.label} + {total > 0 && } + + ); + }; + const renderProductDetails = () => ( @@ -1444,9 +1385,8 @@ const Deliveries = () => { const emptyDescription = selectedBatch === 'all' ? `No ${(getStatusMeta(currentStatus)?.label || tabstatus).toLowerCase()} orders for this filter.` - : `No ${(getStatusMeta(currentStatus)?.label || tabstatus).toLowerCase()} orders in ${ - BATCH_OPTIONS.find((b) => b.id === selectedBatch)?.label || 'this batch' - }.`; + : `No ${(getStatusMeta(currentStatus)?.label || tabstatus).toLowerCase()} orders in ${BATCH_OPTIONS.find((b) => b.id === selectedBatch)?.label || 'this batch' + }.`; return ( <> @@ -1454,7 +1394,7 @@ const Deliveries = () => { )} - + { } /> - {/* All four tiles now read off the same countSourceRows-derived data - the StatusTabs pills use (batchTotals/batchCounts), instead of - three of them reading a separate, unscoped `fetchCountAPI` call — - fetchCountAPI ignores every argument passed to it (see api.js) and - returns an ALL-TIME, un-batched, un-dated total, which is why - "Pending Orders" could show a number the Pending tab itself - 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. */} - - {[ - { ...KPI_META[0], value: batchTotals.all }, - { ...KPI_META[1], value: batchCounts.uncoveredLength }, - { ...KPI_META[2], value: batchCounts.coveredLength }, - { ...KPI_META[3], value: batchCounts.cancelLength } - ].map((item) => { - const Icon = item.icon; - return ( - } - color={item.color} - loading={countSourceIsLoading} - /> - ); - })} - + {/* Status segment cards at the top of the page */} + { + 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 })), + ...(batchCounts.otherLength > 0 + ? [{ status: 'other', label: `Other (${unmappedStatuses.join(', ')})`, count: batchCounts.otherLength }] + : []) + ]} + size="sm" + hasDivider={false} + hideHint={true} + /> - + {/* Filters live OUTSIDE the table card. + Inside it, the batch/date/tenant/location/rider controls read as + part of the table's own chrome — a header band belonging to the + 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. */} +
+
+
v && setSelectedBatch(v)} placeholder="Batch" /> +
+
+
{ isDisabled={appId == null} disabledMessage="Please select your zone first!" /> +
+
{ isDisabled={!tenantid} disabledMessage={appId == null ? 'Please select your Zone and Tenant first!' : 'Please select your Tenant first!'} /> +
+
{ isDisabled={riderListIsLoading || appId == null} disabledMessage="Please select your zone first!" /> - - } - tabs={ - - { - 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} - /> - - - - +
+
- {/* Columns menu — driven by the same `columnVisibility` slice - the tab uses for select/action, so there is one source of - truth for "is this column showing" (same pattern as - orders.js). Only rendered for the desktop table. */} - {!isMobile && ( - }} - menuWidth={200} - placement="below" - > - {table - .getAllLeafColumns() - .filter((column) => column.getCanHide()) - .map((column) => ( - column.toggleVisibility(checked)} - hasCloseOnSelect={false} - /> - ))} - - )} - - - } - > +
+
+ +
+ + {/* Columns menu — driven by the same `columnVisibility` slice + the tab uses for select/action, so there is one source of + truth for "is this column showing" (same pattern as + orders.js). Only rendered for the desktop table. */} + {!isMobile && ( + }} + menuWidth={200} + placement="below" + > + {table + .getAllLeafColumns() + .filter((column) => column.getCanHide()) + .map((column) => ( + column.toggleVisibility(checked)} + hasCloseOnSelect={false} + /> + ))} + + )} +
+
+ + {isMobile ? ( {filteredRows.length === 0 && !loading1 && !countSourceLoading && ( @@ -1815,9 +1769,11 @@ const Deliveries = () => { Widths come from header.getSize(), the columnSizing slice the drag handles write to. */} @@ -1850,11 +1806,11 @@ const Deliveries = () => { onKeyDown={ canSort ? (e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - header.column.toggleSorting(); - } + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + header.column.toggleSorting(); } + } : undefined } style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined} @@ -2082,8 +2038,7 @@ const Deliveries = () => { onClick={() => { setLoading1(true); logger.info( - `Initiating rider assignment change for order ID ${selectedRow?.orderid} to rider: ${ - selectedRider?.displayname || selectedRider?.authname + `Initiating rider assignment change for order ID ${selectedRow?.orderid} to rider: ${selectedRider?.displayname || selectedRider?.authname }` ); changeRiderMutation.mutate({ selectedRider, selectedRow }); @@ -2107,6 +2062,9 @@ const Deliveries = () => { open={open} toggle={() => setOpen(!open)} 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) => { if (range.label === 'All') { setStartdate(''); diff --git a/src/pages/nearle/dispatch/ActiveSection.js b/src/pages/nearle/dispatch/ActiveSection.js index aebc922..bfd004b 100644 --- a/src/pages/nearle/dispatch/ActiveSection.js +++ b/src/pages/nearle/dispatch/ActiveSection.js @@ -128,7 +128,7 @@ const ActiveSection = ({ } // Rider delivered → reached customer location 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); } }); } diff --git a/src/pages/nearle/dispatch/ProfitabilitySection.js b/src/pages/nearle/dispatch/ProfitabilitySection.js index 4483b1f..b320718 100644 --- a/src/pages/nearle/dispatch/ProfitabilitySection.js +++ b/src/pages/nearle/dispatch/ProfitabilitySection.js @@ -492,11 +492,9 @@ export default function ProfitabilitySection({ const slotKms = activeEnriched.reduce((s, r) => s + r._m.kms, 0); const profitCount = activeEnriched.filter((r) => r._m.net >= 0).length; const lossCount = activeEnriched.length - profitCount; - const totalOrders = enriched.reduce((s, r) => s + (r._m.orders?.length ?? 0), 0); const dailyIsProfit = totalDailyProfit >= 0; const slotIsProfit = slotNet >= 0; - const slotMargin = slotRevenue > 0 ? (slotNet / slotRevenue) * 100 : 0; // 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]); @@ -518,42 +516,36 @@ export default function ProfitabilitySection({ value={rupees(Math.abs(totalDailyProfit))} icon={dailyIsProfit ? : } color={moneyColor(dailyIsProfit)} - caption="All slots today" /> } color={STATUS.info} - caption={`${profitCount} in profit · ${lossCount} at loss`} /> } color={STATUS.picked} - caption="Cumulative travel distance" /> } color={STATUS.success} - caption={`From ${totalOrders} order${totalOrders !== 1 ? 's' : ''}`} /> } color={STATUS.error} - caption="Fixed + variable" /> } color={moneyColor(slotIsProfit)} - caption={`${slotMargin >= 0 ? '+' : ''}${slotMargin.toFixed(0)}% margin`} /> diff --git a/src/pages/nearle/exceptions/exceptions.js b/src/pages/nearle/exceptions/exceptions.js index 60feb79..7a630ed 100644 --- a/src/pages/nearle/exceptions/exceptions.js +++ b/src/pages/nearle/exceptions/exceptions.js @@ -29,6 +29,7 @@ import { TextInput } from '@astryxdesign/core/TextInput'; import { TextArea } from '@astryxdesign/core/TextArea'; import { Selector } from '@astryxdesign/core/Selector'; import { Badge } from '@astryxdesign/core/Badge'; +import { Tooltip } from '@astryxdesign/core/Tooltip'; import { Pagination } from '@astryxdesign/core/Pagination'; import { EmptyState } from '@astryxdesign/core/EmptyState'; 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 PageHeader from 'components/nearle_components/PageHeader'; 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 { getExceptions, createException, updateExceptionStatus, getHubs } from 'pages/api/doormileApi'; import { DT } from 'themes/dt/tokens'; @@ -189,29 +191,45 @@ const Exceptions = () => { { id: 'consignment', accessorKey: 'consignmentid', - size: 170, - minSize: 130, - meta: { label: 'Consignment' }, - header: () => Consignment, - cell: ({ getValue }) => {getValue()} - }, - { - id: 'hub', - accessorFn: (row) => hubMap.get(row.hubid)?.hubname || (row.hubid ? `Hub #${row.hubid}` : '—'), - size: 150, - minSize: 120, - meta: { label: 'Hub' }, - header: () => Hub, - cell: ({ getValue }) => {getValue()} + size: 200, + minSize: 150, + meta: { label: 'Consignment & Hub' }, + header: () => Consignment & Hub, + cell: ({ row }) => { + const hub = hubMap.get(row.original.hubid)?.hubname || (row.original.hubid ? `Hub #${row.original.hubid}` : '—'); + return ( + + + #{row.original.consignmentid} + + + {hub} + + + ); + } }, { id: 'type', accessorKey: 'exceptiontype', - size: 150, - minSize: 120, - meta: { label: 'Type' }, - header: () => Type, - cell: ({ getValue }) => {getValue()} + size: 280, + minSize: 200, + meta: { label: 'Exception Details' }, + header: () => Exception Details, + cell: ({ row }) => ( + + + {row.original.exceptiontype || 'Exception'} + + {row.original.description && ( + + + {row.original.description} + + + )} + + ) }, { id: 'severity', @@ -222,19 +240,6 @@ const Exceptions = () => { header: () => Severity, cell: ({ getValue }) => }, - { - id: 'description', - accessorKey: 'description', - size: 260, - minSize: 180, - meta: { label: 'Description' }, - header: () => Description, - cell: ({ getValue }) => ( - - {getValue()} - - ) - }, { id: 'status', accessorFn: (row) => row.status || 'Open', @@ -242,15 +247,15 @@ const Exceptions = () => { minSize: 110, meta: { label: 'Status' }, header: () => Status, - cell: ({ getValue }) => {getValue()} + cell: ({ getValue }) => }, { id: 'actions', - size: 140, - minSize: 110, + size: 90, + minSize: 80, enableSorting: false, enableResizing: false, - meta: { label: 'Actions' }, + meta: { label: 'Actions', align: 'right' }, header: () => Actions, cell: ({ row }) => !['Resolved', 'Closed'].includes(row.original.status) ? ( @@ -305,10 +310,34 @@ const Exceptions = () => { /> - } color={BRAND} /> - } color="#f59e0b" /> - } color="#ef4444" /> - } color="#10b981" /> + } + color={BRAND} + /> + 0 ? Math.round((stats.open / stats.total) * 100) : 0}% of ${stats.total}`} + icon={} + color="#f59e0b" + /> + 0 ? Math.round((stats.critical / stats.total) * 100) : 0}% of ${stats.total}`} + icon={} + color="#ef4444" + /> + 0 ? Math.round((stats.resolved / stats.total) * 100) : 0}% of ${stats.total}`} + icon={} + color="#10b981" + /> { const [debouncedSearch, setDebouncedSearch] = useState(''); const [dialogOpen, setDialogOpen] = useState(false); const [form, setForm] = useState(emptyForm); + const [activeFilter, setActiveFilter] = useState('all'); // Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's // /Tenants.js's slices. const [sorting, setSorting] = useState([]); @@ -121,12 +124,20 @@ const Hubs = () => { const { data: hubs = [], isLoading } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs }); 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(); - 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)) ); - }, [hubs, debouncedSearch]); + }, [hubs, activeFilter, debouncedSearch]); const stats = useMemo(() => { const total = hubs.length; @@ -239,47 +250,45 @@ const Hubs = () => { { id: 'hubname', accessorKey: 'hubname', - size: 220, - minSize: 160, - meta: { label: 'Hub Name' }, - header: () => Hub Name, - cell: ({ getValue }) => {getValue()} - }, - { - id: 'hubtype', - accessorFn: (row) => (row.hubtype === 'sorting_center' ? 'Sorting Center' : 'Delivery Hub'), - size: 150, - minSize: 120, - meta: { label: 'Type' }, - header: () => Type, - cell: ({ getValue }) => getValue() - }, - { - id: 'city', - accessorKey: 'applocationid', - size: 100, - minSize: 80, - meta: { label: 'City' }, - header: () => City, - cell: ({ getValue }) => getValue() ?? '—' + size: 240, + minSize: 180, + meta: { label: 'Hub' }, + header: () => Hub, + cell: ({ row }) => ( + + + + + + + {row.original.hubname} + + + {row.original.hubtype === 'sorting_center' ? 'Sorting Center' : 'Delivery Hub'} + + + + ) }, { id: 'address', accessorKey: 'address', - size: 240, - minSize: 160, - meta: { label: 'Address' }, - header: () => Address, - cell: ({ getValue }) => getValue() || '—' - }, - { - id: 'pincode', - accessorKey: 'pincode', - size: 110, - minSize: 90, - meta: { label: 'Pincode' }, - header: () => Pincode, - cell: ({ getValue }) => getValue() || '—' + size: 320, + minSize: 240, + meta: { label: 'Address & Location' }, + header: () => Address & Location, + cell: ({ row }) => ( + + + + {row.original.address || '—'} + + + + {[row.original.pincode, row.original.applocationid ? `City #${row.original.applocationid}` : null].filter(Boolean).join(' · ') || '—'} + + + ) }, { id: 'status', @@ -292,8 +301,8 @@ const Hubs = () => { }, { id: 'actions', - size: 120, - minSize: 100, + size: 100, + minSize: 90, enableSorting: false, enableResizing: false, meta: { label: 'Actions', align: 'right' }, @@ -320,7 +329,6 @@ const Hubs = () => { ) } ], - // eslint-disable-next-line react-hooks/exhaustive-deps [pagination.pageIndex, pagination.pageSize] ); @@ -355,10 +363,46 @@ const Hubs = () => { /> - } color={BRAND} /> - } color={STATUS.info} /> - } color={STATUS.delivered} /> - } color={STATUS.active} /> + } + color={BRAND} + clickable + isSelected={activeFilter === 'all'} + onClick={() => setActiveFilter('all')} + /> + 0 ? Math.round((stats.sorting / stats.total) * 100) : 0}% of ${stats.total}`} + icon={} + color={STATUS.info} + clickable + isSelected={activeFilter === 'sorting_center'} + onClick={() => setActiveFilter((prev) => (prev === 'sorting_center' ? 'all' : 'sorting_center'))} + /> + 0 ? Math.round((stats.delivery / stats.total) * 100) : 0}% of ${stats.total}`} + icon={} + color={STATUS.delivered} + clickable + isSelected={activeFilter === 'delivery_hub'} + onClick={() => setActiveFilter((prev) => (prev === 'delivery_hub' ? 'all' : 'delivery_hub'))} + /> + 0 ? Math.round((stats.active / stats.total) * 100) : 0}% of ${stats.total}`} + icon={} + color={STATUS.active} + clickable + isSelected={activeFilter === 'active'} + onClick={() => setActiveFilter((prev) => (prev === 'active' ? 'all' : 'active'))} + /> { const navigate = useNavigate(); + const isMobile = useIsNarrowViewport(); const tid = localStorage.getItem('tenantid'); const [tabstatus, setTabstatus] = useState('Pending'); const [currentStatus, setCurrentStatus] = useState('pending_pickup'); @@ -586,44 +586,63 @@ const Orders = () => { } }, { - id: 'pickup', - accessorKey: 'pickupaddress', - size: 320, + id: 'route', + accessorFn: (row) => `${row.pickupaddress || ''} ${row.deliveryaddress || ''}`, + size: 340, minSize: 260, - meta: { label: 'Pickup' }, - header: () => Pickup, - cell: ({ getValue, row }) => ( - } primary={getValue() || '—'} secondary={row.original.pickuppincode || ''} /> - ) - }, - { - id: 'drop', - // The customer join lives in the accessor, so the cell only renders. - accessorFn: (row) => { - const customer = customerMap?.get(row.appcustomerid); - return customer?.firstname || customer?.name || `Customer #${row.appcustomerid ?? '—'}`; - }, - size: 360, - minSize: 280, - meta: { label: 'Drop' }, - header: () => Drop, - cell: ({ getValue, row }) => { - const customer = customerMap?.get(row.original.appcustomerid); + meta: { label: 'Route (Pickup → Drop)' }, + header: () => Route (Pickup → Drop), + cell: ({ row }) => { + 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 = ( +
+
+ Pickup +
+ Pickup + {r.pickupaddress || 'No address'} {r.pickuppincode ? `(${r.pickuppincode})` : ''} +
+
+
+ Drop +
+ Drop + {dropName} {customer?.phone || customer?.contactno ? `(${customer.phone || customer.contactno})` : ''} + {r.deliveryaddress || 'No address'} +
+
+
+ ); + return ( - } primary={getValue()} secondary={customer?.phone || customer?.contactno || ''}> - - - {row.original.deliveryaddress || '—'} - - {/* Parcel count moved out of its own 64px column — it was "1" - on every row. It belongs next to the delivery it describes, - and only shows when it is not 1, which is the one thing - Astryx's Badge guidance says a badge IS for. */} - {(row.original.parcels?.length || 0) > 1 && ( - } label={`${row.original.parcels.length} parcels`} /> - )} - - + + + + Pickup + + Pickup + + + {r.pickupaddress ? `· ${r.pickupaddress}` : '—'} + + + + + Drop + + {dropName} + + + {r.deliveryaddress ? `· ${r.deliveryaddress}` : ''} + + {(r.parcels?.length || 0) > 1 && ( + } label={`${r.parcels.length} parcels`} /> + )} + + + ); } }, @@ -927,33 +946,33 @@ const Orders = () => { viewport, at any window size, with no viewport arithmetic anywhere on this page. See PageLayout.js for why one `:has()` rule is needed to make that flex chain resolve. */} - + - - {/* The shared status strip, same component the other list pages - use, so tab chrome — selected indicator, hover, focus ring, - count badge, arrow-key navigation — is identical across the - console instead of re-declared here. Labels stay this page's - own ("Assigned", not the registry's "Accepted") because the - Orders tabs track the OPERATOR's workflow. */} - ({ - status: t.status, - label: t.label, - count: t.statuses.reduce((sum, s) => sum + (statusCounts[s] ?? 0), 0) - }))} - /> + {/* Status segment cards at the top of the page */} + ({ + 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 - whole toolbar — filter tabs, search, column visibility — - reads as one control strip instead of two stacked rows. */} + + + {filteredRows.length} {filteredRows.length === 1 ? 'order' : 'orders'} + { the tab uses for the checkbox column, so there is one source of truth for "is this column showing". `getCanHide()` is what keeps the checkbox column out of the list. */} - }} - menuWidth={200} - placement="below" - > - {table - .getAllLeafColumns() - .filter((column) => column.getCanHide()) - .map((column) => ( - column.toggleVisibility(checked)} - hasCloseOnSelect={false} - /> - ))} - + {!isMobile && ( + }} + menuWidth={200} + placement="below" + > + {table + .getAllLeafColumns() + .filter((column) => column.getCanHide()) + .map((column) => ( + column.toggleVisibility(checked)} + hasCloseOnSelect={false} + /> + ))} + + )} } diff --git a/src/pages/nearle/reports/ordersDetails.js b/src/pages/nearle/reports/ordersDetails.js index dfd2c9a..6d7b5ee 100644 --- a/src/pages/nearle/reports/ordersDetails.js +++ b/src/pages/nearle/reports/ordersDetails.js @@ -12,10 +12,6 @@ import { MdCurrencyRupee, MdMap, MdNoteAlt, - MdOutlineLocalShipping, - MdOutlineCheckCircle, - MdOutlinePendingActions, - MdOutlineCancel, MdArrowUpward, MdArrowDownward, MdUnfoldMore @@ -67,8 +63,7 @@ import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar'; import LoaderWithImage from 'components/nearle_components/LoaderWithImage'; import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete'; import PageHeader from 'components/nearle_components/PageHeader'; -import StatCard from 'components/nearle_components/StatCard'; -import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout'; +import { PageShell, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout'; import StatusBadge from 'components/nearle_components/StatusBadge'; import StatusTabs from 'components/nearle_components/StatusTabs'; import TableLoader from 'components/nearle_components/TableLoader'; @@ -341,10 +336,6 @@ export default function OrdersDetails() { }); return c; }, [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 // here was already dead: the unconditional drain effect above (`if @@ -476,13 +467,6 @@ export default function OrdersDetails() { } }, [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, // so page 1 is the only page that still means anything — same reset // 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 box below, which is why Export kept wrapping onto its own line and left that box half empty. */} - + {/* 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 button — LocationAutocomplete brings its own width and ignores the @@ -853,25 +837,21 @@ export default function OrdersDetails() { action={