diff --git a/src/App.tsx b/src/App.tsx index 70a68a5..49e4510 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,6 +32,7 @@ const StoresPage = named('StoresPage', () => import('@/features/nearle-admin/pag const StoreDetailPage = named('StoreDetailPage', () => import('@/features/nearle-admin/pages/StoreDetailPage')); const OnboardTenantPage = named('OnboardTenantPage', () => import('@/features/nearle-admin/pages/OnboardTenantPage')); const GlobalCataloguePage = named('GlobalCataloguePage', () => import('@/features/nearle-admin/pages/GlobalCataloguePage')); +const PartnersPage = named('PartnersPage', () => import('@/features/nearle-admin/pages/PartnersPage')); const NearleUploadsPage = named('UploadsPage', () => import('@/features/nearle-admin/pages/UploadsPage')); /* One Console for both workspaces — it reads its own scope from BranchScope, @@ -104,6 +105,9 @@ export function App() { bookmark or a stale link lands on the directory instead of a 404. */} } /> } /> + {/* Delivery partners — the companies that supply riders. Platform-side + only: a merchant is assigned one, never allowed to create one. */} + } /> } /> {/* Absorbed here rather than by the global `*`, so a wrong sub-path can never bounce out to a HOME_ROUTE that points back into this diff --git a/src/api/deliveries.ts b/src/api/deliveries.ts index 741a45a..701fcba 100644 --- a/src/api/deliveries.ts +++ b/src/api/deliveries.ts @@ -33,38 +33,59 @@ export const RIDER_MESSAGE = { : `${count} orders have been assigned to you. Kindly accept and process the deliveries.`, } as const; +/** + * Which fleet to ask for. One of these, in this order of preference. + * + * `getriders` scopes by applocation, partner or tenant. It used to be called + * with the region ALONE, which asks "who is on duty in this city" — so a + * merchant's assign picker offered every on-duty rider in Coimbatore, including + * other merchants' own riders and every other partner's. + * + * Measured 2026-09-09: 118 riders across three regions, and 117 of them belong + * to a delivery partner — 75 to partner 44 alone. Exactly one rider on the + * platform is a merchant's own. So the region scope was not a harmless default; + * it was the only thing holding the picker together while the two real scopes + * went unused. + */ export interface RiderQuery { + /** The merchant's own riders — hired by them, working their branches. */ + tenantid?: number; + /** A delivery partner's riders. One partner supplies many merchants. */ + partnerid?: number; /** - * The delivery region, and for now the only scope that finds anybody. + * The delivery region — a CITY, and the fallback for neither of the above. * - * `tenantid` is accepted too and the query is sound — it just matches nothing - * yet, because `app_users.tenantid` was never filled in for a rider. Riders - * hired through this console DO carry one, so tenant scope starts working the - * moment a merchant has their own. - * - * It is not the scope used here, and that is deliberate: production has 84 - * riders on applocation 1 and none of them has a tenant, so switching today - * would empty the picker for everybody. Revisit once merchants have hired - * their own — preferring tenant and falling back to region. - * - * Note what region means: a CITY. Until then an operator is offered every - * on-duty rider in Coimbatore, including other merchants'. + * Kept because a caller with no merchant in hand still has to ask something, + * not because it is the right scope for an assign picker. */ - applocationid: number; + applocationid?: number; } export const deliveriesApi = { /** - * Riders on duty right now. + * Riders on duty right now, for one OWNER. * * "On duty" is the backend's word, not a filter added here: the query wants * `app_userpools.onduty = 1` and a `riderlogs` row stamped today with * `logstatus = 0`. So this list empties overnight and refills as riders clock * on, and an empty answer means nobody has started their shift — not that * the shop has no riders. The picker has to say which. + * + * ── Why this is scoped and used to not be ───────────────────────────────── + * + * It sent `applocationid` alone, which asks "who is on duty in this city" — + * so a merchant's assign picker listed every on-duty rider in Coimbatore, + * including other merchants' own riders and every partner's. Nobody hit it + * because there is one rider on the platform. `getriders` scopes by + * applocation, partner or tenant, in that order, so the caller names which + * fleet it means and the region is only a fallback for neither. */ riders: (query: RiderQuery) => - api.list(`${WEB}/partners/getriders`, { applocationid: query.applocationid }), + api.list(`${WEB}/partners/getriders`, { + ...(query.tenantid ? { tenantid: query.tenantid } : {}), + ...(query.partnerid ? { partnerid: query.partnerid } : {}), + ...(query.tenantid || query.partnerid ? {} : { applocationid: query.applocationid }), + }), /** * Hand orders to a rider. @@ -165,7 +186,18 @@ export interface NewRider { password?: string; /** The delivery region. Defaulted from the branch — see `RiderDrawer`. */ applocationid: number; + /** + * Whose rider this is — one of these, never both. + * + * `tenantid` is a merchant's own rider; `partnerid` is a delivery partner's, + * who serves several merchants and sits under no single one. The server + * refuses neither and refuses both, so the two can never be confused + * downstream in a directory or an assign picker. + */ + tenantid?: number; partnerid?: number; + /** The branch an OWN rider works out of. Meaningless for a partner's. */ + locationid?: number; shiftid: number; identificationno?: string; vehiclename?: string; @@ -191,6 +223,9 @@ export interface RiderRosterRow { contactno?: string; email?: string; tenantid?: number; + /** The branch an own rider works out of, and its name. */ + locationid?: number; + locationname?: string; applocationid?: number; applocation?: string; partnerid?: number; @@ -213,9 +248,58 @@ export interface RiderRosterRow { export interface Partner { partnerid: number; partnername?: string; + companyname?: string; applocationid?: number; + primarycontact?: string; + primaryemail?: string; contactno?: string; + registrationno?: string; + address?: string; + suburb?: string; city?: string; + state?: string; + status?: string; +} + +/** One region a partner covers — a row of `partnerlocations`. */ +export interface PartnerLocation { + partnerlocationid: number; + partnerid: number; + applocationid: number; + applocation?: string; +} + +/** A delivery region. `applocationid=0` asks for all of them. */ +export interface AppLocation { + applocationid: number; + locationname?: string; +} + +/** Everything the console collects to onboard a delivery partner. */ +export interface NewPartner { + partnerid?: number; + partnername: string; + companyname?: string; + registrationno?: string; + primarycontact: string; + primaryemail?: string; + contactno?: string; + address?: string; + suburb?: string; + city?: string; + state?: string; + postcode?: number; + status?: string; + /** The district they work — one, never a set. */ + applocationid: number; + /** + * The district by NAME, for one Nearle has not opened yet. + * + * Sending it opens the district: the server writes the `app_location` and + * `app_locationconfig` rows every rider query joins through. Ignored when + * `applocationid` is set, which is the ordinary case. + */ + district?: string; } export interface RiderShift { @@ -238,10 +322,27 @@ export const ridersApi = { roster: (tenantid: number) => api.list(`${WEB}/partners/getriderroster`, { tenantid }), - /** Hire one. `tenantid` travels as a param — the backend ignores it in the body. */ + /** + * Hire one for a MERCHANT. `tenantid` travels as a param — the backend takes + * the scope from there rather than trusting the body, so a store admin cannot + * put a rider on another merchant's books by editing a payload. + */ create: (tenantid: number, rider: NewRider) => api.post<{ userid: number }>(`${WEB}/partners/createrider`, rider, { tenantid }), + /** + * Hire one for a delivery PARTNER. + * + * Same endpoint, same rider — what differs is who they ride for. A partner + * has no console of its own, so their riders are added by the platform. + */ + createForPartner: (partnerid: number, rider: NewRider) => + api.post<{ userid: number }>(`${WEB}/partners/createrider`, rider, { partnerid }), + + /** A partner's riders, for the platform's directory. */ + partnerRoster: (partnerid: number) => + api.list(`${WEB}/partners/getriderroster`, { partnerid }), + update: (rider: NewRider & { userid: number }) => api.put(`${WEB}/partners/updaterider`, rider), @@ -253,3 +354,38 @@ export const ridersApi = { partners: (applocationid: number) => api.list(`${WEB}/partners/getpartners`, { applocationid }), }; + +/** + * Delivery partners — the companies that supply riders. + * + * A partner is onboarded by the platform and then ASSIGNED to merchants; a + * merchant never creates one. That split is why `assign` lives on the tenant + * API and not here, and why `partnerid` is kept out of the merchant-editable + * profile allowlist on the server. + * + * One partner routinely serves many merchants: partner 44 supplies 48 of them + * and partner 60 supplies 63, measured on 2026-09-09. + */ +export const partnersApi = { + /** Every partner in a region. `applocationid` 0 is not accepted here. */ + list: (applocationid: number) => + api.list(`${WEB}/partners/getpartners`, { applocationid }), + + /** One partner, by id. */ + byId: (partnerid: number) => + api.list(`${WEB}/partners/getpartners`, { partnerid }), + + create: (partner: NewPartner) => + api.post<{ partnerid: number }>(`${WEB}/partners/createpartner`, partner), + + /** + * Edit a partner. Regions are REPLACED when sent and left alone when not, so + * an edit that changes only a phone number cannot empty the list. + */ + update: (partner: NewPartner & { partnerid: number }) => + api.put(`${WEB}/partners/updatepartner`, partner), + + /** The regions one partner covers. */ + locations: (partnerid: number) => + api.list(`${WEB}/partners/getpartnerlocations`, { partnerid }), +}; diff --git a/src/api/tenants.ts b/src/api/tenants.ts index a0c8352..d894c3c 100644 --- a/src/api/tenants.ts +++ b/src/api/tenants.ts @@ -1,6 +1,7 @@ /** Tenant and branch endpoints — the Nearle Admin's provisioning surface. */ import { api, WEB } from './client'; +import type { AppLocation } from './deliveries'; import type { TenantInfo, TenantLocation } from './types'; /** Everything the tenant-onboarding form collects. */ @@ -172,6 +173,19 @@ export const tenantsApi = { updateProfile: (body: { tenantid: number } & Partial) => api.put(`${WEB}/tenants/updatetenant`, body), + /** + * Which delivery partner supplies this merchant's riders. + * + * Its own endpoint, not a field on `updateProfile`: `partnerid` is kept out + * of the merchant-editable allowlist on purpose, because a merchant who could + * set it would move themselves under another partner's riders and billing. + * + * `partnerid: 0` is a real instruction — it means "this merchant uses their + * own riders" — and the server reads it as sent rather than as absent. + */ + assignPartner: (tenantid: number, partnerid: number) => + api.put(`${WEB}/tenants/assignpartner`, { tenantid, partnerid }), + /** * One business, by id — how a store login reads its own record. * @@ -267,4 +281,14 @@ export const utilsApi = { * invisible to onboarding until someone edits the frontend. */ appCategories: () => api.list(`${WEB}/utils/getappcategories`), + + /** + * The delivery regions — Coimbatore, Madurai, Nagercoil today. + * + * `applocationid` is REQUIRED by the handler and 0 is how you ask for all of + * them; omitting it answers 400 "Invalid applocationid", which reads as a + * broken request rather than a missing default. + */ + appLocations: (applocationid = 0) => + api.list(`${WEB}/utils/getapplocations`, { applocationid }), }; diff --git a/src/features/nearle-admin/NearleAdminShell.tsx b/src/features/nearle-admin/NearleAdminShell.tsx index 7440422..d82bc6c 100644 --- a/src/features/nearle-admin/NearleAdminShell.tsx +++ b/src/features/nearle-admin/NearleAdminShell.tsx @@ -12,6 +12,7 @@ const NAV: readonly NavEntry[] = [ { to: '/nearle/stores', label: 'Stores' }, { to: '/nearle/onboard/tenant', label: 'Onboard tenant' }, { to: '/nearle/catalogue', label: 'Global catalogue' }, + { to: '/nearle/partners', label: 'Rider partners' }, { to: '/nearle/uploads', label: 'Uploads' }, ]; diff --git a/src/features/nearle-admin/PartnerAssignDrawer.tsx b/src/features/nearle-admin/PartnerAssignDrawer.tsx new file mode 100644 index 0000000..a4079e1 --- /dev/null +++ b/src/features/nearle-admin/PartnerAssignDrawer.tsx @@ -0,0 +1,209 @@ +import { useMemo, useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Text } from '@astryxdesign/core/Text'; +import { VStack } from '@astryxdesign/core/VStack'; +import { Bike, Check, Truck } from 'lucide-react'; +import { errorMessage } from '@/api/client'; +import { tenantsApi } from '@/api/tenants'; +import { useAllPartners, useAppRegions } from '@/queries/hooks'; +import { queryKeys } from '@/queries/keys'; +import { Drawer } from '@/features/store-admin/Drawer'; +import { DrawerButton, DrawerCard, Note, Row, Section } from '@/features/store-admin/drawerKit'; + +/** + * Which delivery partner supplies a merchant's riders. + * + * ── Why this is the platform's decision ───────────────────────────────────── + * + * `partnerid` is kept out of the merchant-editable allowlist on the server, so + * this cannot be done from the shop's own profile — a merchant who could set it + * would move themselves under another partner's riders and billing. It is + * changed here, by whoever is looking at that merchant's record. + * + * ── Why "own riders" is an option and not an absence ──────────────────────── + * + * Sending `partnerid: 0` is a real instruction: it means the shop delivers with + * riders it hired itself. The server reads a zero as sent rather than as a + * missing field, which is exactly why the endpoint is separate — everywhere + * else in the tenant API a zero means "not supplied", and there it would + * silently unassign somebody. + * + * One partner per merchant, which is what `tenants.partnerid` allows and what + * the assign picker later branches on. A partner serving several merchants is + * the ordinary case in the other direction: 44 supplies 48 shops. + */ +export function PartnerAssignDrawer({ + tenantid, + tenantname, + currentPartnerId, + onClose, +}: { + tenantid: number; + tenantname: string; + currentPartnerId: number; + onClose: () => void; +}) { + const client = useQueryClient(); + const partners = useAllPartners(); + const regions = useAppRegions(); + const [chosen, setChosen] = useState(currentPartnerId); + const [error, setError] = useState(null); + + const regionName = useMemo(() => { + const map = new Map(); + for (const region of regions.data ?? []) { + map.set(region.applocationid, region.locationname ?? `Region ${region.applocationid}`); + } + return map; + }, [regions.data]); + + const save = useMutation({ + mutationFn: () => tenantsApi.assignPartner(tenantid, chosen), + onSuccess: async () => { + await client.invalidateQueries({ queryKey: queryKeys.tenants.all }); + onClose(); + }, + onError: (cause) => setError(errorMessage(cause)), + }); + + const current = partners.data.find((p) => p.partnerid === currentPartnerId); + + return ( + + + } + isDisabled={save.isPending || chosen === currentPartnerId} + onClick={() => save.mutate()} + /> + + } + > + + {error ? ( + + {error} + + ) : null} + + }> + A partner supplies riders to this shop. With one set, the assign screen offers the + partner’s riders alongside any the shop hired itself; without one, only its own. + + +
+ + 0 ? `Partner ${currentPartnerId}` : 'Own riders only')} + /> + +
+ +
+ + {/* "Own riders" first and always present. It is not the empty state + — a shop that hires its own riders is a real arrangement, and + the option has to be as reachable as any partner. */} + setChosen(0)} + /> + {partners.isLoading ? ( + + ) : ( + partners.data.map((partner) => ( + setChosen(partner.partnerid)} + /> + )) + )} + +
+
+
+ ); +} + +function Choice({ + label, + detail, + isChosen, + onChoose, +}: { + label: string; + detail: string; + isChosen: boolean; + onChoose: () => void; +}) { + return ( + + ); +} diff --git a/src/features/nearle-admin/PartnerRidersDrawer.tsx b/src/features/nearle-admin/PartnerRidersDrawer.tsx new file mode 100644 index 0000000..12be2c5 --- /dev/null +++ b/src/features/nearle-admin/PartnerRidersDrawer.tsx @@ -0,0 +1,128 @@ +import { useState } from 'react'; +import { Button } from '@astryxdesign/core/Button'; +import { Text } from '@astryxdesign/core/Text'; +import { VStack } from '@astryxdesign/core/VStack'; +import { Bike, Plus } from 'lucide-react'; +import type { Partner, RiderRosterRow } from '@/api/deliveries'; +import { usePartnerRiders } from '@/queries/hooks'; +import { Drawer } from '@/features/store-admin/Drawer'; +import { Badge, DrawerButton, DrawerCard, Note, Row, Section } from '@/features/store-admin/drawerKit'; +import { RiderDrawer } from '@/features/store-admin/RiderDrawer'; + +/** + * A delivery partner's riders, kept by the platform. + * + * ── Why the platform keeps them ───────────────────────────────────────────── + * + * A partner has no console of its own. Their riders serve whichever merchants + * the partner supplies — one partner covers 48 shops today, another 63 — so + * they sit under no single merchant and no merchant's console can manage them. + * That leaves here. + * + * ── Duty is a state, never a filter ───────────────────────────────────────── + * + * This reads the ROSTER, not `getriders`. The second wants a clock-in stamped + * today, so a rider hired five minutes ago is absent from it — which is exactly + * what a failed save looks like. Everybody is listed, and whether they are + * working right now is shown beside them. + */ +export function PartnerRidersDrawer({ + partner, + onClose, +}: { + partner: Partner; + onClose: () => void; +}) { + const riders = usePartnerRiders(partner.partnerid); + const [editing, setEditing] = useState(null); + + const rows = riders.data ?? []; + const onDuty = rows.filter((rider) => rider.isonduty).length; + + return ( + <> + + + } + onClick={() => setEditing('new')} + /> + + } + > + + }> + These riders deliver for every merchant this partner supplies. A rider hired here does + not appear in the on-duty fleet until they open the rider app and start a shift — that + is correct, and it looks exactly like a failed save. + + + {riders.isLoading ? ( + + Reading riders… + + ) : rows.length === 0 ? ( +
+ + + +
+ ) : ( +
+ + {rows.map((rider) => ( + + +
+ )} +
+
+ + {editing ? ( + setEditing(null)} + /> + ) : null} + + ); +} diff --git a/src/features/nearle-admin/pages/PartnersPage.tsx b/src/features/nearle-admin/pages/PartnersPage.tsx new file mode 100644 index 0000000..9872142 --- /dev/null +++ b/src/features/nearle-admin/pages/PartnersPage.tsx @@ -0,0 +1,612 @@ +/** + * Rider partners — the companies that supply riders. + * + * ── Why this page did not exist ───────────────────────────────────────────── + * + * `getpartners` has always been readable and nothing on the platform could + * create a partner, so the five that exist were inserted by hand — two are + * still called "Test". Meanwhile 125 of 200 merchants already carry a + * `partnerid`, and one partner supplies 48 shops while another supplies 63. The + * relationship the whole delivery side rests on was real, live and unmanaged. + * + * ── What onboarding a partner records ─────────────────────────────────────── + * + * Three things, and the last two are why the assign screen works at all: + * + * the district `partnerinfo.applocationid`, and `partnerlocations` beside + * it. Every rider query joins through that id, so it has to be + * a district Nearle actually services — see + * `tamilNaduDistricts.ts` for why all 38 are shown anyway. + * the merchant `tenants.partnerid`. This is what the assign screen reads to + * decide whether to offer a partner tab at all. + * the branch `tenantlocations.partnerid`. Which outlet they cover. + * + * A partner can also be attached to a merchant afterwards from that merchant's + * own page — see `StoreDetailPage` — which is the ordinary case of a shop + * changing partner without anybody re-onboarding the company. + */ + +import { useMemo, useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Badge } from '@astryxdesign/core/Badge'; +import { Button } from '@astryxdesign/core/Button'; +import { Card } from '@astryxdesign/core/Card'; +import { HStack } from '@astryxdesign/core/HStack'; +import { Table, type TableColumn } from '@astryxdesign/core/Table'; +import { Text } from '@astryxdesign/core/Text'; +import { TextInput } from '@astryxdesign/core/TextInput'; +import { VStack } from '@astryxdesign/core/VStack'; +import { Bike, Plus, Truck } from 'lucide-react'; +import { errorMessage } from '@/api/client'; +import { partnersApi, type NewPartner, type Partner } from '@/api/deliveries'; +import { tenantsApi } from '@/api/tenants'; +import type { TenantInfo } from '@/api/types'; +import { Selector } from '@astryxdesign/core/Selector'; +import { districtOptions, isRunning, matchDistrict } from '../tamilNaduDistricts'; +import { DataState } from '@/components/DataState'; +import { PageHeader } from '@/components/PageHeader'; +import { TablePager } from '@/components/TablePager'; +import { usePaged } from '@/components/usePaged'; +import { + useAllPartners, + useAppRegions, + usePartnerRiderCounts, + useTenantLocations, + useTenants, +} from '@/queries/hooks'; +import { queryKeys } from '@/queries/keys'; +import { Drawer } from '@/features/store-admin/Drawer'; +import { PartnerRidersDrawer } from '../PartnerRidersDrawer'; +import { DrawerButton } from '@/features/store-admin/drawerKit'; + +interface PartnerRow extends Record { + partnerid: number; + partnername: string; + companyname: string; + region: string; + contact: string; + status: string; + /** How many riders they have. `null` while the count is still being read. */ + riders: number | null; +} + +export function PartnersPage() { + const partners = useAllPartners(); + const regions = useAppRegions(); + const [editing, setEditing] = useState(null); + /** The partner whose riders are on screen, if any. */ + const [ridersFor, setRidersFor] = useState(null); + + /* The fleet size per partner, read alongside the directory. Without it the + Riders button is a door with nothing written on it. */ + const riderCounts = usePartnerRiderCounts(partners.data.map((entry) => entry.partnerid)); + + const regionName = useMemo(() => { + const map = new Map(); + for (const region of regions.data ?? []) { + map.set(region.applocationid, region.locationname ?? `Region ${region.applocationid}`); + } + return map; + }, [regions.data]); + + const rows = useMemo( + () => + partners.data.map((partner) => ({ + partnerid: partner.partnerid, + partnername: partner.partnername ?? `Partner ${partner.partnerid}`, + companyname: partner.companyname ?? '', + region: regionName.get(partner.applocationid ?? 0) ?? '—', + contact: partner.primarycontact || partner.contactno || '', + status: partner.status || 'Active', + riders: riderCounts.get(partner.partnerid) ?? null, + })), + [partners.data, regionName, riderCounts], + ); + + const paged = usePaged(rows); + + const columns: TableColumn[] = [ + { + key: 'partnername', + header: 'Partner', + width: { type: 'proportional', value: 3 }, + renderCell: (row) => ( + + + {row.partnername} + + {row.companyname ? ( + + {row.companyname} + + ) : null} + + ), + }, + { + key: 'region', + header: 'Home region', + width: { type: 'proportional', value: 2 }, + renderCell: (row) => ( + + {row.region} + + ), + }, + { + key: 'contact', + header: 'Contact', + width: { type: 'proportional', value: 2 }, + renderCell: (row) => ( + + {row.contact || '—'} + + ), + }, + { + key: 'status', + header: 'Status', + align: 'end', + width: { type: 'pixel', value: 110 }, + renderCell: (row) => ( + + ), + }, + { + /* Both actions in ONE column with a header, rather than two unlabelled + ones. The riders button carries the fleet size, because "Riders" alone + asks you to open a drawer to learn whether there are any — and the + answer is the reason you would open it. */ + key: 'actions', + header: 'Fleet', + align: 'end', + width: { type: 'pixel', value: 184 }, + renderCell: (row) => ( + + + ); + })} + {shown.length === 0 ? ( + + No district matches “{districtSearch}”. + + ) : null} + + + {chosenDistrict + ? isRunning(chosenDistrict) + ? `Riders are listed against ${chosenDistrict.name}.` + : `${chosenDistrict.name} will be opened when this partner is saved.` + : 'One district per partner. Choosing one Nearle does not run yet opens it.'} + + + + {/* ── Who they deliver for ────────────────────────────────────────── + The merchant, then the branch. Both links are written: the merchant + one is what the assign screen reads to decide whether to offer a + partner tab at all, and the branch one records which outlet. */} + + + Delivers for + + { + // A new merchant clears the branch with it — keeping it would + // leave another shop's outlet id attached to this partner. + setForm((prev) => ({ ...prev, tenantid: Number(value) || 0, locationid: 0 })); + }} + options={merchantOptions} + placeholder={merchants.isLoading ? 'Loading merchants…' : 'Choose a merchant'} + /> + set('locationid')(Number(value) || 0)} + options={branchOptions} + isDisabled={!form.tenantid} + placeholder={ + !form.tenantid + ? 'Choose a merchant first' + : branches.isLoading + ? 'Loading branches…' + : 'Choose the branch they cover' + } + /> + + Optional. Set it and this partner’s riders become an option on that shop’s assign + screen, beside any riders it hired itself. + + + + + + + + + + + + + + + ); +} diff --git a/src/features/nearle-admin/pages/StoreDetailPage.tsx b/src/features/nearle-admin/pages/StoreDetailPage.tsx index 81c89eb..186d466 100644 --- a/src/features/nearle-admin/pages/StoreDetailPage.tsx +++ b/src/features/nearle-admin/pages/StoreDetailPage.tsx @@ -7,7 +7,7 @@ import { HStack } from '@astryxdesign/core/HStack'; import { Table, type TableColumn } from '@astryxdesign/core/Table'; import { Text } from '@astryxdesign/core/Text'; import { VStack } from '@astryxdesign/core/VStack'; -import { IndianRupee, QrCode, ShoppingCart, Store, TriangleAlert } from 'lucide-react'; +import { IndianRupee, QrCode, ShoppingCart, Store, TriangleAlert, Truck } from 'lucide-react'; import { DataState } from '@/components/DataState'; import { Freshness } from '@/components/Freshness'; import { KpiCard } from '@/components/KpiCard'; @@ -19,6 +19,7 @@ import { TablePager } from '@/components/TablePager'; import { usePaged } from '@/components/usePaged'; import { Drawer } from '@/features/store-admin/Drawer'; import { StoreQrPanel } from '@/features/qr/StoreQrPanel'; +import { PartnerAssignDrawer } from '../PartnerAssignDrawer'; interface BranchRow extends Record { locationid: number; @@ -95,6 +96,8 @@ export function StoreDetailPage() { /** The branch whose code is on screen, if any. */ const [qrFor, setQrFor] = useState(null); + /** Open while the merchant's delivery partner is being changed. */ + const [isPartnerOpen, setPartnerOpen] = useState(false); const columns: TableColumn[] = [ { @@ -205,6 +208,12 @@ export function StoreDetailPage() { title={tenant?.tenantname ?? 'Tenant'} actions={ + + + + ) : null} +
0 - ? 'Select a rider…' - : 'No rider has clocked on today' + riders.isLoading + ? 'Loading riders…' + : options.length > 0 + ? 'Select a rider…' + : source === 'partner' + ? `No ${partnerName || 'partner'} rider has clocked on today` + : partnerid > 0 + ? 'This shop has no riders of its own' + : 'No rider has clocked on today' } />
diff --git a/src/features/store-admin/RiderDrawer.tsx b/src/features/store-admin/RiderDrawer.tsx index 7515712..0f0ffda 100644 --- a/src/features/store-admin/RiderDrawer.tsx +++ b/src/features/store-admin/RiderDrawer.tsx @@ -9,7 +9,7 @@ import { Bike, Info } from 'lucide-react'; import { errorMessage } from '@/api/client'; import { ridersApi, type RiderRosterRow } from '@/api/deliveries'; import type { TenantLocation } from '@/api/types'; -import { usePartners, useRiderShifts } from '@/queries/hooks'; +import { useRiderShifts } from '@/queries/hooks'; import { queryKeys } from '@/queries/keys'; import { Drawer } from './Drawer'; import { DrawerButton } from './drawerKit'; @@ -31,15 +31,30 @@ import './pages/deliveries.css'; * is correct behaviour and it looks exactly like a failed save, so the drawer * says it before you press the button rather than leaving you to wonder. */ +/** + * Whose rider this is. + * + * A rider belongs to a merchant OR to a delivery partner — the server refuses + * neither and refuses both, because a rider carrying both ids appears in two + * directories and two assign pickers with nothing saying which owns them. + * + * The two cases genuinely differ in the form, not just in the payload: a + * merchant's rider works out of one of that merchant's branches, and a + * partner's works a region and serves whichever merchants the partner supplies. + */ +export type RiderOwner = + | { kind: 'tenant'; tenantid: number; branches: readonly TenantLocation[] } + | { kind: 'partner'; partnerid: number; partnername: string; applocationid: number }; + export interface RiderDrawerProps { row: RiderRosterRow | null; - tenantid: number; - /** The branch whose delivery region a new rider inherits. */ - branch: TenantLocation | undefined; + owner: RiderOwner; + /** The branch whose delivery region a new rider inherits. Tenant case only. */ + branch?: TenantLocation | undefined; onClose: () => void; } -export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps) { +export function RiderDrawer({ row, owner, branch, onClose }: RiderDrawerProps) { const client = useQueryClient(); const isNew = row === null; @@ -50,10 +65,12 @@ export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps * branch they are standing in already determines it. Editing keeps whatever * the rider has; a new rider takes the branch's. */ - const applocationid = row?.applocationid ?? branch?.applocationid ?? 0; + const applocationid = + row?.applocationid ?? + (owner.kind === 'partner' ? owner.applocationid : branch?.applocationid) ?? + 0; const shifts = useRiderShifts(applocationid || undefined); - const partners = usePartners(applocationid || undefined); const [form, setForm] = useState({ firstname: row?.firstname ?? '', @@ -66,7 +83,12 @@ export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps licenseno: row?.licenseno ?? '', registrationno: row?.registrationno ?? '', shiftid: row?.shiftid ? String(row.shiftid) : '', - partnerid: row?.partnerid ? String(row.partnerid) : '', + // Prefilled from the branch in scope for a new own rider — most shops run + // one outlet, and asking a question with one legal answer is a chance to + // get it wrong. + locationid: String( + row?.locationid ?? (owner.kind === 'tenant' ? (branch?.locationid ?? 0) : 0), + ), status: row?.status ?? 'Active', }); const [problem, setProblem] = useState(null); @@ -82,7 +104,12 @@ export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps contactno: form.contactno.trim(), email: form.email.trim(), applocationid, - partnerid: Number(form.partnerid) || 0, + // Exactly one owner, set from the scope rather than from a field. The + // person filling this in is already inside a merchant's console or a + // partner's directory; asking again would only offer a way to be wrong. + ...(owner.kind === 'tenant' + ? { locationid: Number(form.locationid) || 0 } + : { partnerid: owner.partnerid }), shiftid: Number(form.shiftid) || 0, identificationno: form.identificationno.trim(), vehiclename: form.vehiclename.trim(), @@ -91,9 +118,10 @@ export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps registrationno: form.registrationno.trim(), status: form.status, }; - return isNew - ? ridersApi.create(tenantid, payload) - : ridersApi.update({ ...payload, userid: row.userid }); + if (!isNew) return ridersApi.update({ ...payload, userid: row.userid }); + return owner.kind === 'tenant' + ? ridersApi.create(owner.tenantid, payload) + : ridersApi.createForPartner(owner.partnerid, payload); }, onSuccess: async () => { await client.invalidateQueries({ queryKey: queryKeys.insights.all }); @@ -180,19 +208,32 @@ export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps : 'No shifts set up for this region' } /> - set('partnerid')(String(value))} - options={(partners.data ?? []).map((partner) => ({ - value: String(partner.partnerid), - label: partner.partnername || `Partner ${partner.partnerid}`, - }))} - placeholder={ - partners.isLoading ? 'Loading partners…' : 'None — rides for the shop directly' - } - /> + {/* A merchant's rider works out of one of that merchant's branches. + A partner's does not — a partner supplies several merchants and is + tied to none of their outlets — so the question is only asked + where it has an answer. */} + {owner.kind === 'tenant' ? ( + set('locationid')(String(value))} + options={owner.branches.map((entry) => ({ + value: String(entry.locationid), + label: entry.locationname || `Branch ${entry.locationid}`, + }))} + placeholder="Choose the branch they ride from" + description="Which of your outlets this rider works out of. It can be changed later." + /> + ) : ( +
+ + + Rides for {owner.partnername}, and delivers for every merchant that + partner supplies in this region. + +
+ )} diff --git a/src/features/store-admin/assignDelivery.test.ts b/src/features/store-admin/assignDelivery.test.ts index cc37762..07e7db9 100644 --- a/src/features/store-admin/assignDelivery.test.ts +++ b/src/features/store-admin/assignDelivery.test.ts @@ -14,6 +14,7 @@ import { riderVehicle, stampNow, waitingMs, + riderScope, } from './assignDelivery'; /* @@ -322,3 +323,38 @@ test('the vehicle line is empty rather than a stray separator when nothing is re assert.equal(riderVehicle(rider), 'Bike · TN 38CV 1535'); assert.equal(riderVehicle({ userid: 1 }), ''); }); + +/* ── Which fleet an order is offered ──────────────────────────────────────── */ + +/* +Measured on 2026-09-09: 118 riders across three regions, and 117 of them belong +to a delivery partner — 75 to partner 44 alone. Exactly one is a merchant's own. + +So asking `getriders` by REGION, which is what the picker did, offered a +merchant every on-duty rider in their city: other merchants' own riders and +every other partner's fleet. The scope has to name an owner. +*/ + +test('a fleet scope names an owner, never a city', () => { + assert.deepEqual(riderScope({ tenantid: 1147, partnerid: 44, source: 'partner' }), { + partnerid: 44, + }); + assert.deepEqual(riderScope({ tenantid: 1147, partnerid: 44, source: 'own' }), { + tenantid: 1147, + }); +}); + +// A shop with no partner has one source, and choosing "partner" cannot happen +// — but if it somehow did, it must not fall back to the city. +test('no partner means own riders, not everybody in the city', () => { + assert.deepEqual(riderScope({ tenantid: 1147, partnerid: 0, source: 'partner' }), { + tenantid: 1147, + }); +}); + +// R mart today: no partner, no riders of its own. The honest answer is an empty +// picker, not 82 riders it cannot use. +test('a shop with neither gets an empty fleet, not a borrowed one', () => { + const scope = riderScope({ tenantid: 0, partnerid: 0, source: 'own' }); + assert.deepEqual(scope, { tenantid: undefined }); +}); diff --git a/src/features/store-admin/assignDelivery.ts b/src/features/store-admin/assignDelivery.ts index 7d509bb..3135417 100644 --- a/src/features/store-admin/assignDelivery.ts +++ b/src/features/store-admin/assignDelivery.ts @@ -349,3 +349,20 @@ export function riderName(rider: RiderInfo): string { export function riderVehicle(rider: RiderInfo): string { return [rider.vehiclename, rider.vehicleno].map((part) => (part ?? '').trim()).filter(Boolean).join(' · '); } + +/** + * Which fleet to ask for, given the merchant and the source they chose. + * + * Its own function so the rule is testable and so the picker and anything that + * follows it cannot drift apart on it. Never returns a region: `getriders` + * scoped by city answers "who is on duty in Coimbatore", which for a merchant + * is 82 riders belonging to other companies. + */ +export function riderScope(input: { + tenantid: number; + partnerid: number; + source: 'own' | 'partner'; +}): { tenantid?: number | undefined; partnerid?: number | undefined } { + if (input.source === 'partner' && input.partnerid > 0) return { partnerid: input.partnerid }; + return { tenantid: input.tenantid || undefined }; +} diff --git a/src/features/store-admin/pages/UsersPage.tsx b/src/features/store-admin/pages/UsersPage.tsx index 5883a1e..72bbe26 100644 --- a/src/features/store-admin/pages/UsersPage.tsx +++ b/src/features/store-admin/pages/UsersPage.tsx @@ -288,7 +288,7 @@ export function UsersPage() { {editingRider ? ( .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; +} diff --git a/src/queries/hooks.ts b/src/queries/hooks.ts index 3f7a873..df62458 100644 --- a/src/queries/hooks.ts +++ b/src/queries/hooks.ts @@ -16,6 +16,7 @@ import { productsApi } from '@/api/products'; import { posUsersApi, staffApi } from '@/api/people'; import { stockApi, type StockRequestQuery } from '@/api/stock'; import { tenantsApi, utilsApi, type TenantListQuery } from '@/api/tenants'; +import { partnersApi } from '@/api/deliveries'; import { customersApi, type CustomerQuery } from '@/api/customers'; import { uploadsApi } from '@/api/uploads'; import { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories'; @@ -138,6 +139,93 @@ export function useImportedRefs(tenantid: number | undefined) { /* ── Products ────────────────────────────────────────────────────────────── */ +/** + * Every delivery partner across every region. + * + * `getpartners` takes one region at a time and refuses 0, so the whole list is + * the regions fanned out and flattened. Three regions today — Coimbatore, + * Madurai, Nagercoil — so this is three requests, not a page of them. + */ +export function useAllPartners() { + const regions = useAppRegions(); + const ids = (regions.data ?? []).map((region) => region.applocationid); + + const results = useQueries({ + queries: ids.map((applocationid) => ({ + queryKey: queryKeys.partners.inRegion(applocationid), + queryFn: () => partnersApi.list(applocationid), + ...stable, + })), + }); + + return { + data: results.flatMap((result) => result.data ?? []), + isLoading: regions.isLoading || results.some((result) => result.isLoading), + }; +} + +/** The delivery regions. `0` asks for all of them — see `utilsApi.appLocations`. */ +export function useAppRegions() { + return useQuery({ + queryKey: queryKeys.regions.all, + queryFn: () => utilsApi.appLocations(0), + ...stable, + }); +} + +/** + * One delivery partner's riders. + * + * The roster, not `getriders`: the second wants a clock-in stamped today, so a + * rider added five minutes ago is absent from it — which reads as a failed + * save. The directory shows everybody and reports duty as a state. + */ +export function usePartnerRiders(partnerid: number | undefined) { + return useQuery({ + queryKey: [...queryKeys.partners.all, 'riders', partnerid ?? 0] as const, + queryFn: () => ridersApi.partnerRoster(partnerid as number), + enabled: typeof partnerid === 'number' && partnerid > 0, + ...stable, + }); +} + +/** + * How many riders each partner has, keyed by partnerid. + * + * Fanned out because `getriderroster` takes one partner at a time. Five + * partners today, so five requests — and the count is what makes the Riders + * button worth pressing, since "Riders" alone asks you to open a drawer to find + * out whether there are any. + */ +export function usePartnerRiderCounts(partnerids: readonly number[]) { + const ids = [...new Set(partnerids)].filter((id) => id > 0); + + const results = useQueries({ + queries: ids.map((partnerid) => ({ + queryKey: [...queryKeys.partners.all, 'riders', partnerid] as const, + queryFn: () => ridersApi.partnerRoster(partnerid), + ...stable, + })), + }); + + const counts = new Map(); + results.forEach((result, index) => { + const id = ids[index]; + if (id !== undefined && result.data) counts.set(id, result.data.length); + }); + return counts; +} + +/** The regions one partner covers. */ +export function usePartnerLocations(partnerid: number | undefined) { + return useQuery({ + queryKey: queryKeys.partners.locations(partnerid ?? 0), + queryFn: () => partnersApi.locations(partnerid as number), + enabled: typeof partnerid === 'number' && partnerid > 0, + ...stable, + }); +} + /** * The ten aisles the customer app groups products into. * @@ -356,11 +444,21 @@ export function useDeliveries(query: OrderQuery | undefined) { * Scoped by `applocationid`. Passing a tenant instead returns an empty list * with a 200 — see `deliveriesApi.riders`. */ -export function useRiders(applocationid: number | undefined) { +export function useRiders(scope: { + /** The merchant's own riders. */ + tenantid?: number | undefined; + /** A delivery partner's riders. */ + partnerid?: number | undefined; + /** Only used when neither of the above is given. */ + applocationid?: number | undefined; +}) { + const { tenantid, partnerid, applocationid } = scope; return useQuery({ - queryKey: queryKeys.insights.riders(applocationid ?? 0), - queryFn: () => deliveriesApi.riders({ applocationid: applocationid as number }), - enabled: Boolean(applocationid), + // The scope is part of the key, or one fleet's riders would be served to + // the other after a toggle. + queryKey: [...queryKeys.insights.all, 'riders', tenantid ?? 0, partnerid ?? 0, applocationid ?? 0] as const, + queryFn: () => deliveriesApi.riders({ tenantid, partnerid, applocationid }), + enabled: Boolean(tenantid || partnerid || applocationid), ...live, }); } diff --git a/src/queries/keys.ts b/src/queries/keys.ts index 38247a7..faf0b14 100644 --- a/src/queries/keys.ts +++ b/src/queries/keys.ts @@ -8,6 +8,14 @@ */ export const queryKeys = { + /** Delivery partners — the companies that supply riders. */ + partners: { + all: ['partners'] as const, + inRegion: (applocationid: number) => ['partners', 'region', applocationid] as const, + locations: (partnerid: number) => ['partners', 'locations', partnerid] as const, + }, + /** The delivery regions a partner can cover. */ + regions: { all: ['regions'] as const }, uploads: { all: ['uploads'] as const, list: (tenantid: number, locationid: number) =>