diff --git a/src/App.tsx b/src/App.tsx
index 70a68a5..49e4510 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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. */}
} />
} />
+ {/* Delivery partners — the companies that supply riders. Platform-side
+ only: a merchant is assigned one, never allowed to create one. */}
+ } />
} />
{/* 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
diff --git a/src/api/deliveries.ts b/src/api/deliveries.ts
index 741a45a..701fcba 100644
--- a/src/api/deliveries.ts
+++ b/src/api/deliveries.ts
@@ -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(`${WEB}/partners/getriders`, { applocationid: query.applocationid }),
+ api.list(`${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(`${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(`${WEB}/partners/getriderroster`, { partnerid }),
+
update: (rider: NewRider & { userid: number }) =>
api.put(`${WEB}/partners/updaterider`, rider),
@@ -253,3 +354,38 @@ export const ridersApi = {
partners: (applocationid: number) =>
api.list(`${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(`${WEB}/partners/getpartners`, { applocationid }),
+
+ /** One partner, by id. */
+ byId: (partnerid: number) =>
+ api.list(`${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(`${WEB}/partners/updatepartner`, partner),
+
+ /** The regions one partner covers. */
+ locations: (partnerid: number) =>
+ api.list(`${WEB}/partners/getpartnerlocations`, { partnerid }),
+};
diff --git a/src/api/tenants.ts b/src/api/tenants.ts
index a0c8352..d894c3c 100644
--- a/src/api/tenants.ts
+++ b/src/api/tenants.ts
@@ -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) =>
api.put(`${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(`${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(`${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(`${WEB}/utils/getapplocations`, { applocationid }),
};
diff --git a/src/features/nearle-admin/NearleAdminShell.tsx b/src/features/nearle-admin/NearleAdminShell.tsx
index 7440422..d82bc6c 100644
--- a/src/features/nearle-admin/NearleAdminShell.tsx
+++ b/src/features/nearle-admin/NearleAdminShell.tsx
@@ -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' },
];
diff --git a/src/features/nearle-admin/PartnerAssignDrawer.tsx b/src/features/nearle-admin/PartnerAssignDrawer.tsx
new file mode 100644
index 0000000..a4079e1
--- /dev/null
+++ b/src/features/nearle-admin/PartnerAssignDrawer.tsx
@@ -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(currentPartnerId);
+ const [error, setError] = useState(null);
+
+ const regionName = useMemo(() => {
+ const map = new Map();
+ 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 (
+
+
+ }
+ isDisabled={save.isPending || chosen === currentPartnerId}
+ onClick={() => save.mutate()}
+ />
+ >
+ }
+ >
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ }>
+ 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.
+
+
+
+
+ 0 ? `Partner ${currentPartnerId}` : 'Own riders only')}
+ />
+
+
+
+
+
+ {/* "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. */}
+ setChosen(0)}
+ />
+ {partners.isLoading ? (
+
+ ) : (
+ partners.data.map((partner) => (
+ setChosen(partner.partnerid)}
+ />
+ ))
+ )}
+
+
+
+
+ );
+}
+
+function Choice({
+ label,
+ detail,
+ isChosen,
+ onChoose,
+}: {
+ label: string;
+ detail: string;
+ isChosen: boolean;
+ onChoose: () => void;
+}) {
+ return (
+
+ );
+}
diff --git a/src/features/nearle-admin/PartnerRidersDrawer.tsx b/src/features/nearle-admin/PartnerRidersDrawer.tsx
new file mode 100644
index 0000000..12be2c5
--- /dev/null
+++ b/src/features/nearle-admin/PartnerRidersDrawer.tsx
@@ -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(null);
+
+ const rows = riders.data ?? [];
+ const onDuty = rows.filter((rider) => rider.isonduty).length;
+
+ return (
+ <>
+
+
+ }
+ onClick={() => setEditing('new')}
+ />
+ >
+ }
+ >
+
+ }>
+ 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.
+
+
+ {riders.isLoading ? (
+
+ Reading riders…
+
+ ) : rows.length === 0 ? (
+
+
+
+
+
+ ) : (
+
+
+ {rows.map((rider) => (
+
+
+
+
+ )}
+
+
+
+ {editing ? (
+ setEditing(null)}
+ />
+ ) : null}
+ >
+ );
+}
diff --git a/src/features/nearle-admin/pages/PartnersPage.tsx b/src/features/nearle-admin/pages/PartnersPage.tsx
new file mode 100644
index 0000000..9872142
--- /dev/null
+++ b/src/features/nearle-admin/pages/PartnersPage.tsx
@@ -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 {
+ 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(null);
+ /** The partner whose riders are on screen, if any. */
+ const [ridersFor, setRidersFor] = useState(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();
+ for (const region of regions.data ?? []) {
+ map.set(region.applocationid, region.locationname ?? `Region ${region.applocationid}`);
+ }
+ return map;
+ }, [regions.data]);
+
+ const rows = useMemo(
+ () =>
+ 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[] = [
+ {
+ key: 'partnername',
+ header: 'Partner',
+ width: { type: 'proportional', value: 3 },
+ renderCell: (row) => (
+
+
+ {row.partnername}
+
+ {row.companyname ? (
+
+ {row.companyname}
+
+ ) : null}
+
+ ),
+ },
+ {
+ key: 'region',
+ header: 'Home region',
+ width: { type: 'proportional', value: 2 },
+ renderCell: (row) => (
+
+ {row.region}
+
+ ),
+ },
+ {
+ key: 'contact',
+ header: 'Contact',
+ width: { type: 'proportional', value: 2 },
+ renderCell: (row) => (
+
+ {row.contact || '—'}
+
+ ),
+ },
+ {
+ key: 'status',
+ header: 'Status',
+ align: 'end',
+ width: { type: 'pixel', value: 110 },
+ renderCell: (row) => (
+
+ ),
+ },
+ {
+ /* 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) => (
+
+ }
+ onClick={() => {
+ const found = partners.data.find((p) => p.partnerid === row.partnerid);
+ if (found) setRidersFor(found);
+ }}
+ />
+
+ ),
+ },
+ ];
+
+ return (
+
+ }
+ onClick={() => setEditing('new')}
+ />
+ }
+ />
+
+
+ {/* 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. */}
+
+
+
+
+ data={paged.rows}
+ columns={columns}
+ idKey="partnerid"
+ density="balanced"
+ hasHover
+ dividers="rows"
+ />
+
+
+
+
+
+
+ {ridersFor ? (
+ setRidersFor(null)} />
+ ) : null}
+
+ {editing ? (
+ setEditing(null)}
+ />
+ ) : null}
+
+ );
+}
+
+/* ── 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(() =>
+ 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(null);
+
+ function set(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(() => 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();
+ 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 (
+
+
+ }
+ isDisabled={!isComplete || save.isPending}
+ onClick={() => save.mutate()}
+ />
+ >
+ }
+ >
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+
+
+
+
+ {/* ── 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. */}
+
+
+ District
+
+
+ {/* 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. */}
+
+
+ {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.'}
+
+
+
+ {/* ── 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. */}
+
+
+ Delivers for
+
+ {
+ // 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'}
+ />
+ 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'
+ }
+ />
+
+ Optional. Set it and this partner’s riders become an option on that shop’s assign
+ screen, beside any riders it hired itself.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/nearle-admin/pages/StoreDetailPage.tsx b/src/features/nearle-admin/pages/StoreDetailPage.tsx
index 81c89eb..186d466 100644
--- a/src/features/nearle-admin/pages/StoreDetailPage.tsx
+++ b/src/features/nearle-admin/pages/StoreDetailPage.tsx
@@ -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 {
locationid: number;
@@ -95,6 +96,8 @@ export function StoreDetailPage() {
/** The branch whose code is on screen, if any. */
const [qrFor, setQrFor] = useState(null);
+ /** Open while the merchant's delivery partner is being changed. */
+ const [isPartnerOpen, setPartnerOpen] = useState(false);
const columns: TableColumn[] = [
{
@@ -205,6 +208,12 @@ export function StoreDetailPage() {
title={tenant?.tenantname ?? 'Tenant'}
actions={
+ }
+ onClick={() => setPartnerOpen(true)}
+ />