rider partner page
This commit is contained in:
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Route, UserCheck } from 'lucide-react';
|
||||
import { Bike, Route, Truck, UserCheck } from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import {
|
||||
RIDER_MESSAGE,
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
deliveriesApi,
|
||||
} from '@/api/deliveries';
|
||||
import type { OrderRow, TenantLocation } from '@/api/types';
|
||||
import { useRiders } from '@/queries/hooks';
|
||||
import { useAllPartners, useOwnTenant, useRiders } from '@/queries/hooks';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { buildDeliveries, riderName, riderVehicle } from './assignDelivery';
|
||||
import { useBranchScope } from './BranchScope';
|
||||
import { buildDeliveries, riderName, riderScope, riderVehicle } from './assignDelivery';
|
||||
import { RoutePlanDrawer } from './RoutePlanDrawer';
|
||||
import './pages/deliveries.css';
|
||||
|
||||
@@ -44,12 +45,38 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
|
||||
const [outcome, setOutcome] = useState<string | null>(null);
|
||||
const [planning, setPlanning] = useState(false);
|
||||
|
||||
// Every order in a batch shares a region in practice — they are one branch's
|
||||
// orders — so the first one carrying a region decides which fleet to ask for.
|
||||
const region =
|
||||
orders.map((row) => row.applocationid || branchOf(row)?.applocationid || 0).find(Boolean) ?? 0;
|
||||
/**
|
||||
* Which fleet these orders are handed to.
|
||||
*
|
||||
* Two real sources, and a merchant can have both: riders they hired
|
||||
* themselves, and riders from the delivery partner they sit under. Asked as a
|
||||
* question rather than merged into one list, because they are different
|
||||
* people with different employers and an operator handing an order over
|
||||
* should know which.
|
||||
*
|
||||
* The partner side only appears when the merchant HAS one — `tenants.partnerid`
|
||||
* — so a shop delivering with its own riders never sees an empty second tab.
|
||||
*/
|
||||
const { tenantid } = useBranchScope();
|
||||
const shop = useOwnTenant(tenantid || undefined);
|
||||
const partnerid = Number((shop.data as unknown as Record<string, number>)?.['partnerid'] ?? 0);
|
||||
const [source, setSource] = useState<'own' | 'partner'>(partnerid > 0 ? 'partner' : 'own');
|
||||
|
||||
const riders = useRiders(region || undefined);
|
||||
// The partner's NAME on the tab, not "Partner riders". An operator handing an
|
||||
// order to Xpress-Cbe-Main should read that, not a category.
|
||||
const partners = useAllPartners();
|
||||
const partnerName =
|
||||
partners.data.find((entry) => entry.partnerid === partnerid)?.partnername ?? '';
|
||||
|
||||
/*
|
||||
Scoped by owner, never by city.
|
||||
|
||||
It asked `getriders` with the region alone, which means "who is on duty in
|
||||
Coimbatore" — 82 riders, almost none of them this shop's to use. Measured
|
||||
2026-09-09: 117 of the platform's 118 riders belong to a partner, so region
|
||||
scope was quietly offering other companies' fleets.
|
||||
*/
|
||||
const riders = useRiders(riderScope({ tenantid, partnerid, source }));
|
||||
const fleet = riders.data ?? [];
|
||||
const rider = fleet.find((entry) => String(entry.userid) === riderId);
|
||||
|
||||
@@ -110,6 +137,38 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
|
||||
{orders.length} selected
|
||||
</span>
|
||||
|
||||
{/* Only when there is a choice to make. A shop with no partner has one
|
||||
source, and a toggle with one option is a control that asks a question
|
||||
it already knows the answer to. */}
|
||||
{partnerid > 0 ? (
|
||||
<div className="assign-source" role="group" aria-label="Which riders to offer">
|
||||
<button
|
||||
type="button"
|
||||
className="assign-source-btn"
|
||||
aria-pressed={source === 'own'}
|
||||
onClick={() => {
|
||||
setSource('own');
|
||||
setRiderId('');
|
||||
}}
|
||||
>
|
||||
<Bike size={13} />
|
||||
Own riders
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="assign-source-btn"
|
||||
aria-pressed={source === 'partner'}
|
||||
onClick={() => {
|
||||
setSource('partner');
|
||||
setRiderId('');
|
||||
}}
|
||||
>
|
||||
<Truck size={13} />
|
||||
{partnerName || 'Partner riders'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="assign-bar-picker">
|
||||
<Selector
|
||||
label="Choose a rider"
|
||||
@@ -127,14 +186,19 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
|
||||
rather than being flattened to "Select…". "No riders on duty" is a
|
||||
shift that has not started; the region case is a branch somebody
|
||||
has to configure. Neither is a fault in the orders. */
|
||||
/* Four situations, and the placeholder names which one — an operator
|
||||
looking at an empty list needs to know whether to wait, to hire, or
|
||||
to ask for a partner. */
|
||||
placeholder={
|
||||
region === 0
|
||||
? 'No delivery region on this branch'
|
||||
: riders.isLoading
|
||||
? 'Loading riders…'
|
||||
: options.length > 0
|
||||
? 'Select a rider…'
|
||||
: 'No rider has clocked on today'
|
||||
riders.isLoading
|
||||
? 'Loading riders…'
|
||||
: options.length > 0
|
||||
? 'Select a rider…'
|
||||
: source === 'partner'
|
||||
? `No ${partnerName || 'partner'} rider has clocked on today`
|
||||
: partnerid > 0
|
||||
? 'This shop has no riders of its own'
|
||||
: 'No rider has clocked on today'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Bike, Info } from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { ridersApi, type RiderRosterRow } from '@/api/deliveries';
|
||||
import type { TenantLocation } from '@/api/types';
|
||||
import { usePartners, useRiderShifts } from '@/queries/hooks';
|
||||
import { useRiderShifts } from '@/queries/hooks';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { Drawer } from './Drawer';
|
||||
import { DrawerButton } from './drawerKit';
|
||||
@@ -31,15 +31,30 @@ import './pages/deliveries.css';
|
||||
* is correct behaviour and it looks exactly like a failed save, so the drawer
|
||||
* says it before you press the button rather than leaving you to wonder.
|
||||
*/
|
||||
/**
|
||||
* Whose rider this is.
|
||||
*
|
||||
* A rider belongs to a merchant OR to a delivery partner — the server refuses
|
||||
* neither and refuses both, because a rider carrying both ids appears in two
|
||||
* directories and two assign pickers with nothing saying which owns them.
|
||||
*
|
||||
* The two cases genuinely differ in the form, not just in the payload: a
|
||||
* merchant's rider works out of one of that merchant's branches, and a
|
||||
* partner's works a region and serves whichever merchants the partner supplies.
|
||||
*/
|
||||
export type RiderOwner =
|
||||
| { kind: 'tenant'; tenantid: number; branches: readonly TenantLocation[] }
|
||||
| { kind: 'partner'; partnerid: number; partnername: string; applocationid: number };
|
||||
|
||||
export interface RiderDrawerProps {
|
||||
row: RiderRosterRow | null;
|
||||
tenantid: number;
|
||||
/** The branch whose delivery region a new rider inherits. */
|
||||
branch: TenantLocation | undefined;
|
||||
owner: RiderOwner;
|
||||
/** The branch whose delivery region a new rider inherits. Tenant case only. */
|
||||
branch?: TenantLocation | undefined;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps) {
|
||||
export function RiderDrawer({ row, owner, branch, onClose }: RiderDrawerProps) {
|
||||
const client = useQueryClient();
|
||||
const isNew = row === null;
|
||||
|
||||
@@ -50,10 +65,12 @@ export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps
|
||||
* branch they are standing in already determines it. Editing keeps whatever
|
||||
* the rider has; a new rider takes the branch's.
|
||||
*/
|
||||
const applocationid = row?.applocationid ?? branch?.applocationid ?? 0;
|
||||
const applocationid =
|
||||
row?.applocationid ??
|
||||
(owner.kind === 'partner' ? owner.applocationid : branch?.applocationid) ??
|
||||
0;
|
||||
|
||||
const shifts = useRiderShifts(applocationid || undefined);
|
||||
const partners = usePartners(applocationid || undefined);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
firstname: row?.firstname ?? '',
|
||||
@@ -66,7 +83,12 @@ export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps
|
||||
licenseno: row?.licenseno ?? '',
|
||||
registrationno: row?.registrationno ?? '',
|
||||
shiftid: row?.shiftid ? String(row.shiftid) : '',
|
||||
partnerid: row?.partnerid ? String(row.partnerid) : '',
|
||||
// Prefilled from the branch in scope for a new own rider — most shops run
|
||||
// one outlet, and asking a question with one legal answer is a chance to
|
||||
// get it wrong.
|
||||
locationid: String(
|
||||
row?.locationid ?? (owner.kind === 'tenant' ? (branch?.locationid ?? 0) : 0),
|
||||
),
|
||||
status: row?.status ?? 'Active',
|
||||
});
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
@@ -82,7 +104,12 @@ export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps
|
||||
contactno: form.contactno.trim(),
|
||||
email: form.email.trim(),
|
||||
applocationid,
|
||||
partnerid: Number(form.partnerid) || 0,
|
||||
// Exactly one owner, set from the scope rather than from a field. The
|
||||
// person filling this in is already inside a merchant's console or a
|
||||
// partner's directory; asking again would only offer a way to be wrong.
|
||||
...(owner.kind === 'tenant'
|
||||
? { locationid: Number(form.locationid) || 0 }
|
||||
: { partnerid: owner.partnerid }),
|
||||
shiftid: Number(form.shiftid) || 0,
|
||||
identificationno: form.identificationno.trim(),
|
||||
vehiclename: form.vehiclename.trim(),
|
||||
@@ -91,9 +118,10 @@ export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps
|
||||
registrationno: form.registrationno.trim(),
|
||||
status: form.status,
|
||||
};
|
||||
return isNew
|
||||
? ridersApi.create(tenantid, payload)
|
||||
: ridersApi.update({ ...payload, userid: row.userid });
|
||||
if (!isNew) return ridersApi.update({ ...payload, userid: row.userid });
|
||||
return owner.kind === 'tenant'
|
||||
? ridersApi.create(owner.tenantid, payload)
|
||||
: ridersApi.createForPartner(owner.partnerid, payload);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await client.invalidateQueries({ queryKey: queryKeys.insights.all });
|
||||
@@ -180,19 +208,32 @@ export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps
|
||||
: 'No shifts set up for this region'
|
||||
}
|
||||
/>
|
||||
<Selector
|
||||
label="Delivery partner"
|
||||
size="sm"
|
||||
value={form.partnerid}
|
||||
onChange={(value) => set('partnerid')(String(value))}
|
||||
options={(partners.data ?? []).map((partner) => ({
|
||||
value: String(partner.partnerid),
|
||||
label: partner.partnername || `Partner ${partner.partnerid}`,
|
||||
}))}
|
||||
placeholder={
|
||||
partners.isLoading ? 'Loading partners…' : 'None — rides for the shop directly'
|
||||
}
|
||||
/>
|
||||
{/* A merchant's rider works out of one of that merchant's branches.
|
||||
A partner's does not — a partner supplies several merchants and is
|
||||
tied to none of their outlets — so the question is only asked
|
||||
where it has an answer. */}
|
||||
{owner.kind === 'tenant' ? (
|
||||
<Selector
|
||||
label="Branch"
|
||||
size="sm"
|
||||
value={form.locationid}
|
||||
onChange={(value) => set('locationid')(String(value))}
|
||||
options={owner.branches.map((entry) => ({
|
||||
value: String(entry.locationid),
|
||||
label: entry.locationname || `Branch ${entry.locationid}`,
|
||||
}))}
|
||||
placeholder="Choose the branch they ride from"
|
||||
description="Which of your outlets this rider works out of. It can be changed later."
|
||||
/>
|
||||
) : (
|
||||
<div className="rider-note">
|
||||
<Info size={14} />
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Rides for <strong>{owner.partnername}</strong>, and delivers for every merchant that
|
||||
partner supplies in this region.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -288,7 +288,7 @@ export function UsersPage() {
|
||||
{editingRider ? (
|
||||
<RiderDrawer
|
||||
row={editingRider === 'new' ? null : editingRider}
|
||||
tenantid={tenantid}
|
||||
owner={{ kind: 'tenant', tenantid, branches: scoped }}
|
||||
/* The delivery region is inherited from the branch, so the drawer
|
||||
needs one. A selected branch wins; otherwise the first in scope,
|
||||
since every branch of a tenant sits in the same region in practice
|
||||
|
||||
Reference in New Issue
Block a user