assign option

This commit is contained in:
2026-09-04 11:31:07 +05:30
parent 743aa93e20
commit 416c50755b
8 changed files with 450 additions and 785 deletions

View File

@@ -0,0 +1,156 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { Selector } from '@astryxdesign/core/Selector';
import { 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 { useRiders } from '@/queries/hooks';
import { queryKeys } from '@/queries/keys';
import { buildDeliveries, riderName, riderVehicle } from './assignDelivery';
/**
* 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<number>;
onClear: () => void;
onDone: () => void;
}
export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: AssignBarProps) {
const client = useQueryClient();
const [riderId, setRiderId] = useState('');
const [outcome, setOutcome] = useState<string | null>(null);
// Every order in a batch shares a region in practice — they are one branch's
// orders — so the first one carrying a region decides which fleet to ask for.
const region =
orders.map((row) => row.applocationid || branchOf(row)?.applocationid || 0).find(Boolean) ?? 0;
const riders = useRiders(region || undefined);
const fleet = riders.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);
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)),
});
const options = fleet.map((entry) => ({
value: String(entry.userid),
label: riderVehicle(entry) ? `${riderName(entry)} · ${riderVehicle(entry)}` : riderName(entry),
}));
return (
<div className="assign-bar" role="region" aria-label="Assign selected orders to a rider">
<span className="assign-bar-count">
<UserCheck size={15} />
{orders.length} selected
</span>
<div className="assign-bar-picker">
<Selector
label="Choose a rider"
isLabelHidden
size="sm"
value={riderId}
onChange={(value) => {
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. */
placeholder={
region === 0
? 'No delivery region on this branch'
: riders.isLoading
? 'Loading riders…'
: options.length > 0
? 'Select a rider…'
: 'No rider has clocked on today'
}
/>
</div>
<Button
label={send.isPending ? 'Assigning…' : 'Assign rider'}
variant="primary"
size="sm"
icon={<UserCheck size={14} />}
isDisabled={!rider || orders.length === 0 || send.isPending}
onClick={() => send.mutate()}
/>
<Button label="Clear" variant="ghost" size="sm" onClick={onClear} />
{outcome ? (
<span className="assign-bar-outcome" role="status">
{outcome}
</span>
) : null}
</div>
);
}