rider partner page

This commit is contained in:
2026-09-09 15:43:23 +05:30
parent 32c612a10d
commit b760c1a078
18 changed files with 1802 additions and 63 deletions

View File

@@ -32,6 +32,7 @@ const StoresPage = named('StoresPage', () => import('@/features/nearle-admin/pag
const StoreDetailPage = named('StoreDetailPage', () => import('@/features/nearle-admin/pages/StoreDetailPage'));
const OnboardTenantPage = named('OnboardTenantPage', () => import('@/features/nearle-admin/pages/OnboardTenantPage'));
const GlobalCataloguePage = named('GlobalCataloguePage', () => import('@/features/nearle-admin/pages/GlobalCataloguePage'));
const PartnersPage = named('PartnersPage', () => import('@/features/nearle-admin/pages/PartnersPage'));
const NearleUploadsPage = named('UploadsPage', () => import('@/features/nearle-admin/pages/UploadsPage'));
/* One Console for both workspaces — it reads its own scope from BranchScope,
@@ -104,6 +105,9 @@ export function App() {
bookmark or a stale link lands on the directory instead of a 404. */}
<Route path="onboard/branch" element={<Navigate to="/nearle/stores" replace />} />
<Route path="catalogue" element={<GlobalCataloguePage />} />
{/* Delivery partners — the companies that supply riders. Platform-side
only: a merchant is assigned one, never allowed to create one. */}
<Route path="partners" element={<PartnersPage />} />
<Route path="uploads" element={<NearleUploadsPage />} />
{/* Absorbed here rather than by the global `*`, so a wrong sub-path can
never bounce out to a HOME_ROUTE that points back into this

View File

@@ -33,38 +33,59 @@ export const RIDER_MESSAGE = {
: `${count} orders have been assigned to you. Kindly accept and process the deliveries.`,
} as const;
/**
* Which fleet to ask for. One of these, in this order of preference.
*
* `getriders` scopes by applocation, partner or tenant. It used to be called
* with the region ALONE, which asks "who is on duty in this city" — so a
* merchant's assign picker offered every on-duty rider in Coimbatore, including
* other merchants' own riders and every other partner's.
*
* Measured 2026-09-09: 118 riders across three regions, and 117 of them belong
* to a delivery partner — 75 to partner 44 alone. Exactly one rider on the
* platform is a merchant's own. So the region scope was not a harmless default;
* it was the only thing holding the picker together while the two real scopes
* went unused.
*/
export interface RiderQuery {
/** The merchant's own riders — hired by them, working their branches. */
tenantid?: number;
/** A delivery partner's riders. One partner supplies many merchants. */
partnerid?: number;
/**
* The delivery region, and for now the only scope that finds anybody.
* The delivery region — a CITY, and the fallback for neither of the above.
*
* `tenantid` is accepted too and the query is sound — it just matches nothing
* yet, because `app_users.tenantid` was never filled in for a rider. Riders
* hired through this console DO carry one, so tenant scope starts working the
* moment a merchant has their own.
*
* It is not the scope used here, and that is deliberate: production has 84
* riders on applocation 1 and none of them has a tenant, so switching today
* would empty the picker for everybody. Revisit once merchants have hired
* their own — preferring tenant and falling back to region.
*
* Note what region means: a CITY. Until then an operator is offered every
* on-duty rider in Coimbatore, including other merchants'.
* Kept because a caller with no merchant in hand still has to ask something,
* not because it is the right scope for an assign picker.
*/
applocationid: number;
applocationid?: number;
}
export const deliveriesApi = {
/**
* Riders on duty right now.
* Riders on duty right now, for one OWNER.
*
* "On duty" is the backend's word, not a filter added here: the query wants
* `app_userpools.onduty = 1` and a `riderlogs` row stamped today with
* `logstatus = 0`. So this list empties overnight and refills as riders clock
* on, and an empty answer means nobody has started their shift — not that
* the shop has no riders. The picker has to say which.
*
* ── Why this is scoped and used to not be ─────────────────────────────────
*
* It sent `applocationid` alone, which asks "who is on duty in this city" —
* so a merchant's assign picker listed every on-duty rider in Coimbatore,
* including other merchants' own riders and every partner's. Nobody hit it
* because there is one rider on the platform. `getriders` scopes by
* applocation, partner or tenant, in that order, so the caller names which
* fleet it means and the region is only a fallback for neither.
*/
riders: (query: RiderQuery) =>
api.list<RiderInfo>(`${WEB}/partners/getriders`, { applocationid: query.applocationid }),
api.list<RiderInfo>(`${WEB}/partners/getriders`, {
...(query.tenantid ? { tenantid: query.tenantid } : {}),
...(query.partnerid ? { partnerid: query.partnerid } : {}),
...(query.tenantid || query.partnerid ? {} : { applocationid: query.applocationid }),
}),
/**
* Hand orders to a rider.
@@ -165,7 +186,18 @@ export interface NewRider {
password?: string;
/** The delivery region. Defaulted from the branch — see `RiderDrawer`. */
applocationid: number;
/**
* Whose rider this is — one of these, never both.
*
* `tenantid` is a merchant's own rider; `partnerid` is a delivery partner's,
* who serves several merchants and sits under no single one. The server
* refuses neither and refuses both, so the two can never be confused
* downstream in a directory or an assign picker.
*/
tenantid?: number;
partnerid?: number;
/** The branch an OWN rider works out of. Meaningless for a partner's. */
locationid?: number;
shiftid: number;
identificationno?: string;
vehiclename?: string;
@@ -191,6 +223,9 @@ export interface RiderRosterRow {
contactno?: string;
email?: string;
tenantid?: number;
/** The branch an own rider works out of, and its name. */
locationid?: number;
locationname?: string;
applocationid?: number;
applocation?: string;
partnerid?: number;
@@ -213,9 +248,58 @@ export interface RiderRosterRow {
export interface Partner {
partnerid: number;
partnername?: string;
companyname?: string;
applocationid?: number;
primarycontact?: string;
primaryemail?: string;
contactno?: string;
registrationno?: string;
address?: string;
suburb?: string;
city?: string;
state?: string;
status?: string;
}
/** One region a partner covers — a row of `partnerlocations`. */
export interface PartnerLocation {
partnerlocationid: number;
partnerid: number;
applocationid: number;
applocation?: string;
}
/** A delivery region. `applocationid=0` asks for all of them. */
export interface AppLocation {
applocationid: number;
locationname?: string;
}
/** Everything the console collects to onboard a delivery partner. */
export interface NewPartner {
partnerid?: number;
partnername: string;
companyname?: string;
registrationno?: string;
primarycontact: string;
primaryemail?: string;
contactno?: string;
address?: string;
suburb?: string;
city?: string;
state?: string;
postcode?: number;
status?: string;
/** The district they work — one, never a set. */
applocationid: number;
/**
* The district by NAME, for one Nearle has not opened yet.
*
* Sending it opens the district: the server writes the `app_location` and
* `app_locationconfig` rows every rider query joins through. Ignored when
* `applocationid` is set, which is the ordinary case.
*/
district?: string;
}
export interface RiderShift {
@@ -238,10 +322,27 @@ export const ridersApi = {
roster: (tenantid: number) =>
api.list<RiderRosterRow>(`${WEB}/partners/getriderroster`, { tenantid }),
/** Hire one. `tenantid` travels as a param — the backend ignores it in the body. */
/**
* Hire one for a MERCHANT. `tenantid` travels as a param — the backend takes
* the scope from there rather than trusting the body, so a store admin cannot
* put a rider on another merchant's books by editing a payload.
*/
create: (tenantid: number, rider: NewRider) =>
api.post<{ userid: number }>(`${WEB}/partners/createrider`, rider, { tenantid }),
/**
* Hire one for a delivery PARTNER.
*
* Same endpoint, same rider — what differs is who they ride for. A partner
* has no console of its own, so their riders are added by the platform.
*/
createForPartner: (partnerid: number, rider: NewRider) =>
api.post<{ userid: number }>(`${WEB}/partners/createrider`, rider, { partnerid }),
/** A partner's riders, for the platform's directory. */
partnerRoster: (partnerid: number) =>
api.list<RiderRosterRow>(`${WEB}/partners/getriderroster`, { partnerid }),
update: (rider: NewRider & { userid: number }) =>
api.put<unknown>(`${WEB}/partners/updaterider`, rider),
@@ -253,3 +354,38 @@ export const ridersApi = {
partners: (applocationid: number) =>
api.list<Partner>(`${WEB}/partners/getpartners`, { applocationid }),
};
/**
* Delivery partners — the companies that supply riders.
*
* A partner is onboarded by the platform and then ASSIGNED to merchants; a
* merchant never creates one. That split is why `assign` lives on the tenant
* API and not here, and why `partnerid` is kept out of the merchant-editable
* profile allowlist on the server.
*
* One partner routinely serves many merchants: partner 44 supplies 48 of them
* and partner 60 supplies 63, measured on 2026-09-09.
*/
export const partnersApi = {
/** Every partner in a region. `applocationid` 0 is not accepted here. */
list: (applocationid: number) =>
api.list<Partner>(`${WEB}/partners/getpartners`, { applocationid }),
/** One partner, by id. */
byId: (partnerid: number) =>
api.list<Partner>(`${WEB}/partners/getpartners`, { partnerid }),
create: (partner: NewPartner) =>
api.post<{ partnerid: number }>(`${WEB}/partners/createpartner`, partner),
/**
* Edit a partner. Regions are REPLACED when sent and left alone when not, so
* an edit that changes only a phone number cannot empty the list.
*/
update: (partner: NewPartner & { partnerid: number }) =>
api.put<unknown>(`${WEB}/partners/updatepartner`, partner),
/** The regions one partner covers. */
locations: (partnerid: number) =>
api.list<PartnerLocation>(`${WEB}/partners/getpartnerlocations`, { partnerid }),
};

View File

@@ -1,6 +1,7 @@
/** Tenant and branch endpoints — the Nearle Admin's provisioning surface. */
import { api, WEB } from './client';
import type { AppLocation } from './deliveries';
import type { TenantInfo, TenantLocation } from './types';
/** Everything the tenant-onboarding form collects. */
@@ -172,6 +173,19 @@ export const tenantsApi = {
updateProfile: (body: { tenantid: number } & Partial<TenantInfo>) =>
api.put<unknown>(`${WEB}/tenants/updatetenant`, body),
/**
* Which delivery partner supplies this merchant's riders.
*
* Its own endpoint, not a field on `updateProfile`: `partnerid` is kept out
* of the merchant-editable allowlist on purpose, because a merchant who could
* set it would move themselves under another partner's riders and billing.
*
* `partnerid: 0` is a real instruction — it means "this merchant uses their
* own riders" — and the server reads it as sent rather than as absent.
*/
assignPartner: (tenantid: number, partnerid: number) =>
api.put<unknown>(`${WEB}/tenants/assignpartner`, { tenantid, partnerid }),
/**
* One business, by id — how a store login reads its own record.
*
@@ -267,4 +281,14 @@ export const utilsApi = {
* invisible to onboarding until someone edits the frontend.
*/
appCategories: () => api.list<AppCategory>(`${WEB}/utils/getappcategories`),
/**
* The delivery regions — Coimbatore, Madurai, Nagercoil today.
*
* `applocationid` is REQUIRED by the handler and 0 is how you ask for all of
* them; omitting it answers 400 "Invalid applocationid", which reads as a
* broken request rather than a missing default.
*/
appLocations: (applocationid = 0) =>
api.list<AppLocation>(`${WEB}/utils/getapplocations`, { applocationid }),
};

View File

@@ -12,6 +12,7 @@ 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/uploads', label: 'Uploads' },
];

View File

@@ -0,0 +1,209 @@
import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { Bike, Check, Truck } from 'lucide-react';
import { errorMessage } from '@/api/client';
import { tenantsApi } from '@/api/tenants';
import { useAllPartners, useAppRegions } from '@/queries/hooks';
import { queryKeys } from '@/queries/keys';
import { Drawer } from '@/features/store-admin/Drawer';
import { DrawerButton, DrawerCard, Note, Row, Section } from '@/features/store-admin/drawerKit';
/**
* Which delivery partner supplies a merchant's riders.
*
* ── Why this is the platform's decision ─────────────────────────────────────
*
* `partnerid` is kept out of the merchant-editable allowlist on the server, so
* this cannot be done from the shop's own profile — a merchant who could set it
* would move themselves under another partner's riders and billing. It is
* changed here, by whoever is looking at that merchant's record.
*
* ── Why "own riders" is an option and not an absence ────────────────────────
*
* Sending `partnerid: 0` is a real instruction: it means the shop delivers with
* riders it hired itself. The server reads a zero as sent rather than as a
* missing field, which is exactly why the endpoint is separate — everywhere
* else in the tenant API a zero means "not supplied", and there it would
* silently unassign somebody.
*
* One partner per merchant, which is what `tenants.partnerid` allows and what
* the assign picker later branches on. A partner serving several merchants is
* the ordinary case in the other direction: 44 supplies 48 shops.
*/
export function PartnerAssignDrawer({
tenantid,
tenantname,
currentPartnerId,
onClose,
}: {
tenantid: number;
tenantname: string;
currentPartnerId: number;
onClose: () => void;
}) {
const client = useQueryClient();
const partners = useAllPartners();
const regions = useAppRegions();
const [chosen, setChosen] = useState<number>(currentPartnerId);
const [error, setError] = useState<string | null>(null);
const regionName = useMemo(() => {
const map = new Map<number, string>();
for (const region of regions.data ?? []) {
map.set(region.applocationid, region.locationname ?? `Region ${region.applocationid}`);
}
return map;
}, [regions.data]);
const save = useMutation({
mutationFn: () => tenantsApi.assignPartner(tenantid, chosen),
onSuccess: async () => {
await client.invalidateQueries({ queryKey: queryKeys.tenants.all });
onClose();
},
onError: (cause) => setError(errorMessage(cause)),
});
const current = partners.data.find((p) => p.partnerid === currentPartnerId);
return (
<Drawer
title="Delivery partner"
subtitle={tenantname}
width={460}
onClose={onClose}
isFooterSpread
footer={
<>
<DrawerButton label="Cancel" variant="ghost" onClick={onClose} />
<DrawerButton
label={save.isPending ? 'Saving…' : 'Save'}
variant="primary"
icon={<Truck size={15} />}
isDisabled={save.isPending || chosen === currentPartnerId}
onClick={() => save.mutate()}
/>
</>
}
>
<VStack gap={2}>
{error ? (
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
{error}
</Text>
) : null}
<Note icon={<Bike size={15} />}>
A partner supplies riders to this shop. With one set, the assign screen offers the
partner’s riders alongside any the shop hired itself; without one, only its own.
</Note>
<Section title="Currently">
<DrawerCard>
<Row
label="Partner"
value={current?.partnername ?? (currentPartnerId > 0 ? `Partner ${currentPartnerId}` : 'Own riders only')}
/>
</DrawerCard>
</Section>
<Section title="Change to">
<DrawerCard>
{/* "Own riders" first and always present. It is not the empty state
— a shop that hires its own riders is a real arrangement, and
the option has to be as reachable as any partner. */}
<Choice
label="Own riders only"
detail="This shop delivers with riders it hired itself."
isChosen={chosen === 0}
onChoose={() => setChosen(0)}
/>
{partners.isLoading ? (
<Row label="Partners" value="Reading…" />
) : (
partners.data.map((partner) => (
<Choice
key={partner.partnerid}
label={partner.partnername ?? `Partner ${partner.partnerid}`}
detail={
[
regionName.get(partner.applocationid ?? 0),
partner.companyname,
]
.filter(Boolean)
.join(' · ') || 'No region recorded'
}
isChosen={chosen === partner.partnerid}
onChoose={() => setChosen(partner.partnerid)}
/>
))
)}
</DrawerCard>
</Section>
</VStack>
</Drawer>
);
}
function Choice({
label,
detail,
isChosen,
onChoose,
}: {
label: string;
detail: string;
isChosen: boolean;
onChoose: () => void;
}) {
return (
<button
type="button"
onClick={onChoose}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
width: '100%',
padding: '12px 14px',
border: 0,
borderTop: '1px solid var(--color-line)',
background: isChosen ? 'var(--color-brand-tint)' : 'transparent',
cursor: 'pointer',
textAlign: 'left',
}}
>
<span
aria-hidden
style={{
width: 18,
height: 18,
flex: 'none',
borderRadius: 999,
display: 'grid',
placeItems: 'center',
border: isChosen ? 0 : '1.5px solid var(--color-line)',
background: isChosen ? 'var(--color-brand)' : 'transparent',
color: '#fff',
}}
>
{isChosen ? <Check size={11} strokeWidth={3} /> : null}
</span>
<span style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
<span
style={{
font: '500 13.5px/1.4 var(--font-sans)',
color: isChosen ? 'var(--color-brand)' : 'var(--color-ink-1)',
}}
>
{label}
</span>
<span style={{ font: '400 12px/1.4 var(--font-sans)', color: 'var(--color-ink-3)' }}>
{detail}
</span>
</span>
</button>
);
}

View File

@@ -0,0 +1,128 @@
import { useState } from 'react';
import { Button } from '@astryxdesign/core/Button';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { Bike, Plus } from 'lucide-react';
import type { Partner, RiderRosterRow } from '@/api/deliveries';
import { usePartnerRiders } from '@/queries/hooks';
import { Drawer } from '@/features/store-admin/Drawer';
import { Badge, DrawerButton, DrawerCard, Note, Row, Section } from '@/features/store-admin/drawerKit';
import { RiderDrawer } from '@/features/store-admin/RiderDrawer';
/**
* A delivery partner's riders, kept by the platform.
*
* ── Why the platform keeps them ─────────────────────────────────────────────
*
* A partner has no console of its own. Their riders serve whichever merchants
* the partner supplies — one partner covers 48 shops today, another 63 — so
* they sit under no single merchant and no merchant's console can manage them.
* That leaves here.
*
* ── Duty is a state, never a filter ─────────────────────────────────────────
*
* This reads the ROSTER, not `getriders`. The second wants a clock-in stamped
* today, so a rider hired five minutes ago is absent from it — which is exactly
* what a failed save looks like. Everybody is listed, and whether they are
* working right now is shown beside them.
*/
export function PartnerRidersDrawer({
partner,
onClose,
}: {
partner: Partner;
onClose: () => void;
}) {
const riders = usePartnerRiders(partner.partnerid);
const [editing, setEditing] = useState<RiderRosterRow | 'new' | null>(null);
const rows = riders.data ?? [];
const onDuty = rows.filter((rider) => rider.isonduty).length;
return (
<>
<Drawer
title={partner.partnername ?? `Partner ${partner.partnerid}`}
subtitle="Riders"
width={520}
onClose={onClose}
isFooterSpread
footer={
<>
<DrawerButton label="Close" variant="ghost" onClick={onClose} />
<DrawerButton
label="Add rider"
variant="primary"
icon={<Plus size={15} />}
onClick={() => setEditing('new')}
/>
</>
}
>
<VStack gap={2}>
<Note icon={<Bike size={15} />}>
These riders deliver for every merchant this partner supplies. A rider hired here does
not appear in the on-duty fleet until they open the rider app and start a shift — that
is correct, and it looks exactly like a failed save.
</Note>
{riders.isLoading ? (
<Text type="body" size="sm" color="secondary">
Reading riders…
</Text>
) : rows.length === 0 ? (
<Section title="No riders yet">
<DrawerCard>
<Row
label="Fleet"
value="This partner has nobody on their books. Add one to get started."
isStacked
/>
</DrawerCard>
</Section>
) : (
<Section title={`${rows.length} rider${rows.length === 1 ? '' : 's'} · ${onDuty} on duty`}>
<DrawerCard>
{rows.map((rider) => (
<Row
key={rider.userid}
label={rider.fullname?.trim() || rider.firstname || `Rider ${rider.userid}`}
value={
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Badge
label={rider.isonduty ? 'On duty' : 'Off shift'}
colour={rider.isonduty ? '#0f8a5f' : 'var(--color-ink-4)'}
/>
<Button
label="Edit"
variant="ghost"
size="sm"
onClick={() => setEditing(rider)}
/>
</span>
}
/>
))}
</DrawerCard>
</Section>
)}
</VStack>
</Drawer>
{editing ? (
<RiderDrawer
row={editing === 'new' ? null : editing}
/* A partner's rider, so no tenant and no branch — they work a region
and serve whoever the partner supplies in it. */
owner={{
kind: 'partner',
partnerid: partner.partnerid,
partnername: partner.partnername ?? `Partner ${partner.partnerid}`,
applocationid: partner.applocationid ?? 0,
}}
onClose={() => setEditing(null)}
/>
) : null}
</>
);
}

View File

@@ -0,0 +1,612 @@
/**
* Rider partners — the companies that supply riders.
*
* ── Why this page did not exist ─────────────────────────────────────────────
*
* `getpartners` has always been readable and nothing on the platform could
* create a partner, so the five that exist were inserted by hand — two are
* still called "Test". Meanwhile 125 of 200 merchants already carry a
* `partnerid`, and one partner supplies 48 shops while another supplies 63. The
* relationship the whole delivery side rests on was real, live and unmanaged.
*
* ── What onboarding a partner records ───────────────────────────────────────
*
* Three things, and the last two are why the assign screen works at all:
*
* the district `partnerinfo.applocationid`, and `partnerlocations` beside
* it. Every rider query joins through that id, so it has to be
* a district Nearle actually services — see
* `tamilNaduDistricts.ts` for why all 38 are shown anyway.
* the merchant `tenants.partnerid`. This is what the assign screen reads to
* decide whether to offer a partner tab at all.
* the branch `tenantlocations.partnerid`. Which outlet they cover.
*
* A partner can also be attached to a merchant afterwards from that merchant's
* own page — see `StoreDetailPage` — which is the ordinary case of a shop
* changing partner without anybody re-onboarding the company.
*/
import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Badge } from '@astryxdesign/core/Badge';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Table, type TableColumn } from '@astryxdesign/core/Table';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { Bike, Plus, Truck } from 'lucide-react';
import { errorMessage } from '@/api/client';
import { partnersApi, type NewPartner, type Partner } from '@/api/deliveries';
import { tenantsApi } from '@/api/tenants';
import type { TenantInfo } from '@/api/types';
import { Selector } from '@astryxdesign/core/Selector';
import { districtOptions, isRunning, matchDistrict } from '../tamilNaduDistricts';
import { DataState } from '@/components/DataState';
import { PageHeader } from '@/components/PageHeader';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import {
useAllPartners,
useAppRegions,
usePartnerRiderCounts,
useTenantLocations,
useTenants,
} from '@/queries/hooks';
import { queryKeys } from '@/queries/keys';
import { Drawer } from '@/features/store-admin/Drawer';
import { PartnerRidersDrawer } from '../PartnerRidersDrawer';
import { DrawerButton } from '@/features/store-admin/drawerKit';
interface PartnerRow extends Record<string, unknown> {
partnerid: number;
partnername: string;
companyname: string;
region: string;
contact: string;
status: string;
/** How many riders they have. `null` while the count is still being read. */
riders: number | null;
}
export function PartnersPage() {
const partners = useAllPartners();
const regions = useAppRegions();
const [editing, setEditing] = useState<Partner | 'new' | null>(null);
/** The partner whose riders are on screen, if any. */
const [ridersFor, setRidersFor] = useState<Partner | null>(null);
/* The fleet size per partner, read alongside the directory. Without it the
Riders button is a door with nothing written on it. */
const riderCounts = usePartnerRiderCounts(partners.data.map((entry) => entry.partnerid));
const regionName = useMemo(() => {
const map = new Map<number, string>();
for (const region of regions.data ?? []) {
map.set(region.applocationid, region.locationname ?? `Region ${region.applocationid}`);
}
return map;
}, [regions.data]);
const rows = useMemo<PartnerRow[]>(
() =>
partners.data.map((partner) => ({
partnerid: partner.partnerid,
partnername: partner.partnername ?? `Partner ${partner.partnerid}`,
companyname: partner.companyname ?? '',
region: regionName.get(partner.applocationid ?? 0) ?? '—',
contact: partner.primarycontact || partner.contactno || '',
status: partner.status || 'Active',
riders: riderCounts.get(partner.partnerid) ?? null,
})),
[partners.data, regionName, riderCounts],
);
const paged = usePaged(rows);
const columns: TableColumn<PartnerRow>[] = [
{
key: 'partnername',
header: 'Partner',
width: { type: 'proportional', value: 3 },
renderCell: (row) => (
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">
{row.partnername}
</Text>
{row.companyname ? (
<Text type="body" size="xsm" color="secondary">
{row.companyname}
</Text>
) : null}
</VStack>
),
},
{
key: 'region',
header: 'Home region',
width: { type: 'proportional', value: 2 },
renderCell: (row) => (
<Text type="body" size="sm" color="secondary">
{row.region}
</Text>
),
},
{
key: 'contact',
header: 'Contact',
width: { type: 'proportional', value: 2 },
renderCell: (row) => (
<Text type="body" size="sm" style={{ fontFamily: 'var(--font-mono)' }}>
{row.contact || '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
align: 'end',
width: { type: 'pixel', value: 110 },
renderCell: (row) => (
<Badge
variant={row.status.toLowerCase() === 'active' ? 'success' : 'neutral'}
label={row.status}
/>
),
},
{
/* Both actions in ONE column with a header, rather than two unlabelled
ones. The riders button carries the fleet size, because "Riders" alone
asks you to open a drawer to learn whether there are any — and the
answer is the reason you would open it. */
key: 'actions',
header: 'Fleet',
align: 'end',
width: { type: 'pixel', value: 184 },
renderCell: (row) => (
<HStack gap={0.5} justify="end" align="center" className="fleet-actions">
<Button
/* One width for every row, fixed in CSS. The label is a count, so
it runs from one digit to three and a shrink-to-fit button leaves
the column ragged — an uneven edge reads as disorder before the
numbers themselves are read. */
label={row.riders === null ? '—' : `${row.riders} rider${row.riders === 1 ? '' : 's'}`}
variant="secondary"
size="sm"
icon={<Bike size={13} />}
onClick={() => {
const found = partners.data.find((p) => p.partnerid === row.partnerid);
if (found) setRidersFor(found);
}}
/>
<Button
label="Edit"
variant="ghost"
size="sm"
onClick={() => {
const found = partners.data.find((p) => p.partnerid === row.partnerid);
if (found) setEditing(found);
}}
/>
</HStack>
),
},
];
return (
<VStack gap={3}>
<PageHeader
title="Rider partners"
actions={
<Button
label="Onboard rider partner"
variant="primary"
size="sm"
icon={<Plus size={14} />}
onClick={() => setEditing('new')}
/>
}
/>
<VStack gap={1.5}>
{/* No section heading. The page is already titled "Rider partners" and
the table is the only thing on it — a second heading over one table
restates the page and pushes the rows down a row for nothing. */}
<Card padding={0} elevation="low">
<DataState
isLoading={partners.isLoading}
error={null}
isEmpty={rows.length === 0}
emptyTitle="No rider partners yet"
emptyDescription="A rider partner is the company that supplies riders. Onboard one, choose its district, and attach it to the shop and branch it delivers for."
>
<div className="table-scroll">
<Table<PartnerRow>
data={paged.rows}
columns={columns}
idKey="partnerid"
density="balanced"
hasHover
dividers="rows"
/>
</div>
<TablePager paged={paged} label="partners" />
</DataState>
</Card>
</VStack>
{ridersFor ? (
<PartnerRidersDrawer partner={ridersFor} onClose={() => setRidersFor(null)} />
) : null}
{editing ? (
<PartnerDrawer
partner={editing === 'new' ? null : editing}
onClose={() => setEditing(null)}
/>
) : null}
</VStack>
);
}
/* ── The form ─────────────────────────────────────────────────────────────── */
interface FormState {
partnername: string;
companyname: string;
registrationno: string;
primarycontact: string;
primaryemail: string;
address: string;
suburb: string;
city: string;
state: string;
postcode: string;
/** The serviced district they work out of — an `app_location` id. */
applocationid: number;
/** The merchant this partner delivers for. */
tenantid: number;
/** Which branch of that merchant — written to `tenantlocations.partnerid`. */
locationid: number;
}
const EMPTY: FormState = {
partnername: '',
companyname: '',
registrationno: '',
primarycontact: '',
primaryemail: '',
address: '',
suburb: '',
city: '',
state: '',
postcode: '',
applocationid: 0,
tenantid: 0,
locationid: 0,
};
function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose: () => void }) {
const client = useQueryClient();
const regions = useAppRegions();
const isNew = partner === null;
const [form, setForm] = useState<FormState>(() =>
partner
? {
partnername: partner.partnername ?? '',
companyname: partner.companyname ?? '',
registrationno: partner.registrationno ?? '',
primarycontact: partner.primarycontact ?? partner.contactno ?? '',
primaryemail: partner.primaryemail ?? '',
address: partner.address ?? '',
suburb: partner.suburb ?? '',
city: partner.city ?? '',
state: partner.state ?? '',
postcode: '',
applocationid: partner.applocationid ?? 0,
tenantid: 0,
locationid: 0,
}
: EMPTY,
);
const [error, setError] = useState<string | null>(null);
function set<K extends keyof FormState>(key: K) {
return (value: FormState[K]) => {
setError(null);
setForm((prev) => ({ ...prev, [key]: value }));
};
}
/* ── The district ─────────────────────────────────────────────────────────
One per partner, chosen from all 38. Picking one Nearle does not run yet
opens it — the partner is sent with the NAME and the server writes the
`app_location` and `app_locationconfig` rows first. */
const [districtSearch, setDistrictSearch] = useState('');
const [district, setDistrict] = useState<string>(() => partner?.city ?? '');
const districts = useMemo(() => districtOptions(regions.data ?? []), [regions.data]);
const shown = useMemo(
() => districts.filter((option) => matchDistrict(option, districtSearch)),
[districts, districtSearch],
);
const chosenDistrict = districts.find((option) => option.name === district);
/* ── Who they deliver for ─────────────────────────────────────────────────
A partner supplies riders TO a merchant's branch. Both links are written:
`tenants.partnerid`, which is what the assign screen reads to decide
whether to offer a partner tab at all, and `tenantlocations.partnerid`,
which records the branch. Without the first the toggle never appears;
without the second nothing says which outlet they cover.
Filtered to the district: a partner works one district, so a merchant in
another is not somebody they can deliver for. `getalltenants` returns a row
per BRANCH, and a branch's city is what places it — the tenant's own city
is the head office and can differ. */
const merchants = useTenants({ pageno: 1, pagesize: 200 });
const merchantOptions = useMemo(() => {
const here = district.trim().toLowerCase();
const seen = new Map<number, string>();
for (const entry of (merchants.data ?? []) as TenantInfo[]) {
if (!entry.tenantid || seen.has(entry.tenantid)) continue;
const city = (entry.city ?? '').trim().toLowerCase();
if (here && city && city !== here) continue;
seen.set(entry.tenantid, entry.tenantname);
}
return [...seen.entries()].map(([value, label]) => ({ value: String(value), label }));
}, [merchants.data, district]);
const branches = useTenantLocations(form.tenantid || undefined);
const branchOptions = useMemo(
() =>
(branches.data ?? []).map((branch) => ({
value: String(branch.locationid),
label: branch.locationname || `Branch ${branch.locationid}`,
})),
[branches.data],
);
const save = useMutation({
mutationFn: () => {
const body: NewPartner = {
partnername: form.partnername.trim(),
companyname: form.companyname.trim(),
registrationno: form.registrationno.trim(),
primarycontact: form.primarycontact.trim(),
primaryemail: form.primaryemail.trim(),
address: form.address.trim(),
suburb: form.suburb.trim(),
city: form.city.trim(),
state: form.state.trim(),
...(form.postcode.trim() ? { postcode: Number(form.postcode) || 0 } : {}),
/*
The district, by id when Nearle already runs it and by NAME when it
does not. The name is what opens it — the server writes the region
rows before the partner, so all 38 are real choices rather than three.
*/
applocationid: chosenDistrict?.applocationid ?? 0,
...(chosenDistrict && chosenDistrict.applocationid === 0
? { district: chosenDistrict.name }
: {}),
};
return partner
? partnersApi.update({ ...body, partnerid: partner.partnerid }).then(() => partner.partnerid)
: partnersApi.create(body).then((result) => result?.partnerid ?? 0);
},
/*
The placement is written after the partner exists, because it needs the
id the create hands back.
Reported separately if it fails, and deliberately not rolled back: the
partner is real either way and re-onboarding them would refuse on the
duplicate contact number. Saying "the partner was created but could not be
placed" is recoverable — the drawer stays open on the same form.
*/
onSuccess: async (partnerid) => {
if (partnerid > 0 && form.tenantid > 0) {
try {
await tenantsApi.assignPartner(form.tenantid, partnerid);
if (form.locationid > 0) {
await tenantsApi.updateBranch({ locationid: form.locationid, partnerid });
}
} catch (cause) {
await client.invalidateQueries({ queryKey: queryKeys.partners.all });
setError(
`${form.partnername.trim()} was saved, but could not be assigned to that branch: ${errorMessage(cause)}`,
);
return;
}
}
await client.invalidateQueries({ queryKey: queryKeys.partners.all });
await client.invalidateQueries({ queryKey: queryKeys.tenants.all });
onClose();
},
onError: (cause) => setError(errorMessage(cause)),
});
const isComplete =
form.partnername.trim() !== '' && form.primarycontact.trim() !== '' && form.applocationid > 0;
return (
<Drawer
title={isNew ? 'Onboard a rider partner' : form.partnername || 'Rider partner'}
subtitle={isNew ? 'The company that supplies riders' : `Partner ${partner?.partnerid}`}
width={520}
onClose={onClose}
isFooterSpread
footer={
<>
<DrawerButton label="Cancel" variant="ghost" onClick={onClose} />
<DrawerButton
label={save.isPending ? 'Saving…' : isNew ? 'Onboard rider partner' : 'Save changes'}
variant="primary"
icon={<Truck size={15} />}
isDisabled={!isComplete || save.isPending}
onClick={() => save.mutate()}
/>
</>
}
>
<VStack gap={2}>
{error ? (
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
{error}
</Text>
) : null}
<TextInput
label="Partner name"
size="sm"
value={form.partnername}
onChange={set('partnername')}
placeholder="e.g. Xpress-Cbe-Main"
description="How the partner is named on rider records and in the assign picker."
/>
<TextInput
label="Registered company"
size="sm"
value={form.companyname}
onChange={set('companyname')}
/>
<TextInput
label="Primary contact"
size="sm"
value={form.primarycontact}
onChange={set('primarycontact')}
placeholder="9876543210"
description="One partner per number — the server refuses a second."
/>
<TextInput
label="Email"
size="sm"
value={form.primaryemail}
onChange={set('primaryemail')}
/>
<TextInput
label="Registration number"
size="sm"
value={form.registrationno}
onChange={set('registrationno')}
/>
{/* ── District ──────────────────────────────────────────────────────
All 38 of Tamil Nadu's districts, searchable, with only the ones
Nearle services selectable. A partner placed in a district that has
no `app_location` row is a partner whose riders no query returns —
`getriders` filters on that id — so an unserviced district is shown
and refused rather than hidden, because "Erode is not open yet" is
an answer and a missing Erode is not. */}
<VStack gap={1}>
<Text type="label" size="sm" weight="semibold">
District
</Text>
<TextInput
label="Search districts"
isLabelHidden
size="sm"
value={districtSearch}
onChange={setDistrictSearch}
placeholder="Search all 38 districts…"
hasClear
/>
{/* A single-select list, not a cloud of chips: one partner works one
district, so this is a choice with one answer and it should read
like one. Running districts carry a tick, new ones say what will
happen — the difference is operational, not a restriction. */}
<div className="district-list" role="listbox" aria-label="Tamil Nadu districts">
{shown.map((option) => {
const running = isRunning(option);
const chosen = district === option.name;
return (
<button
key={option.name}
type="button"
role="option"
aria-selected={chosen}
className="district-row"
data-chosen={chosen ? 'yes' : 'no'}
onClick={() => {
setDistrict(option.name);
// The merchant is district-scoped, so changing the district
// invalidates it — keeping it would attach a partner to a
// shop in a place they do not work.
setForm((prev) => ({ ...prev, tenantid: 0, locationid: 0 }));
}}
>
<span className="district-row-name">{option.name}</span>
<span className="district-row-state" data-running={running ? 'yes' : 'no'}>
{running ? 'Running' : 'New district'}
</span>
</button>
);
})}
{shown.length === 0 ? (
<Text type="body" size="sm" color="secondary" style={{ padding: '10px 12px' }}>
No district matches “{districtSearch}”.
</Text>
) : null}
</div>
<Text type="body" size="xsm" color="secondary">
{chosenDistrict
? isRunning(chosenDistrict)
? `Riders are listed against ${chosenDistrict.name}.`
: `${chosenDistrict.name} will be opened when this partner is saved.`
: 'One district per partner. Choosing one Nearle does not run yet opens it.'}
</Text>
</VStack>
{/* ── Who they deliver for ──────────────────────────────────────────
The merchant, then the branch. Both links are written: the merchant
one is what the assign screen reads to decide whether to offer a
partner tab at all, and the branch one records which outlet. */}
<VStack gap={1}>
<Text type="label" size="sm" weight="semibold">
Delivers for
</Text>
<Selector
label="Merchant"
size="sm"
value={form.tenantid ? String(form.tenantid) : ''}
onChange={(value) => {
// A new merchant clears the branch with it — keeping it would
// leave another shop's outlet id attached to this partner.
setForm((prev) => ({ ...prev, tenantid: Number(value) || 0, locationid: 0 }));
}}
options={merchantOptions}
placeholder={merchants.isLoading ? 'Loading merchants…' : 'Choose a merchant'}
/>
<Selector
label="Branch"
size="sm"
value={form.locationid ? String(form.locationid) : ''}
onChange={(value) => set('locationid')(Number(value) || 0)}
options={branchOptions}
isDisabled={!form.tenantid}
placeholder={
!form.tenantid
? 'Choose a merchant first'
: branches.isLoading
? 'Loading branches…'
: 'Choose the branch they cover'
}
/>
<Text type="body" size="xsm" color="secondary">
Optional. Set it and this partner’s riders become an option on that shop’s assign
screen, beside any riders it hired itself.
</Text>
</VStack>
<TextInput label="Address" size="sm" value={form.address} onChange={set('address')} />
<HStack gap={1}>
<TextInput label="Area" size="sm" value={form.suburb} onChange={set('suburb')} />
<TextInput label="City" size="sm" value={form.city} onChange={set('city')} />
</HStack>
<HStack gap={1}>
<TextInput label="State" size="sm" value={form.state} onChange={set('state')} />
<TextInput label="Postcode" size="sm" value={form.postcode} onChange={set('postcode')} />
</HStack>
</VStack>
</Drawer>
);
}

View File

@@ -7,7 +7,7 @@ import { HStack } from '@astryxdesign/core/HStack';
import { Table, type TableColumn } from '@astryxdesign/core/Table';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { IndianRupee, QrCode, ShoppingCart, Store, TriangleAlert } from 'lucide-react';
import { IndianRupee, QrCode, ShoppingCart, Store, TriangleAlert, Truck } from 'lucide-react';
import { DataState } from '@/components/DataState';
import { Freshness } from '@/components/Freshness';
import { KpiCard } from '@/components/KpiCard';
@@ -19,6 +19,7 @@ import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import { Drawer } from '@/features/store-admin/Drawer';
import { StoreQrPanel } from '@/features/qr/StoreQrPanel';
import { PartnerAssignDrawer } from '../PartnerAssignDrawer';
interface BranchRow extends Record<string, unknown> {
locationid: number;
@@ -95,6 +96,8 @@ export function StoreDetailPage() {
/** The branch whose code is on screen, if any. */
const [qrFor, setQrFor] = useState<BranchRow | null>(null);
/** Open while the merchant's delivery partner is being changed. */
const [isPartnerOpen, setPartnerOpen] = useState(false);
const columns: TableColumn<BranchRow>[] = [
{
@@ -205,6 +208,12 @@ export function StoreDetailPage() {
title={tenant?.tenantname ?? 'Tenant'}
actions={
<HStack gap={1}>
<Button
label="Delivery partner"
variant="secondary"
icon={<Truck size={14} />}
onClick={() => setPartnerOpen(true)}
/>
<Button
label="Stock catalogue"
variant="secondary"
@@ -293,6 +302,15 @@ export function StoreDetailPage() {
</Card>
</VStack>
{isPartnerOpen && tenantid ? (
<PartnerAssignDrawer
tenantid={tenantid}
tenantname={tenant?.tenantname ?? 'this merchant'}
currentPartnerId={Number((tenant as unknown as Record<string, number>)?.['partnerid'] ?? 0)}
onClose={() => setPartnerOpen(false)}
/>
) : null}
{qrFor && tenantid ? (
<Drawer
title="Store QR code"

View File

@@ -0,0 +1,74 @@
/**
* The district list, and the line between "not open yet" and "not a place".
*
* A partner placed in a district with no `app_location` row is a partner whose
* riders no query returns — `getriders` filters on that id and `CreateRider`
* refuses a region with no config. Every district can be chosen — picking one
* that is not running yet OPENS it, writing both rows server-side — so what
* these pin is that the list is complete and that the running ones carry the id
* they will be saved with.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
TAMIL_NADU_DISTRICTS,
districtOptions,
isRunning,
matchDistrict,
} from './tamilNaduDistricts';
const live = [
{ applocationid: 1, locationname: 'Coimbatore' },
{ applocationid: 2, locationname: 'Madurai' },
{ applocationid: 23, locationname: 'Nagercoil' },
];
test('all 38 districts are offered, not just the three that are running', () => {
assert.equal(TAMIL_NADU_DISTRICTS.length, 38);
assert.ok(districtOptions(live).length >= 38);
});
test('a running district carries the region id it will be saved with', () => {
const options = districtOptions(live);
const coimbatore = options.find((option) => option.name === 'Coimbatore');
assert.equal(coimbatore?.applocationid, 1);
assert.equal(isRunning(coimbatore!), true);
});
// A district Nearle does not run yet carries no id — the form sends its NAME
// instead and the server opens it. Reporting 0 is what makes the form say
// "Erode will be opened" rather than silently saving a partner into nothing.
test('a district Nearle does not run yet is offered, with no id yet', () => {
const erode = districtOptions(live).find((option) => option.name === 'Erode');
assert.ok(erode, 'Erode is a district and belongs in the list');
assert.equal(erode!.applocationid, 0);
assert.equal(isRunning(erode!), false);
});
// Nagercoil is the district's town; the district is Kanyakumari. Matching on
// name means it arrives as its own entry rather than being lost — which is the
// safe direction: a region somebody is already working must never disappear.
test('a running region whose name is not one of the 38 is still offered', () => {
const options = districtOptions(live);
const nagercoil = options.find((option) => option.name === 'Nagercoil');
assert.equal(nagercoil?.applocationid, 23);
});
test('matching is case-insensitive and matches anywhere in the name', () => {
const [erode] = districtOptions(live).filter((option) => option.name === 'Erode');
assert.equal(matchDistrict(erode!, 'ero'), true);
assert.equal(matchDistrict(erode!, 'ROD'), true);
assert.equal(matchDistrict(erode!, ''), true, 'an empty box hides nothing');
assert.equal(matchDistrict(erode!, 'salem'), false);
});
test('with no regions read yet, every district reads as new and none is lost', () => {
const options = districtOptions([]);
assert.equal(options.length, 38);
assert.equal(options.every((option) => !isRunning(option)), true);
});
test('the list is alphabetical, so a long list can be scanned', () => {
const names = districtOptions(live).map((option) => option.name);
assert.deepEqual(names, [...names].sort((a, b) => a.localeCompare(b)));
});

View File

@@ -0,0 +1,133 @@
/**
* The districts of Tamil Nadu, for the partner onboarding form.
*
* ── Why this is a list in the code ──────────────────────────────────────────
*
* The platform has no district master. `app_location` holds the districts
* Nearle actually RUNS — three of them when this was written: Coimbatore,
* Madurai and Nagercoil — each with a radius, opening hours and an image,
* because it is an operating record rather than a geography one.
*
* A picker built from `app_location` alone can therefore offer three names for
* a state with thirty-eight districts. This list is the geography; the regions
* read back from the API say which of them are already running.
*
* ── Every district can be chosen ────────────────────────────────────────────
*
* Choosing one that is not running yet OPENS it: the partner is sent with a
* district NAME and the server writes the `app_location` and
* `app_locationconfig` rows before creating the partner. Both rows matter —
* `getriders` joins the first and `CreateRider` refuses a region missing the
* second — so opening a district means both or neither.
*
* `isRunning` therefore marks what already exists, not what is permitted. It is
* shown so an operator can tell "this is where we work" from "this will be a
* new district", which is a real operational difference and not a restriction.
*/
/** All 38 districts, alphabetically. Names as the state government writes them. */
export const TAMIL_NADU_DISTRICTS = [
'Ariyalur',
'Chengalpattu',
'Chennai',
'Coimbatore',
'Cuddalore',
'Dharmapuri',
'Dindigul',
'Erode',
'Kallakurichi',
'Kanchipuram',
'Kanyakumari',
'Karur',
'Krishnagiri',
'Madurai',
'Mayiladuthurai',
'Nagapattinam',
'Namakkal',
'Nilgiris',
'Perambalur',
'Pudukkottai',
'Ramanathapuram',
'Ranipet',
'Salem',
'Sivaganga',
'Tenkasi',
'Thanjavur',
'Theni',
'Thoothukudi',
'Tiruchirappalli',
'Tirunelveli',
'Tirupathur',
'Tiruppur',
'Tiruvallur',
'Tiruvannamalai',
'Tiruvarur',
'Vellore',
'Viluppuram',
'Virudhunagar',
] as const;
export type TamilNaduDistrict = (typeof TAMIL_NADU_DISTRICTS)[number];
/** One district as the form sees it: its name, and whether it is running. */
export interface DistrictOption {
name: string;
/** The `app_location` id when the district already runs, 0 when it is new. */
applocationid: number;
}
/**
* The district list, matched against the regions the platform actually runs.
*
* Matched on NAME, case-insensitively, because that is the only thing the two
* sources share — `app_location` has no district column, and its `locationname`
* is the district's name ("Coimbatore", "Madurai", "Nagercoil").
*
* A serviced region whose name is not one of the 38 is still listed rather than
* dropped. Nearle opening a place this file has not heard of is a reason to
* update the file, not a reason to hide a region somebody is already working.
*/
export function districtOptions(
regions: readonly { applocationid: number; locationname?: string }[] = [],
): DistrictOption[] {
const serviced = new Map<string, number>();
for (const region of regions) {
const name = (region.locationname ?? '').trim();
if (name) serviced.set(name.toLowerCase(), region.applocationid);
}
const options: DistrictOption[] = TAMIL_NADU_DISTRICTS.map((name) => ({
name,
applocationid: serviced.get(name.toLowerCase()) ?? 0,
}));
// A running region whose name is not one of the 38 — Nagercoil is the town,
// the district is Kanyakumari — is still listed rather than dropped. A place
// somebody is already working must never disappear from the picker.
const known = new Set(TAMIL_NADU_DISTRICTS.map((name) => name.toLowerCase()));
for (const region of regions) {
const name = (region.locationname ?? '').trim();
if (name && !known.has(name.toLowerCase())) {
options.push({ name, applocationid: region.applocationid });
}
}
return options.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* True when Nearle already runs this district.
*
* Not a permission — every district can be chosen. This distinguishes "we work
* here" from "this will be opened", which the form says out loud because
* opening a district writes rows and is worth knowing before you press save.
*/
export function isRunning(option: DistrictOption): boolean {
return option.applocationid > 0;
}
/** Filter for the search box. Matches anywhere in the name, case-insensitively. */
export function matchDistrict(option: DistrictOption, term: string): boolean {
const needle = term.trim().toLowerCase();
return needle === '' || option.name.toLowerCase().includes(needle);
}

View File

@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { Selector } from '@astryxdesign/core/Selector';
import { Route, UserCheck } from 'lucide-react';
import { Bike, Route, Truck, UserCheck } from 'lucide-react';
import { errorMessage } from '@/api/client';
import {
RIDER_MESSAGE,
@@ -10,9 +10,10 @@ import {
deliveriesApi,
} from '@/api/deliveries';
import type { OrderRow, TenantLocation } from '@/api/types';
import { useRiders } from '@/queries/hooks';
import { useAllPartners, useOwnTenant, useRiders } from '@/queries/hooks';
import { queryKeys } from '@/queries/keys';
import { buildDeliveries, riderName, riderVehicle } from './assignDelivery';
import { useBranchScope } from './BranchScope';
import { buildDeliveries, riderName, riderScope, riderVehicle } from './assignDelivery';
import { RoutePlanDrawer } from './RoutePlanDrawer';
import './pages/deliveries.css';
@@ -44,12 +45,38 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
const [outcome, setOutcome] = useState<string | null>(null);
const [planning, setPlanning] = useState(false);
// Every order in a batch shares a region in practice — they are one branch's
// orders — so the first one carrying a region decides which fleet to ask for.
const region =
orders.map((row) => row.applocationid || branchOf(row)?.applocationid || 0).find(Boolean) ?? 0;
/**
* Which fleet these orders are handed to.
*
* Two real sources, and a merchant can have both: riders they hired
* themselves, and riders from the delivery partner they sit under. Asked as a
* question rather than merged into one list, because they are different
* people with different employers and an operator handing an order over
* should know which.
*
* The partner side only appears when the merchant HAS one — `tenants.partnerid`
* — so a shop delivering with its own riders never sees an empty second tab.
*/
const { tenantid } = useBranchScope();
const shop = useOwnTenant(tenantid || undefined);
const partnerid = Number((shop.data as unknown as Record<string, number>)?.['partnerid'] ?? 0);
const [source, setSource] = useState<'own' | 'partner'>(partnerid > 0 ? 'partner' : 'own');
const riders = useRiders(region || undefined);
// The partner's NAME on the tab, not "Partner riders". An operator handing an
// order to Xpress-Cbe-Main should read that, not a category.
const partners = useAllPartners();
const partnerName =
partners.data.find((entry) => entry.partnerid === partnerid)?.partnername ?? '';
/*
Scoped by owner, never by city.
It asked `getriders` with the region alone, which means "who is on duty in
Coimbatore" — 82 riders, almost none of them this shop's to use. Measured
2026-09-09: 117 of the platform's 118 riders belong to a partner, so region
scope was quietly offering other companies' fleets.
*/
const riders = useRiders(riderScope({ tenantid, partnerid, source }));
const fleet = riders.data ?? [];
const rider = fleet.find((entry) => String(entry.userid) === riderId);
@@ -110,6 +137,38 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
{orders.length} selected
</span>
{/* Only when there is a choice to make. A shop with no partner has one
source, and a toggle with one option is a control that asks a question
it already knows the answer to. */}
{partnerid > 0 ? (
<div className="assign-source" role="group" aria-label="Which riders to offer">
<button
type="button"
className="assign-source-btn"
aria-pressed={source === 'own'}
onClick={() => {
setSource('own');
setRiderId('');
}}
>
<Bike size={13} />
Own riders
</button>
<button
type="button"
className="assign-source-btn"
aria-pressed={source === 'partner'}
onClick={() => {
setSource('partner');
setRiderId('');
}}
>
<Truck size={13} />
{partnerName || 'Partner riders'}
</button>
</div>
) : null}
<div className="assign-bar-picker">
<Selector
label="Choose a rider"
@@ -127,14 +186,19 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
rather than being flattened to "Select…". "No riders on duty" is a
shift that has not started; the region case is a branch somebody
has to configure. Neither is a fault in the orders. */
/* Four situations, and the placeholder names which one — an operator
looking at an empty list needs to know whether to wait, to hire, or
to ask for a partner. */
placeholder={
region === 0
? 'No delivery region on this branch'
: riders.isLoading
? 'Loading riders…'
: options.length > 0
? 'Select a rider…'
: 'No rider has clocked on today'
riders.isLoading
? 'Loading riders…'
: options.length > 0
? 'Select a rider…'
: source === 'partner'
? `No ${partnerName || 'partner'} rider has clocked on today`
: partnerid > 0
? 'This shop has no riders of its own'
: 'No rider has clocked on today'
}
/>
</div>

View File

@@ -9,7 +9,7 @@ import { Bike, Info } from 'lucide-react';
import { errorMessage } from '@/api/client';
import { ridersApi, type RiderRosterRow } from '@/api/deliveries';
import type { TenantLocation } from '@/api/types';
import { usePartners, useRiderShifts } from '@/queries/hooks';
import { useRiderShifts } from '@/queries/hooks';
import { queryKeys } from '@/queries/keys';
import { Drawer } from './Drawer';
import { DrawerButton } from './drawerKit';
@@ -31,15 +31,30 @@ import './pages/deliveries.css';
* is correct behaviour and it looks exactly like a failed save, so the drawer
* says it before you press the button rather than leaving you to wonder.
*/
/**
* Whose rider this is.
*
* A rider belongs to a merchant OR to a delivery partner — the server refuses
* neither and refuses both, because a rider carrying both ids appears in two
* directories and two assign pickers with nothing saying which owns them.
*
* The two cases genuinely differ in the form, not just in the payload: a
* merchant's rider works out of one of that merchant's branches, and a
* partner's works a region and serves whichever merchants the partner supplies.
*/
export type RiderOwner =
| { kind: 'tenant'; tenantid: number; branches: readonly TenantLocation[] }
| { kind: 'partner'; partnerid: number; partnername: string; applocationid: number };
export interface RiderDrawerProps {
row: RiderRosterRow | null;
tenantid: number;
/** The branch whose delivery region a new rider inherits. */
branch: TenantLocation | undefined;
owner: RiderOwner;
/** The branch whose delivery region a new rider inherits. Tenant case only. */
branch?: TenantLocation | undefined;
onClose: () => void;
}
export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps) {
export function RiderDrawer({ row, owner, branch, onClose }: RiderDrawerProps) {
const client = useQueryClient();
const isNew = row === null;
@@ -50,10 +65,12 @@ export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps
* branch they are standing in already determines it. Editing keeps whatever
* the rider has; a new rider takes the branch's.
*/
const applocationid = row?.applocationid ?? branch?.applocationid ?? 0;
const applocationid =
row?.applocationid ??
(owner.kind === 'partner' ? owner.applocationid : branch?.applocationid) ??
0;
const shifts = useRiderShifts(applocationid || undefined);
const partners = usePartners(applocationid || undefined);
const [form, setForm] = useState({
firstname: row?.firstname ?? '',
@@ -66,7 +83,12 @@ export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps
licenseno: row?.licenseno ?? '',
registrationno: row?.registrationno ?? '',
shiftid: row?.shiftid ? String(row.shiftid) : '',
partnerid: row?.partnerid ? String(row.partnerid) : '',
// Prefilled from the branch in scope for a new own rider — most shops run
// one outlet, and asking a question with one legal answer is a chance to
// get it wrong.
locationid: String(
row?.locationid ?? (owner.kind === 'tenant' ? (branch?.locationid ?? 0) : 0),
),
status: row?.status ?? 'Active',
});
const [problem, setProblem] = useState<string | null>(null);
@@ -82,7 +104,12 @@ export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps
contactno: form.contactno.trim(),
email: form.email.trim(),
applocationid,
partnerid: Number(form.partnerid) || 0,
// Exactly one owner, set from the scope rather than from a field. The
// person filling this in is already inside a merchant's console or a
// partner's directory; asking again would only offer a way to be wrong.
...(owner.kind === 'tenant'
? { locationid: Number(form.locationid) || 0 }
: { partnerid: owner.partnerid }),
shiftid: Number(form.shiftid) || 0,
identificationno: form.identificationno.trim(),
vehiclename: form.vehiclename.trim(),
@@ -91,9 +118,10 @@ export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps
registrationno: form.registrationno.trim(),
status: form.status,
};
return isNew
? ridersApi.create(tenantid, payload)
: ridersApi.update({ ...payload, userid: row.userid });
if (!isNew) return ridersApi.update({ ...payload, userid: row.userid });
return owner.kind === 'tenant'
? ridersApi.create(owner.tenantid, payload)
: ridersApi.createForPartner(owner.partnerid, payload);
},
onSuccess: async () => {
await client.invalidateQueries({ queryKey: queryKeys.insights.all });
@@ -180,19 +208,32 @@ export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps
: 'No shifts set up for this region'
}
/>
<Selector
label="Delivery partner"
size="sm"
value={form.partnerid}
onChange={(value) => set('partnerid')(String(value))}
options={(partners.data ?? []).map((partner) => ({
value: String(partner.partnerid),
label: partner.partnername || `Partner ${partner.partnerid}`,
}))}
placeholder={
partners.isLoading ? 'Loading partners…' : 'None — rides for the shop directly'
}
/>
{/* A merchant's rider works out of one of that merchant's branches.
A partner's does not — a partner supplies several merchants and is
tied to none of their outlets — so the question is only asked
where it has an answer. */}
{owner.kind === 'tenant' ? (
<Selector
label="Branch"
size="sm"
value={form.locationid}
onChange={(value) => set('locationid')(String(value))}
options={owner.branches.map((entry) => ({
value: String(entry.locationid),
label: entry.locationname || `Branch ${entry.locationid}`,
}))}
placeholder="Choose the branch they ride from"
description="Which of your outlets this rider works out of. It can be changed later."
/>
) : (
<div className="rider-note">
<Info size={14} />
<Text type="body" size="sm" color="secondary">
Rides for <strong>{owner.partnername}</strong>, and delivers for every merchant that
partner supplies in this region.
</Text>
</div>
)}
</VStack>
<VStack gap={1.5}>

View File

@@ -14,6 +14,7 @@ import {
riderVehicle,
stampNow,
waitingMs,
riderScope,
} from './assignDelivery';
/*
@@ -322,3 +323,38 @@ test('the vehicle line is empty rather than a stray separator when nothing is re
assert.equal(riderVehicle(rider), 'Bike · TN 38CV 1535');
assert.equal(riderVehicle({ userid: 1 }), '');
});
/* ── Which fleet an order is offered ──────────────────────────────────────── */
/*
Measured on 2026-09-09: 118 riders across three regions, and 117 of them belong
to a delivery partner — 75 to partner 44 alone. Exactly one is a merchant's own.
So asking `getriders` by REGION, which is what the picker did, offered a
merchant every on-duty rider in their city: other merchants' own riders and
every other partner's fleet. The scope has to name an owner.
*/
test('a fleet scope names an owner, never a city', () => {
assert.deepEqual(riderScope({ tenantid: 1147, partnerid: 44, source: 'partner' }), {
partnerid: 44,
});
assert.deepEqual(riderScope({ tenantid: 1147, partnerid: 44, source: 'own' }), {
tenantid: 1147,
});
});
// A shop with no partner has one source, and choosing "partner" cannot happen
// — but if it somehow did, it must not fall back to the city.
test('no partner means own riders, not everybody in the city', () => {
assert.deepEqual(riderScope({ tenantid: 1147, partnerid: 0, source: 'partner' }), {
tenantid: 1147,
});
});
// R mart today: no partner, no riders of its own. The honest answer is an empty
// picker, not 82 riders it cannot use.
test('a shop with neither gets an empty fleet, not a borrowed one', () => {
const scope = riderScope({ tenantid: 0, partnerid: 0, source: 'own' });
assert.deepEqual(scope, { tenantid: undefined });
});

View File

@@ -349,3 +349,20 @@ export function riderName(rider: RiderInfo): string {
export function riderVehicle(rider: RiderInfo): string {
return [rider.vehiclename, rider.vehicleno].map((part) => (part ?? '').trim()).filter(Boolean).join(' · ');
}
/**
* Which fleet to ask for, given the merchant and the source they chose.
*
* Its own function so the rule is testable and so the picker and anything that
* follows it cannot drift apart on it. Never returns a region: `getriders`
* scoped by city answers "who is on duty in Coimbatore", which for a merchant
* is 82 riders belonging to other companies.
*/
export function riderScope(input: {
tenantid: number;
partnerid: number;
source: 'own' | 'partner';
}): { tenantid?: number | undefined; partnerid?: number | undefined } {
if (input.source === 'partner' && input.partnerid > 0) return { partnerid: input.partnerid };
return { tenantid: input.tenantid || undefined };
}

View File

@@ -288,7 +288,7 @@ export function UsersPage() {
{editingRider ? (
<RiderDrawer
row={editingRider === 'new' ? null : editingRider}
tenantid={tenantid}
owner={{ kind: 'tenant', tenantid, branches: scoped }}
/* The delivery region is inherited from the branch, so the drawer
needs one. A selected branch wins; otherwise the first in scope,
since every branch of a tenant sits in the same region in practice

View File

@@ -2017,3 +2017,139 @@ main {
font: 500 12px/1.4 var(--font-sans);
color: var(--color-ink-2, var(--color-ink-1));
}
/* ── Assign: which fleet ───────────────────────────────────────────────────
Two sources for one order — the shop's own riders and the delivery partner's
— as a segmented pair rather than a dropdown inside a dropdown. It appears
only when the merchant actually has a partner, so it is never a control with
one option. */
.assign-source {
display: inline-flex;
flex: none;
padding: 2px;
gap: 2px;
border: 1px solid var(--color-line);
border-radius: 10px;
background: var(--color-surface-sunken);
}
.assign-source-btn {
appearance: none;
border: 0;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 11px;
border-radius: 8px;
background: transparent;
font: 500 12.5px/1.4 var(--font-sans);
color: var(--color-ink-3);
cursor: pointer;
white-space: nowrap;
transition: background .15s ease, color .15s ease;
}
.assign-source-btn:hover { color: var(--color-ink-1); }
.assign-source-btn[aria-pressed='true'] {
background: var(--color-surface);
color: var(--color-brand);
font-weight: 600;
box-shadow: 0 1px 2px rgb(15 23 42 / 0.06);
}
.assign-source-btn:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 1px; }
@media (prefers-reduced-motion: reduce) {
.assign-source-btn { transition: none; }
}
/* ── Rider partners: choosing the district ─────────────────────────────────
One partner works one district, so this is a single choice and it is drawn
as a list — a scrollable one, because Tamil Nadu has thirty-eight and the
search box above narrows them.
Every district is choosable. The right-hand label separates the ones Nearle
already runs from the ones that will be opened when the partner is saved;
that is an operational difference worth seeing, not a permission. */
.district-list {
max-height: 208px;
overflow-y: auto;
border: 1px solid var(--color-line);
border-radius: 12px;
background: var(--color-surface);
}
.district-row {
appearance: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
padding: 9px 13px;
border: 0;
border-top: 1px solid var(--color-line-soft, var(--color-line));
background: transparent;
cursor: pointer;
text-align: left;
transition: background .12s ease;
}
.district-list > .district-row:first-child { border-top: 0; }
.district-row:hover { background: var(--color-surface-sunken); }
.district-row[data-chosen='yes'] { background: var(--color-brand-tint); }
.district-row-name {
font: 500 13.5px/1.45 var(--font-sans);
color: var(--color-ink-1);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.district-row[data-chosen='yes'] .district-row-name {
color: var(--color-brand);
font-weight: 600;
}
.district-row-state {
flex: none;
font: 500 11px/1.6 var(--font-sans);
letter-spacing: 0.02em;
padding: 1px 8px;
border-radius: 999px;
color: var(--color-ink-4);
background: var(--color-surface-sunken);
}
.district-row-state[data-running='yes'] {
color: var(--color-success, #1f9d55);
background: var(--color-success-tint, #eaf7ef);
}
@media (prefers-reduced-motion: reduce) {
.district-row { transition: none; }
}
/* ── Rider partners: the Fleet column ──────────────────────────────────────
Both actions on one row, and the riders button held to one width across
every partner. Its label is a count — "3 riders", "75 riders", "—" while it
loads — so a shrink-to-fit button makes the column's right edge ragged, and
a ragged edge reads as disorder before the numbers are read at all.
Width, not min-width: the point is that they match, and min-width would let
a four-digit fleet break the alignment it exists to keep. */
.fleet-actions > *:first-child,
.fleet-actions > *:first-child button {
width: 104px;
flex: none;
}
/* The count is the content, so centre it rather than leaving the icon to
push it around as the digits change. */
.fleet-actions > *:first-child button {
justify-content: center;
}

View File

@@ -16,6 +16,7 @@ import { productsApi } from '@/api/products';
import { posUsersApi, staffApi } from '@/api/people';
import { stockApi, type StockRequestQuery } from '@/api/stock';
import { tenantsApi, utilsApi, type TenantListQuery } from '@/api/tenants';
import { partnersApi } from '@/api/deliveries';
import { customersApi, type CustomerQuery } from '@/api/customers';
import { uploadsApi } from '@/api/uploads';
import { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories';
@@ -138,6 +139,93 @@ export function useImportedRefs(tenantid: number | undefined) {
/* ── Products ────────────────────────────────────────────────────────────── */
/**
* Every delivery partner across every region.
*
* `getpartners` takes one region at a time and refuses 0, so the whole list is
* the regions fanned out and flattened. Three regions today — Coimbatore,
* Madurai, Nagercoil — so this is three requests, not a page of them.
*/
export function useAllPartners() {
const regions = useAppRegions();
const ids = (regions.data ?? []).map((region) => region.applocationid);
const results = useQueries({
queries: ids.map((applocationid) => ({
queryKey: queryKeys.partners.inRegion(applocationid),
queryFn: () => partnersApi.list(applocationid),
...stable,
})),
});
return {
data: results.flatMap((result) => result.data ?? []),
isLoading: regions.isLoading || results.some((result) => result.isLoading),
};
}
/** The delivery regions. `0` asks for all of them — see `utilsApi.appLocations`. */
export function useAppRegions() {
return useQuery({
queryKey: queryKeys.regions.all,
queryFn: () => utilsApi.appLocations(0),
...stable,
});
}
/**
* One delivery partner's riders.
*
* The roster, not `getriders`: the second wants a clock-in stamped today, so a
* rider added five minutes ago is absent from it — which reads as a failed
* save. The directory shows everybody and reports duty as a state.
*/
export function usePartnerRiders(partnerid: number | undefined) {
return useQuery({
queryKey: [...queryKeys.partners.all, 'riders', partnerid ?? 0] as const,
queryFn: () => ridersApi.partnerRoster(partnerid as number),
enabled: typeof partnerid === 'number' && partnerid > 0,
...stable,
});
}
/**
* How many riders each partner has, keyed by partnerid.
*
* Fanned out because `getriderroster` takes one partner at a time. Five
* partners today, so five requests — and the count is what makes the Riders
* button worth pressing, since "Riders" alone asks you to open a drawer to find
* out whether there are any.
*/
export function usePartnerRiderCounts(partnerids: readonly number[]) {
const ids = [...new Set(partnerids)].filter((id) => id > 0);
const results = useQueries({
queries: ids.map((partnerid) => ({
queryKey: [...queryKeys.partners.all, 'riders', partnerid] as const,
queryFn: () => ridersApi.partnerRoster(partnerid),
...stable,
})),
});
const counts = new Map<number, number>();
results.forEach((result, index) => {
const id = ids[index];
if (id !== undefined && result.data) counts.set(id, result.data.length);
});
return counts;
}
/** The regions one partner covers. */
export function usePartnerLocations(partnerid: number | undefined) {
return useQuery({
queryKey: queryKeys.partners.locations(partnerid ?? 0),
queryFn: () => partnersApi.locations(partnerid as number),
enabled: typeof partnerid === 'number' && partnerid > 0,
...stable,
});
}
/**
* The ten aisles the customer app groups products into.
*
@@ -356,11 +444,21 @@ export function useDeliveries(query: OrderQuery | undefined) {
* Scoped by `applocationid`. Passing a tenant instead returns an empty list
* with a 200 — see `deliveriesApi.riders`.
*/
export function useRiders(applocationid: number | undefined) {
export function useRiders(scope: {
/** The merchant's own riders. */
tenantid?: number | undefined;
/** A delivery partner's riders. */
partnerid?: number | undefined;
/** Only used when neither of the above is given. */
applocationid?: number | undefined;
}) {
const { tenantid, partnerid, applocationid } = scope;
return useQuery({
queryKey: queryKeys.insights.riders(applocationid ?? 0),
queryFn: () => deliveriesApi.riders({ applocationid: applocationid as number }),
enabled: Boolean(applocationid),
// The scope is part of the key, or one fleet's riders would be served to
// the other after a toggle.
queryKey: [...queryKeys.insights.all, 'riders', tenantid ?? 0, partnerid ?? 0, applocationid ?? 0] as const,
queryFn: () => deliveriesApi.riders({ tenantid, partnerid, applocationid }),
enabled: Boolean(tenantid || partnerid || applocationid),
...live,
});
}

View File

@@ -8,6 +8,14 @@
*/
export const queryKeys = {
/** Delivery partners — the companies that supply riders. */
partners: {
all: ['partners'] as const,
inRegion: (applocationid: number) => ['partners', 'region', applocationid] as const,
locations: (partnerid: number) => ['partners', 'locations', partnerid] as const,
},
/** The delivery regions a partner can cover. */
regions: { all: ['regions'] as const },
uploads: {
all: ['uploads'] as const,
list: (tenantid: number, locationid: number) =>