bulk request
This commit is contained in:
@@ -30,6 +30,7 @@ import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { useStockRequests, useStockStatement } from '@/queries/hooks';
|
||||
import { useSelection } from '@/components/useSelection';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { CataloguePanel } from '../CataloguePanel';
|
||||
import { ProductsPanel } from '../ProductsPanel';
|
||||
@@ -206,19 +207,50 @@ function RequestsPanel({
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [acting, setActing] = useState<number | null>(null);
|
||||
const [outcome, setOutcome] = useState<string | null>(null);
|
||||
|
||||
const refreshAfterDecision = async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.stock.all });
|
||||
// Approving moves stock, so anything counting stock is stale too.
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.products.all });
|
||||
};
|
||||
|
||||
const decide = useMutation({
|
||||
mutationFn: ({ requestid, approve }: { requestid: number; approve: boolean }) =>
|
||||
approve ? stockApi.approve(requestid) : stockApi.reject(requestid),
|
||||
onSettled: async () => {
|
||||
setActing(null);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.stock.all });
|
||||
// Approving moves stock, so anything counting stock is stale too.
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.products.all });
|
||||
await refreshAfterDecision();
|
||||
},
|
||||
});
|
||||
|
||||
const pending = requests.filter((entry) => entry.status === 'Pending');
|
||||
const selection = useSelection(pending.map((entry) => entry.requestid));
|
||||
|
||||
/**
|
||||
* The batch decision.
|
||||
*
|
||||
* Reports what actually happened rather than assuming it all worked, because
|
||||
* approving moves stock: if three of ten fail, those seven deliveries have
|
||||
* really been received and the merchant must not approve them again.
|
||||
*/
|
||||
const decideBatch = useMutation({
|
||||
mutationFn: ({ ids, approve }: { ids: number[]; approve: boolean }) =>
|
||||
approve ? stockApi.approveMany(ids) : stockApi.rejectMany(ids),
|
||||
onSuccess: (result, { ids, approve }) => {
|
||||
const done = result?.updated?.length ?? ids.length;
|
||||
const failed = result?.failed?.length ?? 0;
|
||||
const verb = approve ? 'approved' : 'rejected';
|
||||
setOutcome(
|
||||
failed === 0
|
||||
? `${count(done)} ${verb}.`
|
||||
: `${count(done)} ${verb}. ${count(failed)} could not be — they are still waiting.`,
|
||||
);
|
||||
selection.clear();
|
||||
},
|
||||
onError: () => setOutcome('Nothing was changed. Try again, or decide them one at a time.'),
|
||||
onSettled: refreshAfterDecision,
|
||||
});
|
||||
const settled = requests.filter((entry) => entry.status !== 'Pending');
|
||||
|
||||
return (
|
||||
@@ -270,10 +302,85 @@ function RequestsPanel({
|
||||
</Card>
|
||||
) : (
|
||||
<VStack gap={1}>
|
||||
{/* The bulk bar. Present but quiet at rest: a row of checkboxes with
|
||||
no visible action reads as broken, and a permanently loud bar
|
||||
pushes the queue itself down the page. */}
|
||||
<div className="bulkbar" data-active={selection.count > 0 ? 'yes' : 'no'}>
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<label className="bulkbar-all">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selection.allChosen}
|
||||
ref={(el) => {
|
||||
// The third state: some ticked, not all. Only settable
|
||||
// from script, and without it "select all" looks off when
|
||||
// two of nine are chosen.
|
||||
if (el) el.indeterminate = selection.someChosen;
|
||||
}}
|
||||
onChange={selection.toggleAll}
|
||||
aria-label={selection.allChosen ? 'Clear selection' : 'Select every request shown'}
|
||||
/>
|
||||
<Text type="body" size="sm">
|
||||
{selection.count > 0
|
||||
? `${count(selection.count)} selected`
|
||||
: `Select all ${count(pending.length)}`}
|
||||
</Text>
|
||||
</label>
|
||||
|
||||
{selection.count > 0 ? (
|
||||
<HStack gap={1} align="center" wrap="wrap">
|
||||
<Button
|
||||
label="Clear"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
isDisabled={decideBatch.isPending}
|
||||
onClick={selection.clear}
|
||||
/>
|
||||
<Button
|
||||
label={`Reject ${count(selection.count)}`}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<X size={14} />}
|
||||
isDisabled={decideBatch.isPending}
|
||||
onClick={() => decideBatch.mutate({ ids: selection.chosen, approve: false })}
|
||||
/>
|
||||
<Button
|
||||
label={
|
||||
decideBatch.isPending
|
||||
? 'Working…'
|
||||
: `Approve ${count(selection.count)}`
|
||||
}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Check size={14} />}
|
||||
isLoading={decideBatch.isPending}
|
||||
isDisabled={decideBatch.isPending}
|
||||
onClick={() => decideBatch.mutate({ ids: selection.chosen, approve: true })}
|
||||
/>
|
||||
</HStack>
|
||||
) : null}
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
{outcome ? (
|
||||
<Card padding={2} variant="transparent">
|
||||
<Text type="body" size="sm" role="status">
|
||||
{outcome}
|
||||
</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{pending.map((request) => (
|
||||
<Card key={request.requestid} padding={0} elevation="low">
|
||||
<HStack justify="between" align="center" gap={2} style={{ padding: 16 }}>
|
||||
<HStack gap={1.5} align="center" style={{ minWidth: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="bulkbar-tick"
|
||||
checked={selection.has(request.requestid)}
|
||||
onChange={() => selection.toggle(request.requestid)}
|
||||
aria-label={`Select ${request.productname ?? `product ${request.productid}`}`}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
width: 40,
|
||||
|
||||
Reference in New Issue
Block a user