import { useMemo, useRef, useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Text } from '@astryxdesign/core/Text'; import { VStack } from '@astryxdesign/core/VStack'; import { HStack } from '@astryxdesign/core/HStack'; import { AlertTriangle, Bike, Sparkles, UserMinus } from 'lucide-react'; import { optimiserApi, OptimiserError } from '@/api/optimiser'; import { deliveriesApi, RIDER_MESSAGE, RiderNotReachableError } from '@/api/deliveries'; import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types'; import { queryKeys } from '@/queries/keys'; import { Drawer } from './Drawer'; import { Badge, DrawerButton, DrawerCard, Note, Row, Section } from './drawerKit'; import { buildDeliveries } from './assignDelivery'; import { absentFrom, branchFor, buildRequest, committable, readPlan, TUNINGS, unmatched, type Plan, type Tuning, } from './autoAssign'; import { moneyExact } from './format'; import './dispatchPanels.css'; /** * Let the optimiser propose the round, then commit it here. * * ── Two steps, deliberately ───────────────────────────────────────────────── * * Running the solver writes nothing. It answers with a PROPOSAL, the operator * reads it, and only "Assign" turns it into deliveries. That separation is the * whole design: a solver that assigned directly would be a button that * dispatches every waiting order to riders nobody has looked at, and the first * time it got a round wrong there would be no moment at which anyone could have * caught it. * * ── Committed through the same door as the manual bar ─────────────────────── * * `buildDeliveries` → `deliveriesApi.assign`, exactly as `AssignBar` does. Not * a shortcut: `assignDelivery` is where the knowledge lives about which ids a * delivery must carry to be joinable afterwards, and a second write path here * would be a second place for that to drift. The solver decides WHO; it does * not get to decide what a delivery row looks like. * * One call for the whole plan, because one call is one transaction — several * riders' rows in a single array is what the endpoint takes and what the old * console sends. */ export function AutoAssignDrawer({ orders, branches, fleet, assigned, released, onClose, onDone, }: { /** The waiting orders this run is for. */ orders: readonly OrderRow[]; branches: readonly TenantLocation[]; /** Riders we know about, for the absentee picker and for naming. */ fleet: readonly RiderInfo[]; assigned: ReadonlySet; /** Orders whose delivery is dead — see `releasedFrom`. */ released?: ReadonlySet; onClose: () => void; onDone: () => void; }) { const client = useQueryClient(); const [tuning, setTuning] = useState('balanced'); const [absent, setAbsent] = useState>(new Set()); const [plan, setPlan] = useState(null); const [outcome, setOutcome] = useState(null); const abort = useRef(null); const branchOf = useMemo( () => (row: OrderRow) => branchFor(row, branches), [branches], ); const solve = useMutation({ mutationFn: async () => { abort.current?.abort(); const controller = new AbortController(); abort.current = controller; const away = fleet.filter((rider) => absent.has(rider.userid)).map(absentFrom); const response = await optimiserApi.assign( buildRequest(orders, away), tuning, controller.signal, ); return readPlan(response, orders); }, onSuccess: (next) => { setPlan(next); setOutcome(null); }, onError: (error) => { setPlan(null); setOutcome( error instanceof OptimiserError ? error.message : 'The optimiser run failed.', ); }, }); const commit = useMutation({ mutationFn: async () => { if (!plan) throw new Error('Run the optimiser first'); /* Every proposal's rows, built through the same path the manual bar uses and sent as one array — one call is one transaction. */ const drafts = plan.proposals.flatMap((proposal) => { const rows = committable(proposal); // Only `userid` is read off the rider when a delivery is built; the // rest of the roster row matters for notifying, which happens after. const rider = (fleet.find((r) => r.userid === proposal.userid) ?? ({ userid: proposal.userid } as RiderInfo)); return buildDeliveries(rows, rider, branchOf, new Date(), assigned, released).drafts; }); if (drafts.length === 0) { throw new Error('Nothing in this plan can be assigned.'); } await deliveriesApi.assign(drafts); return drafts.length; }, onSuccess: async (count) => { await client.invalidateQueries({ queryKey: queryKeys.insights.all }); setOutcome(`${count} order${count === 1 ? '' : 's'} assigned · telling the riders…`); onDone(); /* Notified after the write and reported separately: the deliveries exist either way, so a failed push must not read as a failed assignment — but it must still be visible, because a rider who was never told has work sitting unseen. */ const failures: string[] = []; for (const proposal of plan?.proposals ?? []) { const rider = fleet.find((r) => r.userid === proposal.userid); try { await deliveriesApi.notify( rider?.userfcmtoken ?? '', RIDER_MESSAGE.assigned(proposal.stops.length), ); } catch (error) { failures.push( error instanceof RiderNotReachableError ? `${proposal.rider} has no device registered` : `${proposal.rider} could not be reached`, ); } } setOutcome( failures.length === 0 ? `${count} order${count === 1 ? '' : 's'} assigned · every rider notified` : `${count} assigned · NOT notified: ${failures.join('; ')} — tell them another way`, ); setPlan(null); }, onError: (error) => setOutcome(error instanceof Error ? error.message : 'The assignment failed.'), }); const isRunning = solve.isPending; const missing = plan ? unmatched(plan) : []; const canCommit = Boolean(plan && plan.proposals.length > 0 && !commit.isPending); return ( { if (isRunning) abort.current?.abort(); else onClose(); }} /> {plan ? ( } isDisabled={!canCommit} onClick={() => commit.mutate()} /> ) : ( } isDisabled={isRunning || orders.length === 0} onClick={() => solve.mutate()} /> )} } > }> The optimiser proposes who carries what. Nothing is assigned until you press Assign, so running it is always safe — and running it again just replaces the proposal. {/* ── Before the run ──────────────────────────────────────────── */} {!plan ? ( <>
{TUNINGS.map((option) => ( ))}
{fleet.length === 0 ? ( ) : (
{fleet.map((rider) => { const name = `${rider.firstname ?? ''} ${rider.lastname ?? ''}`.trim() || rider.fullname?.trim() || `Rider ${rider.userid}`; return ( ); })}
)}
) : null} {/* ── The proposal ────────────────────────────────────────────── */} {plan?.blocked ? (
The optimiser could not consider anybody {plan.blocked}
) : null} {plan && plan.proposals.length > 0 ? (
{plan.proposals.map((proposal) => ( } /> {proposal.stops.map((stop) => ( ))} ))} {plan.meta.profit > 0 ? ( The optimiser puts this plan at {moneyExact(plan.meta.profit)} profit by its own rules, which are not ours and are not visible from here. ) : null}
) : null} {missing.length > 0 ? ( }> {missing.length} proposed order{missing.length === 1 ? '' : 's'} could not be matched back to this list and will not be assigned: {missing.join(', ')}. ) : null} {plan && plan.unassigned.length > 0 ? (
{plan.unassigned.slice(0, 12).map((entry) => ( ))} {plan.unassigned.length > 12 ? ( ) : null}
) : null} {outcome ? ( {outcome} ) : null}
); }