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
|
* read back, which is enough to drive a queue but is not a state machine — the
|
||||||
* backend will accept any string at all.
|
* 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 = {
|
export const STOCK_REQUEST_STATUS = {
|
||||||
pending: 'Pending',
|
pending: 'Pending',
|
||||||
|
approved: 'Approved',
|
||||||
received: 'Received',
|
received: 'Received',
|
||||||
rejected: 'Rejected',
|
rejected: 'Rejected',
|
||||||
} as const;
|
} 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
|
* The shelf is written when the branch confirms the goods ARRIVED, not when
|
||||||
* different amount: the service reads the quantity off the request row, not
|
* the admin agrees to send them — see `confirmArrival`.
|
||||||
* off this call.
|
|
||||||
*/
|
*/
|
||||||
approve: (requestid: number) =>
|
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`, {
|
api.put<unknown>(`${WEB}/products/updatestockrequest`, {
|
||||||
requestid,
|
requestid,
|
||||||
status: STOCK_REQUEST_STATUS.received,
|
status: STOCK_REQUEST_STATUS.received,
|
||||||
@@ -119,7 +143,9 @@ export const stockApi = {
|
|||||||
decideMany: (requestids: number[], status: StockRequestStatus) =>
|
decideMany: (requestids: number[], status: StockRequestStatus) =>
|
||||||
api.put<StockBatchOutcome>(`${WEB}/products/updatestockrequest`, { requestids, status }),
|
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),
|
rejectMany: (requestids: number[]) => stockApi.decideMany(requestids, STOCK_REQUEST_STATUS.rejected),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -216,8 +216,12 @@ function RequestsPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const decide = useMutation({
|
const decide = useMutation({
|
||||||
mutationFn: ({ requestid, approve }: { requestid: number; approve: boolean }) =>
|
mutationFn: ({ requestid, action }: { requestid: number; action: 'approve' | 'reject' | 'arrive' }) =>
|
||||||
approve ? stockApi.approve(requestid) : stockApi.reject(requestid),
|
action === 'approve'
|
||||||
|
? stockApi.approve(requestid)
|
||||||
|
: action === 'arrive'
|
||||||
|
? stockApi.confirmArrival(requestid)
|
||||||
|
: stockApi.reject(requestid),
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
setActing(null);
|
setActing(null);
|
||||||
await refreshAfterDecision();
|
await refreshAfterDecision();
|
||||||
@@ -225,7 +229,17 @@ function RequestsPanel({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const pending = requests.filter((entry) => entry.status === 'Pending');
|
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 selection = useSelection(pending.map((entry) => entry.requestid));
|
||||||
|
const arriving = useSelection(inTransit.map((entry) => entry.requestid));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The batch decision.
|
* The batch decision.
|
||||||
@@ -235,18 +249,24 @@ function RequestsPanel({
|
|||||||
* really been received and the merchant must not approve them again.
|
* really been received and the merchant must not approve them again.
|
||||||
*/
|
*/
|
||||||
const decideBatch = useMutation({
|
const decideBatch = useMutation({
|
||||||
mutationFn: ({ ids, approve }: { ids: number[]; approve: boolean }) =>
|
mutationFn: ({ ids, action }: { ids: number[]; action: 'approve' | 'reject' | 'arrive' }) =>
|
||||||
approve ? stockApi.approveMany(ids) : stockApi.rejectMany(ids),
|
action === 'approve'
|
||||||
onSuccess: (result, { ids, approve }) => {
|
? stockApi.approveMany(ids)
|
||||||
|
: action === 'arrive'
|
||||||
|
? stockApi.confirmArrivalMany(ids)
|
||||||
|
: stockApi.rejectMany(ids),
|
||||||
|
onSuccess: (result, { ids, action }) => {
|
||||||
const done = result?.updated?.length ?? ids.length;
|
const done = result?.updated?.length ?? ids.length;
|
||||||
const failed = result?.failed?.length ?? 0;
|
const failed = result?.failed?.length ?? 0;
|
||||||
const verb = approve ? 'approved' : 'rejected';
|
const verb =
|
||||||
|
action === 'approve' ? 'approved' : action === 'arrive' ? 'added to the shelf' : 'rejected';
|
||||||
setOutcome(
|
setOutcome(
|
||||||
failed === 0
|
failed === 0
|
||||||
? `${count(done)} ${verb}.`
|
? `${count(done)} ${verb}.`
|
||||||
: `${count(done)} ${verb}. ${count(failed)} could not be — they are still waiting.`,
|
: `${count(done)} ${verb}. ${count(failed)} could not be — they are still waiting.`,
|
||||||
);
|
);
|
||||||
selection.clear();
|
selection.clear();
|
||||||
|
arriving.clear();
|
||||||
},
|
},
|
||||||
onError: () => setOutcome('Nothing was changed. Try again, or decide them one at a time.'),
|
onError: () => setOutcome('Nothing was changed. Try again, or decide them one at a time.'),
|
||||||
onSettled: refreshAfterDecision,
|
onSettled: refreshAfterDecision,
|
||||||
@@ -342,7 +362,7 @@ function RequestsPanel({
|
|||||||
size="sm"
|
size="sm"
|
||||||
icon={<X size={14} />}
|
icon={<X size={14} />}
|
||||||
isDisabled={decideBatch.isPending}
|
isDisabled={decideBatch.isPending}
|
||||||
onClick={() => decideBatch.mutate({ ids: selection.chosen, approve: false })}
|
onClick={() => decideBatch.mutate({ ids: selection.chosen, action: 'reject' })}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
label={
|
label={
|
||||||
@@ -355,7 +375,7 @@ function RequestsPanel({
|
|||||||
icon={<Check size={14} />}
|
icon={<Check size={14} />}
|
||||||
isLoading={decideBatch.isPending}
|
isLoading={decideBatch.isPending}
|
||||||
isDisabled={decideBatch.isPending}
|
isDisabled={decideBatch.isPending}
|
||||||
onClick={() => decideBatch.mutate({ ids: selection.chosen, approve: true })}
|
onClick={() => decideBatch.mutate({ ids: selection.chosen, action: 'approve' })}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -424,7 +444,7 @@ function RequestsPanel({
|
|||||||
isDisabled={decide.isPending && acting === request.requestid}
|
isDisabled={decide.isPending && acting === request.requestid}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActing(request.requestid);
|
setActing(request.requestid);
|
||||||
decide.mutate({ requestid: request.requestid, approve: false });
|
decide.mutate({ requestid: request.requestid, action: 'reject' });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@@ -435,14 +455,100 @@ function RequestsPanel({
|
|||||||
isDisabled={decide.isPending && acting === request.requestid}
|
isDisabled={decide.isPending && acting === request.requestid}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActing(request.requestid);
|
setActing(request.requestid);
|
||||||
decide.mutate({ requestid: request.requestid, approve: true });
|
decide.mutate({ requestid: request.requestid, action: 'approve' });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Card>
|
</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. */}
|
{/* Stated once, under the queue, rather than on every row. */}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
latestByProduct,
|
latestByProduct,
|
||||||
REQUEST_COLOR,
|
REQUEST_COLOR,
|
||||||
REQUEST_LABEL,
|
REQUEST_LABEL,
|
||||||
requestState,
|
canAskAgain, requestState,
|
||||||
stockHealth,
|
stockHealth,
|
||||||
} from '../requestState';
|
} from '../requestState';
|
||||||
import { Chip, TabButton, Td, Th, Thumb } from '../ui';
|
import { Chip, TabButton, Td, Th, Thumb } from '../ui';
|
||||||
@@ -252,14 +252,19 @@ function CataloguePanel({
|
|||||||
onAskMany: (products: Product[]) => void;
|
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
|
* Asking twice while a request is live gives the admin the same decision
|
||||||
* receives the stock twice if they approve both. The single-row button is
|
* twice and receives the delivery twice if they approve both. A SETTLED
|
||||||
* already disabled in that state; the tick follows the same rule.
|
* 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(
|
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],
|
[rows, latest],
|
||||||
);
|
);
|
||||||
const selection = useSelection(selectableIds);
|
const selection = useSelection(selectableIds);
|
||||||
@@ -342,17 +347,19 @@ function CataloguePanel({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col style={{ width: '30%' }} />
|
<col style={{ width: '38%' }} />
|
||||||
<col style={{ width: '17%' }} />
|
<col style={{ width: '11%' }} />
|
||||||
<col style={{ width: '10%' }} />
|
<col style={{ width: '11%' }} />
|
||||||
<col style={{ width: '10%' }} />
|
<col style={{ width: '12%' }} />
|
||||||
<col style={{ width: '10%' }} />
|
<col style={{ width: '28%' }} />
|
||||||
<col style={{ width: '23%' }} />
|
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<Th>Product</Th>
|
<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>Unit</Th>
|
||||||
<Th>Price</Th>
|
<Th>Price</Th>
|
||||||
<Th>On shelf</Th>
|
<Th>On shelf</Th>
|
||||||
@@ -366,12 +373,12 @@ function CataloguePanel({
|
|||||||
product={product}
|
product={product}
|
||||||
request={latest.get(product.productid)}
|
request={latest.get(product.productid)}
|
||||||
onOpen={() => onOpen(product)}
|
onOpen={() => onOpen(product)}
|
||||||
{...(latest.get(product.productid)
|
{...(canAskAgain(latest.get(product.productid))
|
||||||
? {}
|
? {
|
||||||
: {
|
|
||||||
isSelected: selection.has(product.productid),
|
isSelected: selection.has(product.productid),
|
||||||
onSelect: () => selection.toggle(product.productid),
|
onSelect: () => selection.toggle(product.productid),
|
||||||
})}
|
}
|
||||||
|
: {})}
|
||||||
onAsk={(qty) => onAsk(product, qty)}
|
onAsk={(qty) => onAsk(product, qty)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -460,18 +467,6 @@ function CatalogueRow({
|
|||||||
</VStack>
|
</VStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Td>
|
</Td>
|
||||||
<Td isMuted>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: 'block',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{product.categoryname || product.Subcategoryname || '—'}
|
|
||||||
</span>
|
|
||||||
</Td>
|
|
||||||
<Td isMuted>{unit || '—'}</Td>
|
<Td isMuted>{unit || '—'}</Td>
|
||||||
<Td isStrong>
|
<Td isStrong>
|
||||||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{money(effectivePrice(product))}</span>
|
<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
|
(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