updats on the dispatch page
This commit is contained in:
359
src/features/store-admin/AutoAssignDrawer.tsx
Normal file
359
src/features/store-admin/AutoAssignDrawer.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
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,
|
||||
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>;
|
||||
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).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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user