Files
daily_console_web/src/features/store-admin/AssignBar.tsx
2026-09-22 11:13:15 +05:30

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&rsquo;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>
);
}