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

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