ask again

This commit is contained in:
2026-09-03 11:17:31 +05:30
parent aad72cfe42
commit d39956a2f1
5 changed files with 238 additions and 45 deletions

View File

@@ -22,8 +22,20 @@ import type { StockRequest, StockStatementRow } from './types';
* read back, which is enough to drive a queue but is not a state machine — the
* backend will accept any string at all.
*/
/**
* The ladder a request climbs.
*
* `Approved` sits between asking and arriving, and adding it is the point:
* approving used to put the stock on the shelf immediately, so the count said
* the goods were there from the moment the admin agreed to send them — which is
* days before they arrive, and the branch sells against a shelf that is empty.
*
* Only `Received` moves the ledger. Fiesta keys the stock write on that exact
* word, so `Approved` is a status and nothing else.
*/
export const STOCK_REQUEST_STATUS = {
pending: 'Pending',
approved: 'Approved',
received: 'Received',
rejected: 'Rejected',
} as const;
@@ -92,13 +104,25 @@ export const stockApi = {
}),
/**
* Approve a request — which means marking it Received.
* Agree to send the stock. Nothing reaches the shelf yet.
*
* This adds `request.qty` to the branch's stock. There is no way to approve a
* different amount: the service reads the quantity off the request row, not
* off this call.
* The shelf is written when the branch confirms the goods ARRIVED, not when
* the admin agrees to send them — see `confirmArrival`.
*/
approve: (requestid: number) =>
api.put<unknown>(`${WEB}/products/updatestockrequest`, {
requestid,
status: STOCK_REQUEST_STATUS.approved,
}),
/**
* The goods turned up. THIS is what adds `request.qty` to the branch's stock.
*
* There is no way to receive a different amount: the service reads the
* quantity off the request row, not off this call. A short delivery has to be
* corrected on the stock ledger afterwards.
*/
confirmArrival: (requestid: number) =>
api.put<unknown>(`${WEB}/products/updatestockrequest`, {
requestid,
status: STOCK_REQUEST_STATUS.received,
@@ -119,7 +143,9 @@ export const stockApi = {
decideMany: (requestids: number[], status: StockRequestStatus) =>
api.put<StockBatchOutcome>(`${WEB}/products/updatestockrequest`, { requestids, status }),
approveMany: (requestids: number[]) => stockApi.decideMany(requestids, STOCK_REQUEST_STATUS.received),
approveMany: (requestids: number[]) => stockApi.decideMany(requestids, STOCK_REQUEST_STATUS.approved),
confirmArrivalMany: (requestids: number[]) =>
stockApi.decideMany(requestids, STOCK_REQUEST_STATUS.received),
rejectMany: (requestids: number[]) => stockApi.decideMany(requestids, STOCK_REQUEST_STATUS.rejected),
/**

View File

@@ -216,8 +216,12 @@ function RequestsPanel({
};
const decide = useMutation({
mutationFn: ({ requestid, approve }: { requestid: number; approve: boolean }) =>
approve ? stockApi.approve(requestid) : stockApi.reject(requestid),
mutationFn: ({ requestid, action }: { requestid: number; action: 'approve' | 'reject' | 'arrive' }) =>
action === 'approve'
? stockApi.approve(requestid)
: action === 'arrive'
? stockApi.confirmArrival(requestid)
: stockApi.reject(requestid),
onSettled: async () => {
setActing(null);
await refreshAfterDecision();
@@ -225,7 +229,17 @@ function RequestsPanel({
});
const pending = requests.filter((entry) => entry.status === 'Pending');
/**
* Agreed to, not yet arrived.
*
* The stage that did not exist: approving wrote the stock onto the shelf
* straight away, so the count claimed the goods were there from the moment
* the admin agreed to send them — days before the van turns up, and the
* branch sells against a shelf that is empty.
*/
const inTransit = requests.filter((entry) => entry.status === 'Approved');
const selection = useSelection(pending.map((entry) => entry.requestid));
const arriving = useSelection(inTransit.map((entry) => entry.requestid));
/**
* The batch decision.
@@ -235,18 +249,24 @@ function RequestsPanel({
* 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 }) => {
mutationFn: ({ ids, action }: { ids: number[]; action: 'approve' | 'reject' | 'arrive' }) =>
action === 'approve'
? stockApi.approveMany(ids)
: action === 'arrive'
? stockApi.confirmArrivalMany(ids)
: stockApi.rejectMany(ids),
onSuccess: (result, { ids, action }) => {
const done = result?.updated?.length ?? ids.length;
const failed = result?.failed?.length ?? 0;
const verb = approve ? 'approved' : 'rejected';
const verb =
action === 'approve' ? 'approved' : action === 'arrive' ? 'added to the shelf' : 'rejected';
setOutcome(
failed === 0
? `${count(done)} ${verb}.`
: `${count(done)} ${verb}. ${count(failed)} could not be — they are still waiting.`,
);
selection.clear();
arriving.clear();
},
onError: () => setOutcome('Nothing was changed. Try again, or decide them one at a time.'),
onSettled: refreshAfterDecision,
@@ -342,7 +362,7 @@ function RequestsPanel({
size="sm"
icon={<X size={14} />}
isDisabled={decideBatch.isPending}
onClick={() => decideBatch.mutate({ ids: selection.chosen, approve: false })}
onClick={() => decideBatch.mutate({ ids: selection.chosen, action: 'reject' })}
/>
<Button
label={
@@ -355,7 +375,7 @@ function RequestsPanel({
icon={<Check size={14} />}
isLoading={decideBatch.isPending}
isDisabled={decideBatch.isPending}
onClick={() => decideBatch.mutate({ ids: selection.chosen, approve: true })}
onClick={() => decideBatch.mutate({ ids: selection.chosen, action: 'approve' })}
/>
</HStack>
) : null}
@@ -424,7 +444,7 @@ function RequestsPanel({
isDisabled={decide.isPending && acting === request.requestid}
onClick={() => {
setActing(request.requestid);
decide.mutate({ requestid: request.requestid, approve: false });
decide.mutate({ requestid: request.requestid, action: 'reject' });
}}
/>
<Button
@@ -435,14 +455,100 @@ function RequestsPanel({
isDisabled={decide.isPending && acting === request.requestid}
onClick={() => {
setActing(request.requestid);
decide.mutate({ requestid: request.requestid, approve: true });
decide.mutate({ requestid: request.requestid, action: 'approve' });
}}
/>
</HStack>
</HStack>
</Card>
))}
</VStack>
{/* ── Agreed, not yet arrived ─────────────────────────────────────────
The stage between deciding and stocking. Nothing here has touched the
shelf: the ledger is written when the goods turn up, so the count
never claims stock that is still on a van. */}
{inTransit.length > 0 ? (
<VStack gap={1}>
<SectionHeader
title="On their way"
note="nothing reaches a shelf until arrival is confirmed"
/>
<div className="bulkbar" data-active={arriving.count > 0 ? 'yes' : 'no'}>
<HStack justify="between" align="center" gap={2} wrap="wrap">
<label className="bulkbar-all">
<input
type="checkbox"
checked={arriving.allChosen}
ref={(el) => {
if (el) el.indeterminate = arriving.someChosen;
}}
onChange={arriving.toggleAll}
aria-label={arriving.allChosen ? 'Clear selection' : 'Select everything on its way'}
/>
<Text type="body" size="sm">
{arriving.count > 0
? `${count(arriving.count)} selected`
: `Select all ${count(inTransit.length)}`}
</Text>
</label>
{arriving.count > 0 ? (
<HStack gap={1} align="center" wrap="wrap">
<Button label="Clear" variant="ghost" size="sm" isDisabled={decideBatch.isPending} onClick={arriving.clear} />
<Button
label={decideBatch.isPending ? 'Working…' : `Confirm arrival of ${count(arriving.count)}`}
variant="primary"
size="sm"
icon={<CheckCheck size={14} />}
isLoading={decideBatch.isPending}
isDisabled={decideBatch.isPending}
onClick={() => decideBatch.mutate({ ids: arriving.chosen, action: 'arrive' })}
/>
</HStack>
) : null}
</HStack>
</div>
{inTransit.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={arriving.has(request.requestid)}
onChange={() => arriving.toggle(request.requestid)}
aria-label={`Select ${request.productname ?? `product ${request.productid}`}`}
/>
<VStack gap={0} style={{ minWidth: 0 }}>
<Text type="label" size="sm" weight="semibold" maxLines={1}>
{request.productname ?? `Product ${request.productid}`}
</Text>
<Text type="body" size="xsm" color="secondary">
{branchLabel(request.locationname) || `Branch ${request.locationid}`} · <strong>{count(request.qty)}</strong> units approved
</Text>
</VStack>
</HStack>
<Button
label={`Confirm arrival of ${count(request.qty)}`}
variant="primary"
size="sm"
icon={<CheckCheck size={14} />}
isDisabled={decide.isPending && acting === request.requestid}
onClick={() => {
setActing(request.requestid);
decide.mutate({ requestid: request.requestid, action: 'arrive' });
}}
/>
</HStack>
</Card>
))}
</VStack>
) : null}
</VStack>
)}
{/* Stated once, under the queue, rather than on every row. */}

View File

@@ -30,7 +30,7 @@ import {
latestByProduct,
REQUEST_COLOR,
REQUEST_LABEL,
requestState,
canAskAgain, requestState,
stockHealth,
} from '../requestState';
import { Chip, TabButton, Td, Th, Thumb } from '../ui';
@@ -252,14 +252,19 @@ function CataloguePanel({
onAskMany: (products: Product[]) => void;
}) {
/**
* Only rows with no open request can be ticked.
* Rows with no OPEN request can be ticked — including ones asked for before.
*
* Asking twice for the same product gives the admin two rows to decide and
* receives the stock twice if they approve both. The single-row button is
* already disabled in that state; the tick follows the same rule.
* Asking twice while a request is live gives the admin the same decision
* twice and receives the delivery twice if they approve both. A SETTLED
* request is history though, and re-asking is ordinary: last week’s stock has
* sold, or the admin said no and the branch wants to ask again. The tick now
* follows exactly the same rule as the row’s own "Ask again" button.
*/
const selectableIds = useMemo(
() => rows.filter((product) => !latest.get(product.productid)).map((product) => product.productid),
() =>
rows
.filter((product) => canAskAgain(latest.get(product.productid)))
.map((product) => product.productid),
[rows, latest],
);
const selection = useSelection(selectableIds);
@@ -342,17 +347,19 @@ function CataloguePanel({
}}
>
<colgroup>
<col style={{ width: '30%' }} />
<col style={{ width: '17%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '23%' }} />
<col style={{ width: '38%' }} />
<col style={{ width: '11%' }} />
<col style={{ width: '11%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '28%' }} />
</colgroup>
<thead>
<tr>
<Th>Product</Th>
<Th>Category</Th>
{/* No Category column. It repeated the filter directly above
the table and cost 17% of the width that Product and Stock
request both wanted — the filter is how a shop narrows by
category; the column only restated the answer. */}
<Th>Unit</Th>
<Th>Price</Th>
<Th>On shelf</Th>
@@ -366,12 +373,12 @@ function CataloguePanel({
product={product}
request={latest.get(product.productid)}
onOpen={() => onOpen(product)}
{...(latest.get(product.productid)
? {}
: {
{...(canAskAgain(latest.get(product.productid))
? {
isSelected: selection.has(product.productid),
onSelect: () => selection.toggle(product.productid),
})}
}
: {})}
onAsk={(qty) => onAsk(product, qty)}
/>
))}
@@ -460,18 +467,6 @@ function CatalogueRow({
</VStack>
</HStack>
</Td>
<Td isMuted>
<span
style={{
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{product.categoryname || product.Subcategoryname || '—'}
</span>
</Td>
<Td isMuted>{unit || '—'}</Td>
<Td isStrong>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{money(effectivePrice(product))}</span>

View File

@@ -0,0 +1,46 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import type { StockRequest } from '@/api/types';
import { canAskAgain } from './requestState';
const at = (status: string) => ({ status }) as StockRequest;
/*
Which products a branch may include in a bulk request.
The rule follows the single "Ask again" button that was already on the row: a
settled request is history, and asking again is ordinary. Gating on "has ever
been requested" meant a shop that once asked for rice could never bulk-request
it again, which is exactly the products a restock is about.
*/
test('a product never requested can be asked for', () => {
assert.equal(canAskAgain(undefined), true);
});
test('a settled request does not block asking again', () => {
// Last week's delivery has been sold; the admin said no and the branch wants
// to make the case again.
for (const status of ['Received', 'Rejected', 'Cancelled']) {
assert.equal(canAskAgain(at(status)), true, status);
}
});
test('an open request does block asking again', () => {
// Two live asks give the admin the same decision twice, and approving both
// receives the delivery twice.
for (const status of ['Pending', 'Approved']) {
assert.equal(canAskAgain(at(status)), false, status);
}
});
test('status is read regardless of case or padding', () => {
assert.equal(canAskAgain(at(' received ')), true);
assert.equal(canAskAgain(at('PENDING')), false);
});
test('an unrecognised status is treated as still open', () => {
// Safer to withhold the tick than to let a shop double-ask against a state
// this build does not understand.
assert.equal(canAskAgain(at('AwaitingDispatch')), false);
});

View File

@@ -91,3 +91,23 @@ export function hasMoved(row: {
(row.closing ?? 0) !== 0
);
}
/**
* Whether a product can be asked for again.
*
* A request is only "open" while somebody still has to act on it: waiting to be
* decided, or agreed and not yet arrived. Once it is received, rejected or
* cancelled it is history, and asking again is an ordinary thing a shop does —
* last week's delivery has been sold, or the admin said no and the branch wants
* to make the case a second time.
*
* Used to gate the tick box, so the bulk ask covers exactly the products the
* single "Ask again" button already offers. Treating any past request as a
* block was the bug: a shop that had ever asked for a product could never
* include it in a bulk request again.
*/
export function canAskAgain(request: StockRequest | undefined): boolean {
const state = requestState(request);
if (!state) return true;
return state !== 'pending' && state !== 'approved';
}