363 lines
14 KiB
TypeScript
363 lines
14 KiB
TypeScript
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<number>;
|
|
/** Orders whose delivery is dead — see `releasedFrom`. */
|
|
released?: ReadonlySet<number>;
|
|
onClose: () => void;
|
|
onDone: () => void;
|
|
}) {
|
|
const client = useQueryClient();
|
|
const [tuning, setTuning] = useState<Tuning>('balanced');
|
|
const [absent, setAbsent] = useState<ReadonlySet<number>>(new Set());
|
|
const [plan, setPlan] = useState<Plan | null>(null);
|
|
const [outcome, setOutcome] = useState<string | null>(null);
|
|
const abort = useRef<AbortController | null>(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 (
|
|
<Drawer
|
|
title="Auto-assign"
|
|
subtitle={`${orders.length} order${orders.length === 1 ? '' : 's'} waiting`}
|
|
width={620}
|
|
onClose={onClose}
|
|
isFooterSpread
|
|
footer={
|
|
<>
|
|
<DrawerButton
|
|
label={isRunning ? 'Cancel' : 'Close'}
|
|
variant="ghost"
|
|
onClick={() => {
|
|
if (isRunning) abort.current?.abort();
|
|
else onClose();
|
|
}}
|
|
/>
|
|
{plan ? (
|
|
<DrawerButton
|
|
label={commit.isPending ? 'Assigning…' : `Assign ${plan.meta.assigned}`}
|
|
variant="primary"
|
|
icon={<Bike size={15} />}
|
|
isDisabled={!canCommit}
|
|
onClick={() => commit.mutate()}
|
|
/>
|
|
) : (
|
|
<DrawerButton
|
|
label={isRunning ? 'Working…' : 'Run the optimiser'}
|
|
variant="primary"
|
|
icon={<Sparkles size={15} />}
|
|
isDisabled={isRunning || orders.length === 0}
|
|
onClick={() => solve.mutate()}
|
|
/>
|
|
)}
|
|
</>
|
|
}
|
|
>
|
|
<VStack gap={2}>
|
|
<Note icon={<Sparkles size={15} />}>
|
|
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.
|
|
</Note>
|
|
|
|
{/* ── Before the run ──────────────────────────────────────────── */}
|
|
{!plan ? (
|
|
<>
|
|
<Section title="How to solve it">
|
|
<div className="aa-tunings">
|
|
{TUNINGS.map((option) => (
|
|
<button
|
|
key={option.id}
|
|
type="button"
|
|
className="aa-tuning"
|
|
data-active={tuning === option.id}
|
|
disabled={isRunning}
|
|
onClick={() => setTuning(option.id)}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</Section>
|
|
|
|
<Section title="Anybody off today?">
|
|
<DrawerCard>
|
|
<Row
|
|
label="Absent riders"
|
|
value="Ticked riders are left out of the plan. Leaving this empty is normal — the solver then considers everyone."
|
|
isStacked
|
|
/>
|
|
{fleet.length === 0 ? (
|
|
<Row
|
|
label="Fleet"
|
|
value="No riders are on duty, so there is nobody to mark absent."
|
|
isStacked
|
|
/>
|
|
) : (
|
|
<div className="aa-absent">
|
|
{fleet.map((rider) => {
|
|
const name =
|
|
`${rider.firstname ?? ''} ${rider.lastname ?? ''}`.trim() ||
|
|
rider.fullname?.trim() ||
|
|
`Rider ${rider.userid}`;
|
|
return (
|
|
<label key={rider.userid} className="aa-absent-row">
|
|
<input
|
|
type="checkbox"
|
|
checked={absent.has(rider.userid)}
|
|
disabled={isRunning}
|
|
onChange={() =>
|
|
setAbsent((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(rider.userid)) next.delete(rider.userid);
|
|
else next.add(rider.userid);
|
|
return next;
|
|
})
|
|
}
|
|
/>
|
|
<UserMinus size={13} />
|
|
{name}
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</DrawerCard>
|
|
</Section>
|
|
</>
|
|
) : null}
|
|
|
|
{/* ── The proposal ────────────────────────────────────────────── */}
|
|
{plan?.blocked ? (
|
|
<div className="aa-blocked">
|
|
<AlertTriangle size={16} />
|
|
<div>
|
|
<strong>The optimiser could not consider anybody</strong>
|
|
<span>{plan.blocked}</span>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{plan && plan.proposals.length > 0 ? (
|
|
<Section
|
|
title={`${plan.meta.assigned} of ${plan.meta.totalOrders} placed with ${plan.proposals.length} rider${plan.proposals.length === 1 ? '' : 's'}`}
|
|
>
|
|
{plan.proposals.map((proposal) => (
|
|
<DrawerCard key={proposal.userid}>
|
|
<Row
|
|
label={proposal.rider}
|
|
value={
|
|
<Badge
|
|
label={`${proposal.stops.length} stop${proposal.stops.length === 1 ? '' : 's'}`}
|
|
colour="var(--color-brand)"
|
|
/>
|
|
}
|
|
/>
|
|
{proposal.stops.map((stop) => (
|
|
<Row
|
|
key={stop.orderid || stop.orderheaderid}
|
|
label={stop.orderid || `#${stop.orderheaderid}`}
|
|
value={
|
|
stop.row
|
|
? moneyExact(
|
|
stop.row.ordervalue || stop.row.orderamount || stop.row.deliveryamt || 0,
|
|
)
|
|
: 'not in this list'
|
|
}
|
|
/>
|
|
))}
|
|
</DrawerCard>
|
|
))}
|
|
{plan.meta.profit > 0 ? (
|
|
<Note>
|
|
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.
|
|
</Note>
|
|
) : null}
|
|
</Section>
|
|
) : null}
|
|
|
|
{missing.length > 0 ? (
|
|
<Note icon={<AlertTriangle size={15} />}>
|
|
{missing.length} proposed order{missing.length === 1 ? '' : 's'} could not be matched
|
|
back to this list and will not be assigned: {missing.join(', ')}.
|
|
</Note>
|
|
) : null}
|
|
|
|
{plan && plan.unassigned.length > 0 ? (
|
|
<Section title={`${plan.unassigned.length} left unassigned`}>
|
|
<DrawerCard>
|
|
{plan.unassigned.slice(0, 12).map((entry) => (
|
|
<Row key={entry.orderid} label={entry.orderid} value={entry.reason} isStacked />
|
|
))}
|
|
{plan.unassigned.length > 12 ? (
|
|
<Row
|
|
label="…"
|
|
value={`and ${plan.unassigned.length - 12} more, for the same reasons`}
|
|
/>
|
|
) : null}
|
|
</DrawerCard>
|
|
</Section>
|
|
) : null}
|
|
|
|
{outcome ? (
|
|
<HStack gap={1} align="center">
|
|
<Text type="body" size="sm">
|
|
{outcome}
|
|
</Text>
|
|
</HStack>
|
|
) : null}
|
|
</VStack>
|
|
</Drawer>
|
|
);
|
|
}
|