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; /** Orders whose delivery is dead — see `releasedFrom`. */ released?: ReadonlySet; 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(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)?.['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 (
{orders.length} selected
{ 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)' } />
{/* 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) ? ( No app on this rider’s phone yet — they will not be told. Call them. ) : 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. */}
); }