280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { Button } from '@astryxdesign/core/Button';
|
|
import { Selector } from '@astryxdesign/core/Selector';
|
|
import { Route, TriangleAlert, UserCheck } from 'lucide-react';
|
|
import { errorMessage } from '@/api/client';
|
|
import {
|
|
RIDER_MESSAGE,
|
|
RiderNotReachableError,
|
|
deliveriesApi,
|
|
} from '@/api/deliveries';
|
|
import type { OrderRow, TenantLocation } from '@/api/types';
|
|
import { useAllPartners, useOwnTenant, useRiders } from '@/queries/hooks';
|
|
import { queryKeys } from '@/queries/keys';
|
|
import { useBranchScope } from './BranchScope';
|
|
import { buildDeliveries, riderName, riderVehicle } from './assignDelivery';
|
|
import { hasNoDevice } from './riderReach';
|
|
import { RoutePlanDrawer } from './RoutePlanDrawer';
|
|
import './pages/deliveries.css';
|
|
|
|
/**
|
|
* Assigning riders, from the orders table itself.
|
|
*
|
|
* A bar above the list rather than a page or a drawer of its own, which is what
|
|
* the old console did and the right shape for the job: assigning is something
|
|
* you do WHILE reading the day's orders, glancing between the drop addresses
|
|
* and who is free. A separate screen makes you leave the list to act on it and
|
|
* come back to check.
|
|
*
|
|
* It appears when rows are ticked and stays afterwards to report what happened,
|
|
* because the result is the only record — `createdeliveries` answers with a
|
|
* message and no ids.
|
|
*/
|
|
export interface AssignBarProps {
|
|
/** The ticked orders. */
|
|
orders: OrderRow[];
|
|
branchOf: (row: OrderRow) => TenantLocation | undefined;
|
|
assigned: ReadonlySet<number>;
|
|
/** Orders whose delivery is dead — see `releasedFrom`. */
|
|
released?: ReadonlySet<number>;
|
|
onClear: () => void;
|
|
onDone: () => void;
|
|
}
|
|
|
|
export function AssignBar({
|
|
orders,
|
|
branchOf,
|
|
assigned,
|
|
released,
|
|
onClear,
|
|
onDone,
|
|
}: AssignBarProps) {
|
|
const client = useQueryClient();
|
|
const [riderId, setRiderId] = useState('');
|
|
const [outcome, setOutcome] = useState<string | null>(null);
|
|
const [planning, setPlanning] = useState(false);
|
|
|
|
/**
|
|
* 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);
|
|
/*
|
|
Both fleets are read, and the picker lists them together.
|
|
|
|
This used to be an either/or toggle, so a shop with a partner had to switch
|
|
back and forth to compare who was free — and the whole question at this
|
|
moment is "who can take this", across everybody available. Two reads and one
|
|
grouped list answers it in a glance instead.
|
|
|
|
Each rider still shows whose they are, because they are different people with
|
|
different employers and an operator handing over a parcel should know which.
|
|
*/
|
|
const ownRiders = useRiders(tenantid ? { tenantid } : {});
|
|
const partnerRiders = useRiders(partnerid ? { partnerid } : {});
|
|
|
|
// 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 = { isLoading: ownRiders.isLoading || partnerRiders.isLoading };
|
|
const fleet = useMemo(
|
|
() => [...(ownRiders.data ?? []), ...(partnerRiders.data ?? [])],
|
|
[ownRiders.data, partnerRiders.data],
|
|
);
|
|
/** Whose rider this is, for the label beside their name. */
|
|
const ownIds = useMemo(
|
|
() => new Set((ownRiders.data ?? []).map((entry) => entry.userid)),
|
|
[ownRiders.data],
|
|
);
|
|
const rider = fleet.find((entry) => String(entry.userid) === riderId);
|
|
|
|
const send = useMutation({
|
|
mutationFn: async () => {
|
|
if (!rider) throw new Error('Pick a rider first');
|
|
const { drafts, skipped } = buildDeliveries(orders, rider, branchOf, new Date(), assigned, released);
|
|
if (drafts.length === 0) {
|
|
throw new Error(skipped[0]?.reason ?? 'None of these orders can be assigned');
|
|
}
|
|
await deliveriesApi.assign(drafts);
|
|
return { count: drafts.length, skipped: skipped.length };
|
|
},
|
|
onSuccess: async ({ count, skipped }) => {
|
|
const who = riderName(rider!);
|
|
const base =
|
|
skipped === 0
|
|
? `${count} order${count === 1 ? '' : 's'} assigned to ${who}`
|
|
: `${count} assigned to ${who} · ${skipped} could not be and are still waiting`;
|
|
|
|
await client.invalidateQueries({ queryKey: queryKeys.insights.all });
|
|
onDone();
|
|
|
|
/*
|
|
* The push is fired after the write and reported separately.
|
|
*
|
|
* The deliveries exist either way, so a failed notification must not read
|
|
* as a failed assignment — but it must still be visible, because a rider
|
|
* who was never told has work sitting unseen. This is the old console's
|
|
* contract and it is the right one.
|
|
*/
|
|
setOutcome(`${base} · telling them…`);
|
|
try {
|
|
await deliveriesApi.notify(rider!.userfcmtoken ?? '', RIDER_MESSAGE.assigned(count));
|
|
setOutcome(`${base} · rider notified`);
|
|
} catch (error) {
|
|
setOutcome(
|
|
`${base} · NOT notified — ${
|
|
error instanceof RiderNotReachableError
|
|
? 'this rider has no device registered'
|
|
: 'the push failed, tell them another way'
|
|
}`,
|
|
);
|
|
}
|
|
},
|
|
onError: (error) => setOutcome(errorMessage(error)),
|
|
});
|
|
|
|
/*
|
|
Whose rider, on every row.
|
|
|
|
The two fleets are one list now, so the label has to carry the employer —
|
|
otherwise a shop with a partner reads eleven names and cannot tell which of
|
|
them it pays. The vehicle stays because it is the other thing an operator
|
|
picks on.
|
|
*/
|
|
const options = fleet.map((entry) => {
|
|
const whose = ownIds.has(entry.userid) ? 'yours' : partnerName || 'partner';
|
|
const vehicle = riderVehicle(entry);
|
|
return {
|
|
value: String(entry.userid),
|
|
label: vehicle
|
|
? `${riderName(entry)} · ${vehicle} · ${whose}`
|
|
: `${riderName(entry)} · ${whose}`,
|
|
};
|
|
});
|
|
|
|
return (
|
|
<div className="assign-bar" role="region" aria-label="Assign selected orders to a rider">
|
|
<span className="assign-bar-count">
|
|
<UserCheck size={15} />
|
|
{orders.length} selected
|
|
</span>
|
|
|
|
|
|
<div className="assign-bar-picker">
|
|
<Selector
|
|
label="Choose a rider"
|
|
isLabelHidden
|
|
size="sm"
|
|
value={riderId}
|
|
onChange={(value) => {
|
|
setRiderId(String(value));
|
|
setOutcome(null);
|
|
}}
|
|
options={options}
|
|
isDisabled={orders.length === 0}
|
|
/* The empty-list wording carries the actual information — which of
|
|
the three situations you are in — so it lives in the placeholder
|
|
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. */
|
|
/* An empty list has three different causes and the operator needs to
|
|
know which: wait for somebody to clock on, hire a rider, or ask
|
|
Nearle for a partner. Naming the fleets that were searched is what
|
|
distinguishes them. */
|
|
placeholder={
|
|
riders.isLoading
|
|
? 'Loading riders…'
|
|
: options.length > 0
|
|
? 'Select a rider…'
|
|
: partnerid > 0
|
|
? 'No riders or partners on duty'
|
|
: 'No riders on duty (no partner)'
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{/*
|
|
Said before the hand-off, not after it.
|
|
|
|
A rider who has never opened the app has no device to push to, so the
|
|
job lands in a queue nobody is told about. That used to surface as
|
|
"NOT notified" in the outcome line AFTER the deliveries were written —
|
|
by which point the only remedy is a phone call, and one navigation
|
|
later there was no record of it at all.
|
|
|
|
It disables nothing. Assigning to a rider you are about to ring is a
|
|
legitimate thing to do; being surprised by it afterwards is not.
|
|
*/}
|
|
{rider && hasNoDevice(rider) ? (
|
|
<span className="assign-bar-warn" role="status">
|
|
<TriangleAlert size={14} />
|
|
No app on this rider’s phone yet — they will not be told. Call them.
|
|
</span>
|
|
) : null}
|
|
|
|
{/* Two ways to commit the same selection, and the difference is worth the
|
|
second button: "Assign" hands the orders over as they are, which is
|
|
right for one or two. "Plan the route" sequences them first — real
|
|
road distances and an ETA per leg — which starts paying for itself at
|
|
about four stops and is the only path that fills the delivery rows'
|
|
kms fields with anything real. */}
|
|
<Button
|
|
label="Plan the route"
|
|
variant="secondary"
|
|
size="sm"
|
|
icon={<Route size={14} />}
|
|
isDisabled={!rider || orders.length === 0}
|
|
onClick={() => setPlanning(true)}
|
|
/>
|
|
<Button
|
|
label={send.isPending ? 'Assigning…' : 'Assign rider'}
|
|
variant="primary"
|
|
size="sm"
|
|
icon={<UserCheck size={14} />}
|
|
isDisabled={!rider || orders.length === 0 || send.isPending}
|
|
onClick={() => send.mutate()}
|
|
/>
|
|
<Button label="Clear" variant="ghost" size="sm" onClick={onClear} />
|
|
|
|
{planning && rider ? (
|
|
<RoutePlanDrawer
|
|
orders={orders}
|
|
rider={rider}
|
|
branchOf={branchOf}
|
|
assigned={assigned}
|
|
onClose={() => setPlanning(false)}
|
|
onDone={onDone}
|
|
/>
|
|
) : null}
|
|
|
|
{outcome ? (
|
|
<span className="assign-bar-outcome" role="status">
|
|
{outcome}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|