rider partner page
This commit is contained in:
@@ -32,6 +32,7 @@ const StoresPage = named('StoresPage', () => import('@/features/nearle-admin/pag
|
|||||||
const StoreDetailPage = named('StoreDetailPage', () => import('@/features/nearle-admin/pages/StoreDetailPage'));
|
const StoreDetailPage = named('StoreDetailPage', () => import('@/features/nearle-admin/pages/StoreDetailPage'));
|
||||||
const OnboardTenantPage = named('OnboardTenantPage', () => import('@/features/nearle-admin/pages/OnboardTenantPage'));
|
const OnboardTenantPage = named('OnboardTenantPage', () => import('@/features/nearle-admin/pages/OnboardTenantPage'));
|
||||||
const GlobalCataloguePage = named('GlobalCataloguePage', () => import('@/features/nearle-admin/pages/GlobalCataloguePage'));
|
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'));
|
const NearleUploadsPage = named('UploadsPage', () => import('@/features/nearle-admin/pages/UploadsPage'));
|
||||||
|
|
||||||
/* One Console for both workspaces — it reads its own scope from BranchScope,
|
/* 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. */}
|
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="onboard/branch" element={<Navigate to="/nearle/stores" replace />} />
|
||||||
<Route path="catalogue" element={<GlobalCataloguePage />} />
|
<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 />} />
|
<Route path="uploads" element={<NearleUploadsPage />} />
|
||||||
{/* Absorbed here rather than by the global `*`, so a wrong sub-path can
|
{/* 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
|
never bounce out to a HOME_ROUTE that points back into this
|
||||||
|
|||||||
@@ -33,38 +33,59 @@ export const RIDER_MESSAGE = {
|
|||||||
: `${count} orders have been assigned to you. Kindly accept and process the deliveries.`,
|
: `${count} orders have been assigned to you. Kindly accept and process the deliveries.`,
|
||||||
} as const;
|
} 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 {
|
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
|
* Kept because a caller with no merchant in hand still has to ask something,
|
||||||
* yet, because `app_users.tenantid` was never filled in for a rider. Riders
|
* not because it is the right scope for an assign picker.
|
||||||
* 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'.
|
|
||||||
*/
|
*/
|
||||||
applocationid: number;
|
applocationid?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const deliveriesApi = {
|
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
|
* "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
|
* `app_userpools.onduty = 1` and a `riderlogs` row stamped today with
|
||||||
* `logstatus = 0`. So this list empties overnight and refills as riders clock
|
* `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
|
* on, and an empty answer means nobody has started their shift — not that
|
||||||
* the shop has no riders. The picker has to say which.
|
* 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) =>
|
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.
|
* Hand orders to a rider.
|
||||||
@@ -165,7 +186,18 @@ export interface NewRider {
|
|||||||
password?: string;
|
password?: string;
|
||||||
/** The delivery region. Defaulted from the branch — see `RiderDrawer`. */
|
/** The delivery region. Defaulted from the branch — see `RiderDrawer`. */
|
||||||
applocationid: number;
|
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;
|
partnerid?: number;
|
||||||
|
/** The branch an OWN rider works out of. Meaningless for a partner's. */
|
||||||
|
locationid?: number;
|
||||||
shiftid: number;
|
shiftid: number;
|
||||||
identificationno?: string;
|
identificationno?: string;
|
||||||
vehiclename?: string;
|
vehiclename?: string;
|
||||||
@@ -191,6 +223,9 @@ export interface RiderRosterRow {
|
|||||||
contactno?: string;
|
contactno?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
tenantid?: number;
|
tenantid?: number;
|
||||||
|
/** The branch an own rider works out of, and its name. */
|
||||||
|
locationid?: number;
|
||||||
|
locationname?: string;
|
||||||
applocationid?: number;
|
applocationid?: number;
|
||||||
applocation?: string;
|
applocation?: string;
|
||||||
partnerid?: number;
|
partnerid?: number;
|
||||||
@@ -213,9 +248,58 @@ export interface RiderRosterRow {
|
|||||||
export interface Partner {
|
export interface Partner {
|
||||||
partnerid: number;
|
partnerid: number;
|
||||||
partnername?: string;
|
partnername?: string;
|
||||||
|
companyname?: string;
|
||||||
applocationid?: number;
|
applocationid?: number;
|
||||||
|
primarycontact?: string;
|
||||||
|
primaryemail?: string;
|
||||||
contactno?: string;
|
contactno?: string;
|
||||||
|
registrationno?: string;
|
||||||
|
address?: string;
|
||||||
|
suburb?: string;
|
||||||
city?: 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 {
|
export interface RiderShift {
|
||||||
@@ -238,10 +322,27 @@ export const ridersApi = {
|
|||||||
roster: (tenantid: number) =>
|
roster: (tenantid: number) =>
|
||||||
api.list<RiderRosterRow>(`${WEB}/partners/getriderroster`, { tenantid }),
|
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) =>
|
create: (tenantid: number, rider: NewRider) =>
|
||||||
api.post<{ userid: number }>(`${WEB}/partners/createrider`, rider, { tenantid }),
|
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 }) =>
|
update: (rider: NewRider & { userid: number }) =>
|
||||||
api.put<unknown>(`${WEB}/partners/updaterider`, rider),
|
api.put<unknown>(`${WEB}/partners/updaterider`, rider),
|
||||||
|
|
||||||
@@ -253,3 +354,38 @@ export const ridersApi = {
|
|||||||
partners: (applocationid: number) =>
|
partners: (applocationid: number) =>
|
||||||
api.list<Partner>(`${WEB}/partners/getpartners`, { applocationid }),
|
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 }),
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/** Tenant and branch endpoints — the Nearle Admin's provisioning surface. */
|
/** Tenant and branch endpoints — the Nearle Admin's provisioning surface. */
|
||||||
|
|
||||||
import { api, WEB } from './client';
|
import { api, WEB } from './client';
|
||||||
|
import type { AppLocation } from './deliveries';
|
||||||
import type { TenantInfo, TenantLocation } from './types';
|
import type { TenantInfo, TenantLocation } from './types';
|
||||||
|
|
||||||
/** Everything the tenant-onboarding form collects. */
|
/** Everything the tenant-onboarding form collects. */
|
||||||
@@ -172,6 +173,19 @@ export const tenantsApi = {
|
|||||||
updateProfile: (body: { tenantid: number } & Partial<TenantInfo>) =>
|
updateProfile: (body: { tenantid: number } & Partial<TenantInfo>) =>
|
||||||
api.put<unknown>(`${WEB}/tenants/updatetenant`, body),
|
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.
|
* 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.
|
* invisible to onboarding until someone edits the frontend.
|
||||||
*/
|
*/
|
||||||
appCategories: () => api.list<AppCategory>(`${WEB}/utils/getappcategories`),
|
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 }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const NAV: readonly NavEntry[] = [
|
|||||||
{ to: '/nearle/stores', label: 'Stores' },
|
{ to: '/nearle/stores', label: 'Stores' },
|
||||||
{ to: '/nearle/onboard/tenant', label: 'Onboard tenant' },
|
{ to: '/nearle/onboard/tenant', label: 'Onboard tenant' },
|
||||||
{ to: '/nearle/catalogue', label: 'Global catalogue' },
|
{ to: '/nearle/catalogue', label: 'Global catalogue' },
|
||||||
|
{ to: '/nearle/partners', label: 'Rider partners' },
|
||||||
{ to: '/nearle/uploads', label: 'Uploads' },
|
{ to: '/nearle/uploads', label: 'Uploads' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
209
src/features/nearle-admin/PartnerAssignDrawer.tsx
Normal file
209
src/features/nearle-admin/PartnerAssignDrawer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/features/nearle-admin/PartnerRidersDrawer.tsx
Normal file
128
src/features/nearle-admin/PartnerRidersDrawer.tsx
Normal 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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
612
src/features/nearle-admin/pages/PartnersPage.tsx
Normal file
612
src/features/nearle-admin/pages/PartnersPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import { HStack } from '@astryxdesign/core/HStack';
|
|||||||
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
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 { DataState } from '@/components/DataState';
|
||||||
import { Freshness } from '@/components/Freshness';
|
import { Freshness } from '@/components/Freshness';
|
||||||
import { KpiCard } from '@/components/KpiCard';
|
import { KpiCard } from '@/components/KpiCard';
|
||||||
@@ -19,6 +19,7 @@ import { TablePager } from '@/components/TablePager';
|
|||||||
import { usePaged } from '@/components/usePaged';
|
import { usePaged } from '@/components/usePaged';
|
||||||
import { Drawer } from '@/features/store-admin/Drawer';
|
import { Drawer } from '@/features/store-admin/Drawer';
|
||||||
import { StoreQrPanel } from '@/features/qr/StoreQrPanel';
|
import { StoreQrPanel } from '@/features/qr/StoreQrPanel';
|
||||||
|
import { PartnerAssignDrawer } from '../PartnerAssignDrawer';
|
||||||
|
|
||||||
interface BranchRow extends Record<string, unknown> {
|
interface BranchRow extends Record<string, unknown> {
|
||||||
locationid: number;
|
locationid: number;
|
||||||
@@ -95,6 +96,8 @@ export function StoreDetailPage() {
|
|||||||
|
|
||||||
/** The branch whose code is on screen, if any. */
|
/** The branch whose code is on screen, if any. */
|
||||||
const [qrFor, setQrFor] = useState<BranchRow | null>(null);
|
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>[] = [
|
const columns: TableColumn<BranchRow>[] = [
|
||||||
{
|
{
|
||||||
@@ -205,6 +208,12 @@ export function StoreDetailPage() {
|
|||||||
title={tenant?.tenantname ?? 'Tenant'}
|
title={tenant?.tenantname ?? 'Tenant'}
|
||||||
actions={
|
actions={
|
||||||
<HStack gap={1}>
|
<HStack gap={1}>
|
||||||
|
<Button
|
||||||
|
label="Delivery partner"
|
||||||
|
variant="secondary"
|
||||||
|
icon={<Truck size={14} />}
|
||||||
|
onClick={() => setPartnerOpen(true)}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
label="Stock catalogue"
|
label="Stock catalogue"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -293,6 +302,15 @@ export function StoreDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</VStack>
|
</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 ? (
|
{qrFor && tenantid ? (
|
||||||
<Drawer
|
<Drawer
|
||||||
title="Store QR code"
|
title="Store QR code"
|
||||||
|
|||||||
74
src/features/nearle-admin/tamilNaduDistricts.test.ts
Normal file
74
src/features/nearle-admin/tamilNaduDistricts.test.ts
Normal 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)));
|
||||||
|
});
|
||||||
133
src/features/nearle-admin/tamilNaduDistricts.ts
Normal file
133
src/features/nearle-admin/tamilNaduDistricts.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { Selector } from '@astryxdesign/core/Selector';
|
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 { errorMessage } from '@/api/client';
|
||||||
import {
|
import {
|
||||||
RIDER_MESSAGE,
|
RIDER_MESSAGE,
|
||||||
@@ -10,9 +10,10 @@ import {
|
|||||||
deliveriesApi,
|
deliveriesApi,
|
||||||
} from '@/api/deliveries';
|
} from '@/api/deliveries';
|
||||||
import type { OrderRow, TenantLocation } from '@/api/types';
|
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 { 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 { RoutePlanDrawer } from './RoutePlanDrawer';
|
||||||
import './pages/deliveries.css';
|
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 [outcome, setOutcome] = useState<string | null>(null);
|
||||||
const [planning, setPlanning] = useState(false);
|
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.
|
* Which fleet these orders are handed to.
|
||||||
const region =
|
*
|
||||||
orders.map((row) => row.applocationid || branchOf(row)?.applocationid || 0).find(Boolean) ?? 0;
|
* 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 fleet = riders.data ?? [];
|
||||||
const rider = fleet.find((entry) => String(entry.userid) === riderId);
|
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
|
{orders.length} selected
|
||||||
</span>
|
</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">
|
<div className="assign-bar-picker">
|
||||||
<Selector
|
<Selector
|
||||||
label="Choose a rider"
|
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
|
rather than being flattened to "Select…". "No riders on duty" is a
|
||||||
shift that has not started; the region case is a branch somebody
|
shift that has not started; the region case is a branch somebody
|
||||||
has to configure. Neither is a fault in the orders. */
|
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={
|
placeholder={
|
||||||
region === 0
|
riders.isLoading
|
||||||
? 'No delivery region on this branch'
|
? 'Loading riders…'
|
||||||
: riders.isLoading
|
: options.length > 0
|
||||||
? 'Loading riders…'
|
? 'Select a rider…'
|
||||||
: options.length > 0
|
: source === 'partner'
|
||||||
? 'Select a rider…'
|
? `No ${partnerName || 'partner'} rider has clocked on today`
|
||||||
: 'No rider has clocked on today'
|
: partnerid > 0
|
||||||
|
? 'This shop has no riders of its own'
|
||||||
|
: 'No rider has clocked on today'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Bike, Info } from 'lucide-react';
|
|||||||
import { errorMessage } from '@/api/client';
|
import { errorMessage } from '@/api/client';
|
||||||
import { ridersApi, type RiderRosterRow } from '@/api/deliveries';
|
import { ridersApi, type RiderRosterRow } from '@/api/deliveries';
|
||||||
import type { TenantLocation } from '@/api/types';
|
import type { TenantLocation } from '@/api/types';
|
||||||
import { usePartners, useRiderShifts } from '@/queries/hooks';
|
import { useRiderShifts } from '@/queries/hooks';
|
||||||
import { queryKeys } from '@/queries/keys';
|
import { queryKeys } from '@/queries/keys';
|
||||||
import { Drawer } from './Drawer';
|
import { Drawer } from './Drawer';
|
||||||
import { DrawerButton } from './drawerKit';
|
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
|
* 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.
|
* 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 {
|
export interface RiderDrawerProps {
|
||||||
row: RiderRosterRow | null;
|
row: RiderRosterRow | null;
|
||||||
tenantid: number;
|
owner: RiderOwner;
|
||||||
/** The branch whose delivery region a new rider inherits. */
|
/** The branch whose delivery region a new rider inherits. Tenant case only. */
|
||||||
branch: TenantLocation | undefined;
|
branch?: TenantLocation | undefined;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps) {
|
export function RiderDrawer({ row, owner, branch, onClose }: RiderDrawerProps) {
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
const isNew = row === null;
|
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
|
* branch they are standing in already determines it. Editing keeps whatever
|
||||||
* the rider has; a new rider takes the branch's.
|
* 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 shifts = useRiderShifts(applocationid || undefined);
|
||||||
const partners = usePartners(applocationid || undefined);
|
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
firstname: row?.firstname ?? '',
|
firstname: row?.firstname ?? '',
|
||||||
@@ -66,7 +83,12 @@ export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps
|
|||||||
licenseno: row?.licenseno ?? '',
|
licenseno: row?.licenseno ?? '',
|
||||||
registrationno: row?.registrationno ?? '',
|
registrationno: row?.registrationno ?? '',
|
||||||
shiftid: row?.shiftid ? String(row.shiftid) : '',
|
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',
|
status: row?.status ?? 'Active',
|
||||||
});
|
});
|
||||||
const [problem, setProblem] = useState<string | null>(null);
|
const [problem, setProblem] = useState<string | null>(null);
|
||||||
@@ -82,7 +104,12 @@ export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps
|
|||||||
contactno: form.contactno.trim(),
|
contactno: form.contactno.trim(),
|
||||||
email: form.email.trim(),
|
email: form.email.trim(),
|
||||||
applocationid,
|
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,
|
shiftid: Number(form.shiftid) || 0,
|
||||||
identificationno: form.identificationno.trim(),
|
identificationno: form.identificationno.trim(),
|
||||||
vehiclename: form.vehiclename.trim(),
|
vehiclename: form.vehiclename.trim(),
|
||||||
@@ -91,9 +118,10 @@ export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps
|
|||||||
registrationno: form.registrationno.trim(),
|
registrationno: form.registrationno.trim(),
|
||||||
status: form.status,
|
status: form.status,
|
||||||
};
|
};
|
||||||
return isNew
|
if (!isNew) return ridersApi.update({ ...payload, userid: row.userid });
|
||||||
? ridersApi.create(tenantid, payload)
|
return owner.kind === 'tenant'
|
||||||
: ridersApi.update({ ...payload, userid: row.userid });
|
? ridersApi.create(owner.tenantid, payload)
|
||||||
|
: ridersApi.createForPartner(owner.partnerid, payload);
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await client.invalidateQueries({ queryKey: queryKeys.insights.all });
|
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'
|
: 'No shifts set up for this region'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Selector
|
{/* A merchant's rider works out of one of that merchant's branches.
|
||||||
label="Delivery partner"
|
A partner's does not — a partner supplies several merchants and is
|
||||||
size="sm"
|
tied to none of their outlets — so the question is only asked
|
||||||
value={form.partnerid}
|
where it has an answer. */}
|
||||||
onChange={(value) => set('partnerid')(String(value))}
|
{owner.kind === 'tenant' ? (
|
||||||
options={(partners.data ?? []).map((partner) => ({
|
<Selector
|
||||||
value: String(partner.partnerid),
|
label="Branch"
|
||||||
label: partner.partnername || `Partner ${partner.partnerid}`,
|
size="sm"
|
||||||
}))}
|
value={form.locationid}
|
||||||
placeholder={
|
onChange={(value) => set('locationid')(String(value))}
|
||||||
partners.isLoading ? 'Loading partners…' : 'None — rides for the shop directly'
|
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>
|
||||||
|
|
||||||
<VStack gap={1.5}>
|
<VStack gap={1.5}>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
riderVehicle,
|
riderVehicle,
|
||||||
stampNow,
|
stampNow,
|
||||||
waitingMs,
|
waitingMs,
|
||||||
|
riderScope,
|
||||||
} from './assignDelivery';
|
} 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(rider), 'Bike · TN 38CV 1535');
|
||||||
assert.equal(riderVehicle({ userid: 1 }), '');
|
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 });
|
||||||
|
});
|
||||||
|
|||||||
@@ -349,3 +349,20 @@ export function riderName(rider: RiderInfo): string {
|
|||||||
export function riderVehicle(rider: RiderInfo): string {
|
export function riderVehicle(rider: RiderInfo): string {
|
||||||
return [rider.vehiclename, rider.vehicleno].map((part) => (part ?? '').trim()).filter(Boolean).join(' · ');
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ export function UsersPage() {
|
|||||||
{editingRider ? (
|
{editingRider ? (
|
||||||
<RiderDrawer
|
<RiderDrawer
|
||||||
row={editingRider === 'new' ? null : editingRider}
|
row={editingRider === 'new' ? null : editingRider}
|
||||||
tenantid={tenantid}
|
owner={{ kind: 'tenant', tenantid, branches: scoped }}
|
||||||
/* The delivery region is inherited from the branch, so the drawer
|
/* The delivery region is inherited from the branch, so the drawer
|
||||||
needs one. A selected branch wins; otherwise the first in scope,
|
needs one. A selected branch wins; otherwise the first in scope,
|
||||||
since every branch of a tenant sits in the same region in practice
|
since every branch of a tenant sits in the same region in practice
|
||||||
|
|||||||
136
src/index.css
136
src/index.css
@@ -2017,3 +2017,139 @@ main {
|
|||||||
font: 500 12px/1.4 var(--font-sans);
|
font: 500 12px/1.4 var(--font-sans);
|
||||||
color: var(--color-ink-2, var(--color-ink-1));
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { productsApi } from '@/api/products';
|
|||||||
import { posUsersApi, staffApi } from '@/api/people';
|
import { posUsersApi, staffApi } from '@/api/people';
|
||||||
import { stockApi, type StockRequestQuery } from '@/api/stock';
|
import { stockApi, type StockRequestQuery } from '@/api/stock';
|
||||||
import { tenantsApi, utilsApi, type TenantListQuery } from '@/api/tenants';
|
import { tenantsApi, utilsApi, type TenantListQuery } from '@/api/tenants';
|
||||||
|
import { partnersApi } from '@/api/deliveries';
|
||||||
import { customersApi, type CustomerQuery } from '@/api/customers';
|
import { customersApi, type CustomerQuery } from '@/api/customers';
|
||||||
import { uploadsApi } from '@/api/uploads';
|
import { uploadsApi } from '@/api/uploads';
|
||||||
import { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories';
|
import { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories';
|
||||||
@@ -138,6 +139,93 @@ export function useImportedRefs(tenantid: number | undefined) {
|
|||||||
|
|
||||||
/* ── Products ────────────────────────────────────────────────────────────── */
|
/* ── 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.
|
* 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
|
* Scoped by `applocationid`. Passing a tenant instead returns an empty list
|
||||||
* with a 200 — see `deliveriesApi.riders`.
|
* 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({
|
return useQuery({
|
||||||
queryKey: queryKeys.insights.riders(applocationid ?? 0),
|
// The scope is part of the key, or one fleet's riders would be served to
|
||||||
queryFn: () => deliveriesApi.riders({ applocationid: applocationid as number }),
|
// the other after a toggle.
|
||||||
enabled: Boolean(applocationid),
|
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,
|
...live,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const queryKeys = {
|
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: {
|
uploads: {
|
||||||
all: ['uploads'] as const,
|
all: ['uploads'] as const,
|
||||||
list: (tenantid: number, locationid: number) =>
|
list: (tenantid: number, locationid: number) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user