This commit is contained in:
2026-09-11 16:32:50 +05:30
parent 81b7d32672
commit 0a11f7543c
24 changed files with 2036 additions and 798 deletions

View File

@@ -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>
);
}

View File

@@ -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} />

View 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>
);
}

View File

@@ -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} />}
/>

View File

@@ -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;
}