redesign
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { AppShell, type NavEntry } from '@/components/shell/AppShell';
|
||||
import { FileSpreadsheet, Bike } from 'lucide-react';
|
||||
import { AppShell, type MenuEntry, type NavEntry } from '@/components/shell/AppShell';
|
||||
import { DateScopeProvider } from '@/components/shell/DateScope';
|
||||
|
||||
/**
|
||||
@@ -12,15 +13,51 @@ const NAV: readonly NavEntry[] = [
|
||||
{ to: '/nearle/stores', label: 'Stores' },
|
||||
{ to: '/nearle/onboard/tenant', label: 'Onboard tenant' },
|
||||
{ to: '/nearle/catalogue', label: 'Global catalogue' },
|
||||
{ to: '/nearle/partners', label: 'Rider partners' },
|
||||
{ to: '/nearle/fleet', label: 'Fleet' },
|
||||
{ to: '/nearle/uploads', label: 'Uploads' },
|
||||
{ to: '/nearle/dispatch', label: 'Dispatch' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Setup destinations, in the account menu rather than the nav.
|
||||
*
|
||||
* Both of these used to hold a nav slot beside Stores and Dispatch, and neither
|
||||
* earns one: a partner is signed up once and then edited when a contract
|
||||
* changes, and an upload is checked in the days after it was sent. The nav's
|
||||
* four remaining slots are the places the platform actually works from.
|
||||
*
|
||||
* Onboarding a partner and watching their riders are also not the same job.
|
||||
* Dispatch answers "what is happening on the road right now", every day; Rider
|
||||
* partners answers "who are we contracted with", a handful of times a year. Two
|
||||
* adjacent nav entries made them look like two halves of one screen — and now
|
||||
* that Dispatch reads a partner's whole book of work, the one that matters
|
||||
* daily is the one that stayed.
|
||||
*
|
||||
* This mirrors the Store Admin's own menu, where Uploads sits for exactly the
|
||||
* same reason. See `AppShellProps.manageItems`.
|
||||
*/
|
||||
const MANAGE: readonly MenuEntry[] = [
|
||||
{
|
||||
to: '/nearle/partners',
|
||||
label: 'Rider partners',
|
||||
icon: <Bike size={13} />,
|
||||
note: 'Who we are contracted with',
|
||||
},
|
||||
{
|
||||
to: '/nearle/uploads',
|
||||
label: 'Uploads',
|
||||
icon: <FileSpreadsheet size={13} />,
|
||||
note: 'Spreadsheets sent to the catalogue',
|
||||
},
|
||||
];
|
||||
|
||||
export function NearleAdminShell() {
|
||||
return (
|
||||
<DateScopeProvider>
|
||||
<AppShell nav={NAV} home="/nearle/stores" navLabel="Nearle Admin" />
|
||||
<AppShell
|
||||
nav={NAV}
|
||||
home="/nearle/stores"
|
||||
navLabel="Nearle Admin"
|
||||
manageItems={MANAGE}
|
||||
/>
|
||||
</DateScopeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertTriangle, Bike, CheckCircle2, Clock, Info, MapPin, Timer } from 'lucide-react';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { TrailMap, trailColour, type MapPin as Pin } from '@/components/TrailMap';
|
||||
import { useAllPartners, usePartnerRiderLogs } from '@/queries/hooks';
|
||||
import { duration, fleetDay, recommend, type RiderDay, type Severity } from '../riderShifts';
|
||||
import './fleet.css';
|
||||
import { usePartnerRiderLogs } from '@/queries/hooks';
|
||||
import { duration, fleetDay, recommend, type RiderDay, type Severity } from './riderShifts';
|
||||
import './pages/fleet.css';
|
||||
|
||||
/**
|
||||
* Who was online, and for how long.
|
||||
@@ -42,19 +41,9 @@ import './fleet.css';
|
||||
* empty or zero on all 320,132 rows read for August, so this is the only way to
|
||||
* know, and it is inferred rather than recorded. The page says so out loud.
|
||||
*/
|
||||
export function FleetPage() {
|
||||
const partners = useAllPartners();
|
||||
const [partnerid, setPartnerid] = useState<number>(0);
|
||||
export function ShiftsPanel({ partnerid }: { partnerid: number }) {
|
||||
const [day, setDay] = useState(() => yesterday());
|
||||
|
||||
// The first partner, once they load — an empty page with a dropdown on it
|
||||
// makes the reader do a step the page could have done.
|
||||
useEffect(() => {
|
||||
if (partnerid === 0 && partners.data.length > 0) {
|
||||
setPartnerid(partners.data[0]?.partnerid ?? 0);
|
||||
}
|
||||
}, [partnerid, partners.data]);
|
||||
|
||||
const logs = usePartnerRiderLogs(partnerid || undefined, { fromdate: day, todate: day });
|
||||
|
||||
const fleet = useMemo(() => fleetDay(logs.data ?? []), [logs.data]);
|
||||
@@ -90,40 +79,25 @@ export function FleetPage() {
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Fleet"
|
||||
actions={
|
||||
<HStack gap={1} align="center" wrap="wrap">
|
||||
<select
|
||||
className="fleet-select"
|
||||
aria-label="Rider partner"
|
||||
value={partnerid}
|
||||
onChange={(event) => {
|
||||
setPartnerid(Number(event.target.value));
|
||||
setFocused(null);
|
||||
}}
|
||||
>
|
||||
{partners.data.length === 0 ? <option value={0}>No partners</option> : null}
|
||||
{partners.data.map((partner) => (
|
||||
<option key={partner.partnerid} value={partner.partnerid}>
|
||||
{partner.partnername ?? `Partner ${partner.partnerid}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
className="fleet-select"
|
||||
aria-label="Day"
|
||||
value={day}
|
||||
max={today()}
|
||||
onChange={(event) => {
|
||||
setDay(event.target.value);
|
||||
setFocused(null);
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
{/* No page header. This is a tab on the platform's Dispatch page now,
|
||||
and the partner it describes is chosen there — a second picker here
|
||||
would be two controls setting one thing. */}
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Who was on, and for how long
|
||||
</Text>
|
||||
<input
|
||||
type="date"
|
||||
className="fleet-select"
|
||||
aria-label="Day"
|
||||
value={day}
|
||||
max={today()}
|
||||
onChange={(event) => {
|
||||
setDay(event.target.value);
|
||||
setFocused(null);
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<Banner advice={advice} />
|
||||
|
||||
141
src/features/nearle-admin/pages/DispatchPage.tsx
Normal file
141
src/features/nearle-admin/pages/DispatchPage.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Bike, Clock } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useAllPartners } from '@/queries/hooks';
|
||||
import { DispatchBoard } from '@/features/store-admin/pages/DispatchPage';
|
||||
import { ShiftsPanel } from '../ShiftsPanel';
|
||||
import './fleet.css';
|
||||
|
||||
/**
|
||||
* The platform's dispatch board — one delivery partner at a time.
|
||||
*
|
||||
* ── Why this replaced the Fleet page ────────────────────────────────────────
|
||||
*
|
||||
* There were two pages answering halves of one question. "Fleet" read
|
||||
* `getriderlogs` and showed presence — who clocked on, for how long — while the
|
||||
* shops' "Dispatch" read `getdeliveries` and showed the work itself. So the
|
||||
* platform could see that a partner's riders were online and had no way to see
|
||||
* what they delivered.
|
||||
*
|
||||
* `getdeliveries` takes a `partnerid`. Verified live on 2026-09-11: partner 60
|
||||
* answered with 376 deliveries spanning 12 merchants and 12 riders. That is the
|
||||
* platform's real view of dispatch and it was one query parameter away the
|
||||
* whole time.
|
||||
*
|
||||
* So this is the same board the shops use — same tabs, same cards, same map,
|
||||
* same component — scoped to a partner instead of a tenant. Presence survives
|
||||
* as the Shifts tab rather than as a page of its own.
|
||||
*
|
||||
* ── What the platform deliberately cannot do here ───────────────────────────
|
||||
*
|
||||
* See a merchant's OWN riders, and assign anything.
|
||||
*
|
||||
* Both fall out of the scope type rather than being hidden: `DispatchScope`'s
|
||||
* partner variant has no field to put a tenantid in, so the reads cannot be
|
||||
* widened by accident, and `canAssign` is false for it, so the assign bar, the
|
||||
* auto-assign drawer and the queue's tick boxes are all absent. The platform
|
||||
* watches partners; it does not dispatch on a shop's behalf.
|
||||
*/
|
||||
export function NearleDispatchPage() {
|
||||
const partners = useAllPartners();
|
||||
const [partnerid, setPartnerid] = useState<number>(0);
|
||||
const [view, setView] = useState<'work' | 'shifts'>('work');
|
||||
|
||||
// The first partner, once they load. An empty page with a dropdown on it
|
||||
// makes the reader do a step the page could have done.
|
||||
useEffect(() => {
|
||||
if (partnerid === 0 && partners.data.length > 0) {
|
||||
setPartnerid(partners.data[0]?.partnerid ?? 0);
|
||||
}
|
||||
}, [partnerid, partners.data]);
|
||||
|
||||
const chosen = useMemo(
|
||||
() => partners.data.find((partner) => partner.partnerid === partnerid),
|
||||
[partners.data, partnerid],
|
||||
);
|
||||
|
||||
const scope = useMemo(
|
||||
() =>
|
||||
({
|
||||
kind: 'partner' as const,
|
||||
partnerid,
|
||||
partnername: chosen?.partnername ?? `Partner ${partnerid}`,
|
||||
}),
|
||||
[partnerid, chosen],
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
isTabsInline
|
||||
title="Dispatch"
|
||||
actions={
|
||||
<HStack gap={1} align="center" wrap="wrap">
|
||||
<select
|
||||
className="fleet-select"
|
||||
aria-label="Rider partner"
|
||||
value={partnerid}
|
||||
onChange={(event) => setPartnerid(Number(event.target.value))}
|
||||
>
|
||||
{partners.data.length === 0 ? <option value={0}>No partners</option> : null}
|
||||
{partners.data.map((partner) => (
|
||||
<option key={partner.partnerid} value={partner.partnerid}>
|
||||
{partner.partnername ?? `Partner ${partner.partnerid}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</HStack>
|
||||
}
|
||||
tabs={
|
||||
/* The store board's own tab styling — `.sbt`, defined in
|
||||
`dispatch.css`, which this page already loads through
|
||||
`DispatchBoard`.
|
||||
|
||||
These were written against a `.mode-tab` class that exists in no
|
||||
stylesheet, so they rendered as bare browser buttons directly above
|
||||
a row of properly styled ones. Nothing catches that: the markup is
|
||||
valid, the class is just never matched. */
|
||||
<div className="nearle-view-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={view === 'work' ? 'sbt active' : 'sbt'}
|
||||
onClick={() => setView('work')}
|
||||
>
|
||||
<span className="sbt-icon">
|
||||
<Bike size={15} />
|
||||
</span>
|
||||
<span>The work</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={view === 'shifts' ? 'sbt active' : 'sbt'}
|
||||
onClick={() => setView('shifts')}
|
||||
>
|
||||
<span className="sbt-icon">
|
||||
<Clock size={15} />
|
||||
</span>
|
||||
<span>Shifts</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{partnerid === 0 ? (
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{partners.isLoading
|
||||
? 'Reading partners…'
|
||||
: 'No rider partners are onboarded yet. Onboard one from Rider partners, in the account menu, to see their dispatch.'}
|
||||
</Text>
|
||||
) : view === 'work' ? (
|
||||
/* The shops' own board, against a partner. Nothing about it is
|
||||
duplicated here — this is the same component. */
|
||||
<DispatchBoard scope={scope} />
|
||||
) : (
|
||||
<ShiftsPanel partnerid={partnerid} />
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -13,8 +13,9 @@ import { Freshness } from '@/components/Freshness';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { useLocationSummary, useOrderSummary, useTenantLocations, useTenants } from '@/queries/hooks';
|
||||
import type { LocationOrderSummary, TenantInfo, TenantLocation } from '@/api/types';
|
||||
import { useOrderSummary, useOrders, useTenantLocations, useTenants } from '@/queries/hooks';
|
||||
import { branchOrderStats, NO_ORDERS } from '@/features/store-admin/branchStats';
|
||||
import type { TenantInfo, TenantLocation } from '@/api/types';
|
||||
import { TablePager } from '@/components/TablePager';
|
||||
import { usePaged } from '@/components/usePaged';
|
||||
import { Drawer } from '@/features/store-admin/Drawer';
|
||||
@@ -40,12 +41,15 @@ function money(value: number | undefined): string {
|
||||
/**
|
||||
* One tenant: its branches, and how each is performing.
|
||||
*
|
||||
* Order figures come from `/orders/getlocationsummary`, which is scoped to the
|
||||
* tenant and returns one row per branch. Counter sales are NOT folded in here:
|
||||
* the POS endpoints take a single required locationid, so a tenant-wide till
|
||||
* figure would mean one request per branch, and a blended number would be
|
||||
* eventually consistent in a way this page cannot honestly caption. Branch-level
|
||||
* POS lives on the branch screen instead.
|
||||
* Order figures are totalled from the tenant's own order rows and grouped by
|
||||
* branch. They used to come from `/orders/getlocationsummary`, which does return
|
||||
* one row per branch but carries no money on it at all — so every Revenue cell
|
||||
* in this table read ₹0 for as long as the page has existed.
|
||||
*
|
||||
* Counter sales are NOT folded in here: the POS endpoints take a single required
|
||||
* locationid, so a tenant-wide till figure would mean one request per branch,
|
||||
* and a blended number would be eventually consistent in a way this page cannot
|
||||
* honestly caption. Branch-level POS lives on the branch screen instead.
|
||||
*/
|
||||
export function StoreDetailPage() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
@@ -53,7 +57,11 @@ export function StoreDetailPage() {
|
||||
|
||||
const { data: tenants } = useTenants();
|
||||
const { data: locations, isLoading, error } = useTenantLocations(tenantid);
|
||||
const summary = useLocationSummary(tenantid);
|
||||
/* Per-branch takings come from the order rows. `getlocationsummary`, which
|
||||
used to feed this table, sends counts and no money at all — see
|
||||
`branchStats.ts`. No date range here on purpose: this is the platform's
|
||||
all-time view of a merchant, not a trading-day board. */
|
||||
const summary = useOrders(tenantid ? { tenantid, pagesize: 500 } : undefined);
|
||||
const orders = useOrderSummary(tenantid);
|
||||
|
||||
const tenant = useMemo(
|
||||
@@ -62,13 +70,10 @@ export function StoreDetailPage() {
|
||||
);
|
||||
|
||||
const rows = useMemo<BranchRow[]>(() => {
|
||||
const perLocation = new Map<number, LocationOrderSummary>();
|
||||
for (const entry of summary.data ?? []) {
|
||||
if (typeof entry.locationid === 'number') perLocation.set(entry.locationid, entry);
|
||||
}
|
||||
const perLocation = branchOrderStats(summary.data ?? []);
|
||||
|
||||
return ((locations ?? []) as TenantLocation[]).map((branch) => {
|
||||
const stats = perLocation.get(branch.locationid);
|
||||
const stats = perLocation.get(branch.locationid) ?? NO_ORDERS;
|
||||
return {
|
||||
locationid: branch.locationid,
|
||||
locationname: branch.locationname,
|
||||
@@ -77,8 +82,8 @@ export function StoreDetailPage() {
|
||||
branch.opentime && branch.closetime ? `${branch.opentime}–${branch.closetime}` : '—',
|
||||
radius: branch.deliveryradius ? `${(branch.deliveryradius / 1000).toFixed(1)} km` : '—',
|
||||
status: branch.status ?? 'Unknown',
|
||||
orders: Number(stats?.totalorders ?? 0),
|
||||
revenue: Number(stats?.revenue ?? 0),
|
||||
orders: stats.orders,
|
||||
revenue: stats.revenue,
|
||||
};
|
||||
});
|
||||
}, [locations, summary.data]);
|
||||
@@ -243,7 +248,7 @@ export function StoreDetailPage() {
|
||||
<KpiCard
|
||||
label="Revenue"
|
||||
value={money(totals.totalRevenue)}
|
||||
note="from order summary"
|
||||
note="across every branch"
|
||||
tone={totals.totalRevenue > 0 ? 'success' : 'neutral'}
|
||||
icon={<IndianRupee size={15} />}
|
||||
/>
|
||||
|
||||
@@ -261,3 +261,17 @@
|
||||
flex: none;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── The dispatch page's two view tabs ───────────────────────────────────── */
|
||||
|
||||
/* The buttons themselves are `.sbt` from the store board's `dispatch.css`, so
|
||||
the platform's tabs and the shops' tabs are the same control rather than two
|
||||
that happen to look alike. Only the row they sit in belongs here: the store
|
||||
board wraps its tabs in `.strat-row`, a bordered bar that also carries an ops
|
||||
cluster on the right, and there is nothing to put on the right of these two. */
|
||||
.nearle-view-tabs {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user