ask again
This commit is contained in:
@@ -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),
|
||||
|
||||
/**
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
46
src/features/store-user/requestState.test.ts
Normal file
46
src/features/store-user/requestState.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user