bulk release

This commit is contained in:
2026-09-08 12:59:17 +05:30
parent 12269deeda
commit 024fc4adfd
4 changed files with 552 additions and 323 deletions

View File

@@ -14,6 +14,7 @@ import { count, money } from './format';
import { ProductDrawer } from './ProductDrawer'; import { ProductDrawer } from './ProductDrawer';
import { PublishDialog } from './PublishDialog'; import { PublishDialog } from './PublishDialog';
import { SheetUploadDrawer } from './SheetUploadDrawer'; import { SheetUploadDrawer } from './SheetUploadDrawer';
import { useSelection } from '@/components/useSelection';
import { TablePager } from '@/components/TablePager'; import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged'; import { usePaged } from '@/components/usePaged';
import { import {
@@ -108,6 +109,16 @@ export function ProductsPanel({
}); });
}, [products.data, category]); }, [products.data, category]);
/**
* Every product listed can be ticked, not only the unpriced ones.
*
* Releasing is the obvious bulk job, but re-pricing a shelf is the other one
* — a supplier raises a rate and eleven products move together — and the same
* drawer does both. Restricting the ticks to "Not ready" would have made the
* second impossible for no gain.
*/
const selection = useSelection(rows.map((product) => product.productid));
const categoryOptions = useMemo( const categoryOptions = useMemo(
() => [ () => [
{ value: '', label: 'All aisles' }, { value: '', label: 'All aisles' },
@@ -141,9 +152,49 @@ export function ProductsPanel({
} }
/> />
{rows.length > 0 ? (
<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) => {
if (el) el.indeterminate = selection.someChosen;
}}
onChange={selection.toggleAll}
aria-label={
selection.allChosen ? 'Clear selection' : 'Select every product shown'
}
/>
<Text type="body" size="sm">
{selection.count > 0
? `${selection.count} selected`
: `Select all ${rows.length}`}
</Text>
</label>
{selection.count > 0 ? (
<HStack gap={1} align="center" wrap="wrap">
<Button label="Clear" variant="ghost" size="sm" onClick={selection.clear} />
<Button
label={`Price and release ${selection.count}`}
variant="primary"
size="sm"
onClick={() =>
setPublishing(rows.filter((product) => selection.has(product.productid)))
}
/>
</HStack>
) : null}
</HStack>
</div>
) : null}
<ProductTable <ProductTable
rows={rows} rows={rows}
aisleNames={aisleNames} aisleNames={aisleNames}
selection={selection}
queryKey={category} queryKey={category}
isLoading={products.isLoading} isLoading={products.isLoading}
onOpen={setOpen} onOpen={setOpen}
@@ -168,7 +219,13 @@ export function ProductsPanel({
products={publishing} products={publishing}
tenantid={tenantid} tenantid={tenantid}
branchCount={scoped.length} branchCount={scoped.length}
onClose={() => setPublishing(null)} onClose={() => {
setPublishing(null);
// The work is done, so the ticks go with it — leaving eleven rows
// selected after releasing them invites a second release of the
// same eleven.
selection.clear();
}}
/> />
) : null} ) : null}
</VStack> </VStack>
@@ -180,6 +237,7 @@ export function ProductsPanel({
function ProductTable({ function ProductTable({
rows, rows,
aisleNames, aisleNames,
selection,
isLoading, isLoading,
onOpen, onOpen,
onPrice, onPrice,
@@ -188,6 +246,8 @@ function ProductTable({
rows: Product[]; rows: Product[];
/** Subcategory id to the aisle name the customer app shows it under. */ /** Subcategory id to the aisle name the customer app shows it under. */
aisleNames: ReadonlyMap<number, string>; aisleNames: ReadonlyMap<number, string>;
/** The tick state, owned by the panel so its bar can act on the choice. */
selection: ReturnType<typeof useSelection>;
isLoading: boolean; isLoading: boolean;
onOpen: (product: Product) => void; onOpen: (product: Product) => void;
onPrice: (product: Product) => void; onPrice: (product: Product) => void;
@@ -250,7 +310,8 @@ function ProductTable({
}} }}
> >
<colgroup> <colgroup>
<col style={{ width: '28%' }} /> <col style={{ width: '4%' }} />
<col style={{ width: '24%' }} />
<col style={{ width: '17%' }} /> <col style={{ width: '17%' }} />
<col style={{ width: '9.5%' }} /> <col style={{ width: '9.5%' }} />
<col style={{ width: '9.5%' }} /> <col style={{ width: '9.5%' }} />
@@ -260,6 +321,7 @@ function ProductTable({
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<Th />
<Th>Product</Th> <Th>Product</Th>
<Th>Category</Th> <Th>Category</Th>
<Th>Unit</Th> <Th>Unit</Th>
@@ -275,6 +337,8 @@ function ProductTable({
key={product.productid} key={product.productid}
product={product} product={product}
aisleNames={aisleNames} aisleNames={aisleNames}
isSelected={selection.has(product.productid)}
onSelect={() => selection.toggle(product.productid)}
onOpen={() => onOpen(product)} onOpen={() => onOpen(product)}
onPrice={() => onPrice(product)} onPrice={() => onPrice(product)}
/> />
@@ -301,11 +365,15 @@ function ProductTable({
function Row({ function Row({
product, product,
aisleNames, aisleNames,
isSelected,
onSelect,
onOpen, onOpen,
onPrice, onPrice,
}: { }: {
product: Product; product: Product;
aisleNames: ReadonlyMap<number, string>; aisleNames: ReadonlyMap<number, string>;
isSelected: boolean;
onSelect: () => void;
onOpen: () => void; onOpen: () => void;
onPrice: () => void; onPrice: () => void;
}) { }) {
@@ -331,10 +399,26 @@ function Row({
}} }}
style={{ style={{
cursor: 'pointer', cursor: 'pointer',
background: isHovered ? 'var(--color-surface-sunken)' : 'transparent', background: isSelected
? 'var(--color-brand-tint)'
: isHovered
? 'var(--color-surface-sunken)'
: 'transparent',
transition: 'background .15s', transition: 'background .15s',
}} }}
> >
{/* The tick stops the click from reaching the row, or choosing a product
would also open its drawer over the list being worked through. */}
<Td>
<input
type="checkbox"
className="bulkbar-tick"
checked={isSelected}
onClick={(event) => event.stopPropagation()}
onChange={onSelect}
aria-label={`Select ${product.productname ?? 'product'}`}
/>
</Td>
<Td> <Td>
<HStack gap={1.5} align="center"> <HStack gap={1.5} align="center">
<Thumb images={imagesOf(product)} alt={product.productname ?? ''} /> <Thumb images={imagesOf(product)} alt={product.productname ?? ''} />

View File

@@ -1,22 +1,25 @@
import { useEffect, useState } from 'react'; import { useMemo, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput'; import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack'; import { AlertTriangle, Check, IndianRupee } from 'lucide-react';
import { AlertTriangle, Check } from 'lucide-react';
import { productsApi } from '@/api/products'; import { productsApi } from '@/api/products';
import type { Product } from '@/api/types'; import type { Product } from '@/api/types';
import { queryKeys } from '@/queries/keys'; import { queryKeys } from '@/queries/keys';
import { effectivePrice } from './productState'; import { Drawer } from './Drawer';
import { needingPrice, planPublish } from './publishPlan'; import { Badge, DrawerButton, DrawerCard, Note, Row, Section } from './drawerKit';
import {
planPublishEach,
priceFieldFor,
taxFieldFor,
unpricedIn,
type TypedPrice,
} from './publishPlan';
/** /**
* Set a price and release to the shops. * Set a price and release to the shops.
* *
* Two things about this are the backend's shape, not a choice, and both are * Three things about this are the backend's shape, not a choice, and all three
* stated on screen rather than hidden: * are stated on screen rather than hidden:
* *
* 1. **It is all-or-nothing across branches.** `PublishProduct` reads the * 1. **It is all-or-nothing across branches.** `PublishProduct` reads the
* tenant's active outlets server-side precisely so a caller cannot narrow * tenant's active outlets server-side precisely so a caller cannot narrow
@@ -27,6 +30,21 @@ import { needingPrice, planPublish } from './publishPlan';
* productid and runs its own transaction. Publishing 500 products is 500 * productid and runs its own transaction. Publishing 500 products is 500
* calls. So the loop is here, with real progress and a per-row result, * calls. So the loop is here, with real progress and a per-row result,
* rather than a spinner that pretends one call is happening. * rather than a spinner that pretends one call is happening.
*
* 3. **Nothing goes out without a price.** The backend refuses `price <= 0`
* ("a product with no price cannot be sold at a till"), and a product that
* slipped through at ₹0 could be ordered for nothing. So the release button
* stays disabled while any selected row is empty, and the rows that are
* empty say so — rather than releasing what it can and reporting the rest as
* failures afterwards, which leaves the merchant to work out which.
*
* ── Why a drawer and not a modal ────────────────────────────────────────────
*
* It was a centred modal, which is the wrong shape for a list: pricing eleven
* products means eleven fields beside the eleven names, and a box in the middle
* of the screen hides the table those names came from. The drawer is the
* console's one panel shell — sticky header, scrolling body, fixed action bar —
* so the button stays put however long the list runs.
*/ */
export interface PublishDialogProps { export interface PublishDialogProps {
products: Product[]; products: Product[];
@@ -42,65 +60,55 @@ export function PublishDialog({ products, tenantid, branchCount, onClose }: Publ
const isBulk = products.length > 1; const isBulk = products.length > 1;
const first = products[0]; const first = products[0];
const [price, setPrice] = useState(() => { /**
const existing = first ? effectivePrice(first) : 0; * One row per product, prefilled with that product's own figures.
return existing > 0 ? String(existing) : ''; *
}); * Seeded once from the selection: re-seeding on every render would throw away
const [tax, setTax] = useState(() => String(first?.taxpercent ?? 0)); * what is being typed, and re-seeding when the selection changes cannot
* happen because the drawer is opened with a selection and closed before the
* next one.
*/
const [rows, setRows] = useState<Map<number, TypedPrice>>(
() =>
new Map(
products.map((product) => [
product.productid,
{ price: priceFieldFor(product), taxpercent: taxFieldFor(product) },
]),
),
);
const [fill, setFill] = useState('');
const [done, setDone] = useState(0); const [done, setDone] = useState(0);
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const [results, setResults] = useState<Outcome[] | null>(null); const [results, setResults] = useState<Outcome[] | null>(null);
useEffect(() => { function setRow(productid: number, patch: Partial<TypedPrice>) {
const onKey = (event: KeyboardEvent) => { setRows((current) => {
if (event.key === 'Escape' && !running) onClose(); const next = new Map(current);
}; const row = next.get(productid) ?? { price: '', taxpercent: '' };
window.addEventListener('keydown', onKey); next.set(productid, { ...row, ...patch });
return () => window.removeEventListener('keydown', onKey); return next;
}, [onClose, running]); });
}
/** /** Fills only the EMPTY price fields — it never overwrites a real price. */
* The products that still need a price typed in. function applyToEmpty() {
* if (fill.trim() === '') return;
* Usually none. A spreadsheet import carries a price on every row, so asking setRows((current) => {
* for one again is asking a question that has already been answered — and it const next = new Map(current);
* was doing more than annoy: the typed figure was applied to every product in for (const product of products) {
* the selection. const row = next.get(product.productid);
*/ if (!row || row.price.trim() === '') {
const needPrice = needingPrice(products); next.set(product.productid, { price: fill, taxpercent: row?.taxpercent ?? '' });
/** }
* Whether to show the price field. }
* return next;
* Always for ONE product, even when it already has a price — that is the });
* "Change price" case, and a dialog that hides the field because the value setFill('');
* exists is a dialog that cannot change it. That was the bug: the field was }
* shown only when something LACKED a price, so a priced product opened a form
* with nothing to edit and re-published the figure it already had.
*
* For a batch, only when something lacks one: a spreadsheet import carries a
* price on every row, and asking again is asking a question already answered.
*/
const askForPrice = needPrice.length > 0 || !isBulk;
const priceValue = Number(price); const blocked = useMemo(() => unpricedIn(products, rows), [products, rows]);
// The backend refuses `price <= 0` with "set a price before publishing — a const canSubmit = !running && blocked.length === 0;
// product with no price cannot be sold at a till". Refusing here too means
// the merchant is told before the round trip, not after it.
const priceError =
price.trim() === ''
? null
: !Number.isFinite(priceValue) || priceValue <= 0
? 'A price above zero is required — a till cannot ring up a product at ₹0.'
: null;
const taxValue = Number(tax);
const taxError =
!Number.isFinite(taxValue) || taxValue < 0 ? 'Tax cannot be negative.' : null;
const canSubmit =
!running &&
// A price is required only when something in the selection lacks one.
(!askForPrice || price.trim() !== '') &&
priceError === null &&
taxError === null;
async function run() { async function run() {
setRunning(true); setRunning(true);
@@ -111,18 +119,9 @@ export function PublishDialog({ products, tenantid, branchCount, onClose }: Publ
// its own transaction with a per-outlet loop inside it; firing hundreds at // its own transaction with a per-outlet loop inside it; firing hundreds at
// a single Go instance to save a few seconds risks a half-published // a single Go instance to save a few seconds risks a half-published
// catalogue, which is worse than a slow one. // catalogue, which is worse than a slow one.
const plan = planPublish(products, { price: priceValue, taxpercent: taxValue }); const plan = planPublishEach(products, rows);
for (const [index, product] of products.entries()) { for (const [index, product] of products.entries()) {
// Each product is released AT ITS OWN PRICE. The field above only fills
// in for one that has none.
//
// Sending the typed figure for every row was a quiet way to destroy a
// catalogue: `publishproduct` writes `products.retailprice` as well as
// the per-outlet price, so releasing seventeen imported products together
// — ₹395 rice, ₹30 crisps, ₹105 cleaner — would have set all seventeen to
// whichever price happened to prefill from the first one, at every
// branch, with no way back to the originals.
const line = plan[index]!; const line = plan[index]!;
try { try {
await productsApi.publish({ await productsApi.publish({
@@ -149,163 +148,219 @@ export function PublishDialog({ products, tenantid, branchCount, onClose }: Publ
const failed = results?.filter((entry) => !entry.ok) ?? []; const failed = results?.filter((entry) => !entry.ok) ?? [];
const footer =
results === null ? (
<>
<DrawerButton label="Cancel" variant="ghost" onClick={onClose} isDisabled={running} />
<DrawerButton
label={
running
? `Releasing ${done} of ${products.length}…`
: isBulk
? `Set prices and release ${products.length}`
: 'Set price and release'
}
variant="primary"
icon={<IndianRupee size={15} />}
isDisabled={!canSubmit}
onClick={() => void run()}
/>
</>
) : (
<>
{failed.length > 0 ? (
<DrawerButton
label="Try the failures again"
variant="ghost"
onClick={() => {
setResults(null);
setDone(0);
}}
/>
) : null}
<DrawerButton label="Done" variant="primary" onClick={onClose} />
</>
);
return ( return (
<> <Drawer
<div title={isBulk ? `Price and release ${products.length} products` : 'Price and release'}
onClick={running ? undefined : onClose} {...(isBulk ? {} : { subtitle: first?.productname ?? '' })}
style={{ position: 'fixed', inset: 0, zIndex: 60, background: 'rgb(16 24 40 / .35)' }} width={560}
/> onClose={running ? () => undefined : onClose}
<div isFooterSpread
role="dialog" footer={footer}
aria-modal >
aria-label={isBulk ? 'Price and release products' : 'Price and release product'} {results === null ? (
style={{ <>
position: 'fixed', {/* The blast radius, before the button rather than after it. */}
zIndex: 61, <Note icon={<AlertTriangle size={15} />}>
left: '50%', {branchCount === 1 ? (
top: '50%', <>
transform: 'translate(-50%, -50%)', This sets the price at <strong>your branch</strong> and puts{' '}
width: 'min(520px, calc(100vw - 32px))', {isBulk ? 'these products' : 'this product'} on sale there. Open a second outlet and
maxHeight: 'calc(100vh - 48px)', the same release reaches it too — publishing is business-wide in this system, never
overflowY: 'auto', per branch.
borderRadius: 16, </>
border: '1px solid var(--color-line)', ) : (
background: 'var(--color-surface)', <>
boxShadow: '0 24px 48px -12px rgb(16 24 40 / .25)', This sets the price at <strong>all {branchCount} branches</strong> and releases{' '}
}} {isBulk ? 'these products' : 'this product'} to every one of them. There is no way
> to price or release a single branch — publishing is a business-wide decision in this
<VStack gap={2} padding={3}> system.
<VStack gap={0.5}> </>
<Text type="label" size="lg" weight="semibold"> )}
{isBulk ? `Price ${products.length} products` : 'Price and release'} </Note>
</Text>
<Text type="body" size="sm" color="secondary">
{/* This line used to read "The same price is set on every product
selected" — which was true, and was the bug. It is now each
product's own, and saying so is what stops someone reaching
for the field to "correct" a price that is already right. */}
{isBulk
? 'Each keeps its own price. Only products without one use the figure below.'
: (first?.productname ?? '')}
</Text>
</VStack>
{results === null ? ( {isBulk && blocked.length > 0 ? (
<> <Note>
{/* Only asked when something in the selection actually lacks a {blocked.length} of {products.length} still {blocked.length === 1 ? 'has' : 'have'} no
price. A sheet import carries one on every row, and asking price. Nothing is released until every one is priced — a product at ₹0 can be ordered
again reads as though the price were missing when it is on for nothing.
screen two lines above. */} </Note>
{askForPrice ? ( ) : null}
<>
<TextInput {/* Filling several empty rows without typing the same figure into
label="Selling price (₹)" each. Deliberately only the EMPTY ones: a control that overwrote
size="md" real prices is the failure this whole screen was rebuilt around. */}
value={price} {isBulk && blocked.length > 1 ? (
onChange={setPrice} <Section title="Fill the empty ones">
placeholder="0.00" <DrawerCard>
{...(priceError ? { error: priceError } : {})} <div style={{ display: 'flex', gap: 10, alignItems: 'end', padding: 14 }}>
<div style={{ flex: 1 }}>
<TextInput
label="Price for every row still empty (₹)"
size="sm"
value={fill}
onChange={setFill}
placeholder="0.00"
/>
</div>
<DrawerButton
label="Apply"
onClick={applyToEmpty}
isDisabled={fill.trim() === ''}
/> />
<TextInput </div>
label="Tax %" </DrawerCard>
size="md" </Section>
value={tax} ) : null}
onChange={setTax}
placeholder="0"
{...(taxError ? { error: taxError } : {})}
/>
{isBulk ? (
<Text type="body" size="sm" color="secondary">
{needPrice.length} of {products.length} have no price yet and will use this
one. The rest keep their own.
</Text>
) : null}
</>
) : (
<Text type="body" size="sm" color="secondary">
{isBulk
? `All ${products.length} already have a price — each is released at its own. Nothing is overwritten.`
: `Released at its own price of ₹${effectivePrice(first as Product)}.`}
</Text>
)}
{/* The blast radius, stated before the button is pressed. */} <Section title={isBulk ? `Prices — ${products.length} selected` : undefined}>
<HStack gap={1.5} align="start"> <DrawerCard>
<span style={{ color: 'var(--color-warning, #b7860b)', flex: 'none', marginTop: 2 }}> {products.map((product) => {
<AlertTriangle size={15} /> const row = rows.get(product.productid) ?? { price: '', taxpercent: '' };
</span> const isBlocked = blocked.some((p) => p.productid === product.productid);
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}> return (
This sets the price at <strong>all {branchCount} branches</strong> and releases <div
the product to every one of them. There is no way to price or release a single key={product.productid}
branch — publishing is a business-wide decision in this system. style={{
</Text> display: 'grid',
</HStack> gridTemplateColumns: 'minmax(0, 1fr) 108px 84px',
gap: 10,
{running ? ( alignItems: 'end',
<Text type="body" size="sm" color="secondary"> padding: '12px 14px',
Publishing {done} of {products.length}… borderTop: '1px solid var(--color-line)',
</Text>
) : null}
<HStack gap={1} justify="end" wrap="wrap">
<Button label="Cancel" variant="ghost" size="sm" onClick={onClose} isDisabled={running} />
<Button
label={running ? `Publishing… ${done}/${products.length}` : 'Set price and release'}
variant="primary"
size="sm"
onClick={() => void run()}
isDisabled={!canSubmit}
/>
</HStack>
</>
) : (
<>
<HStack gap={1} align="center">
<span style={{ color: 'var(--color-success, #10b981)' }}>
<Check size={16} />
</span>
<Text type="body" size="sm">
{results.length - failed.length} of {results.length} released to all{' '}
{branchCount} branches.
</Text>
</HStack>
{failed.length > 0 ? (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
{failed.length} could not be published
</Text>
{failed.map((entry) => (
<Text
key={entry.product.productid}
type="body"
size="xsm"
color="secondary"
>
{entry.product.productname} — {entry.reason}
</Text>
))}
</VStack>
) : null}
<HStack gap={1} justify="end" wrap="wrap">
{failed.length > 0 ? (
<Button
label="Retry the failures"
variant="ghost"
size="sm"
onClick={() => {
setResults(null);
setDone(0);
}} }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
<span
style={{
font: '500 13.5px/1.4 var(--font-sans)',
color: 'var(--color-ink-1)',
overflowWrap: 'anywhere',
}}
>
{product.productname}
</span>
<span
style={{
font: '400 11.5px/1.4 var(--font-mono)',
color: 'var(--color-ink-4)',
}}
>
{product.productsku || `#${product.productid}`}
</span>
{isBlocked ? (
<span
style={{
font: '500 11.5px/1.4 var(--font-sans)',
color: 'var(--color-warning, #b7860b)',
}}
>
Needs a price above zero
</span>
) : null}
</div>
<TextInput
label="Price (₹)"
size="sm"
value={row.price}
onChange={(value) => setRow(product.productid, { price: value })}
placeholder="0.00"
isDisabled={running}
/>
<TextInput
label="Tax %"
size="sm"
value={row.taxpercent}
onChange={(value) => setRow(product.productid, { taxpercent: value })}
placeholder="0"
isDisabled={running}
/>
</div>
);
})}
</DrawerCard>
</Section>
{running ? (
<Note>
Releasing {done} of {products.length}. One call per product — leave this open until it
finishes.
</Note>
) : null}
</>
) : (
<>
<Section>
<DrawerCard>
<Row
label={branchCount === 1 ? 'Released to your branch' : 'Released to all branches'}
value={
<Badge
label={`${results.length - failed.length} of ${results.length}`}
colour={failed.length > 0 ? 'var(--color-warning, #b7860b)' : '#0f8a5f'}
/> />
) : null} }
<Button label="Done" variant="primary" size="sm" onClick={onClose} /> {...(failed.length === 0 ? { tone: 'success' as const } : {})}
</HStack> />
</> <Row label="Branches" value={String(branchCount)} />
</DrawerCard>
</Section>
{failed.length > 0 ? (
<Section title={`${failed.length} could not be released`}>
<DrawerCard>
{failed.map((entry) => (
<Row
key={entry.product.productid}
label={entry.product.productname ?? `#${entry.product.productid}`}
value={entry.reason ?? 'Failed'}
isStacked
/>
))}
</DrawerCard>
</Section>
) : (
<Note icon={<Check size={15} />} tone="brand">
{branchCount === 1
? 'Every one is on sale. Prices can still be changed from this list at any time.'
: `Every one is on sale at all ${branchCount} branches. Prices can still be changed from this list at any time.`}
</Note>
)} )}
</VStack> </>
</div> )}
</> </Drawer>
); );
} }

View File

@@ -4,101 +4,135 @@
* `publishproduct` writes `products.retailprice` as well as the per-outlet * `publishproduct` writes `products.retailprice` as well as the per-outlet
* price, at every branch. So publishing the seventeen products R mart imported * price, at every branch. So publishing the seventeen products R mart imported
* on 2026-08-31 — ₹395 rice, ₹30 crisps, ₹105 cleaner — with the single figure * on 2026-08-31 — ₹395 rice, ₹30 crisps, ₹105 cleaner — with the single figure
* the dialog prefilled from the first row would have set all seventeen to ₹395 * a dialog prefilled from the first row would have set all seventeen to ₹395 and
* and left nothing to restore the real prices from. * left nothing to restore the real prices from.
*
* Each product now has its own field, so these tests pin two things: that a
* figure typed into one row stays in that row, and that nothing is released
* without a price at all.
*/ */
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { test } from 'node:test'; import { test } from 'node:test';
import { needingPrice, planPublish } from './publishPlan'; import {
needingPrice,
planPublishEach,
priceFieldFor,
taxFieldFor,
unpricedIn,
type TypedPrice,
} from './publishPlan';
import type { Product } from '@/api/types'; import type { Product } from '@/api/types';
const product = (id: number, price: number, retail = 0, tax?: number) => const product = (id: number, price: number, retail = 0, tax?: number) =>
({ productid: id, price, retailprice: retail, ...(tax === undefined ? {} : { taxpercent: tax }) }) as Product; ({
productid: id,
price,
retailprice: retail,
...(tax === undefined ? {} : { taxpercent: tax }),
}) as Product;
/** What the drawer holds once every row has been prefilled and none edited. */
const asOpened = (products: Product[]): Map<number, TypedPrice> =>
new Map(
products.map((p) => [p.productid, { price: priceFieldFor(p), taxpercent: taxFieldFor(p) }]),
);
/* ── A typed figure stays in the row it was typed into ────────────────────── */
test('each product is released at its own price', () => { test('each product is released at its own price', () => {
const plan = planPublish( const products = [product(7089, 395), product(7103, 30), product(7104, 105)];
[product(7089, 395), product(7103, 30), product(7104, 105)], const plan = planPublishEach(products, asOpened(products));
{ price: 395, taxpercent: 0 },
);
assert.deepEqual( assert.deepEqual(
plan.map((line) => line.price), plan.map((line) => line.price),
[395, 30, 105], [395, 30, 105],
); );
}); });
// The whole point of the fallback: a product with no price of its own is the // The failure this guards: three products released together, all set to
// only one the typed figure may touch. // whichever figure prefilled from the first.
test('only a product with no price uses the typed figure', () => { test('editing one row does not move the other prices', () => {
const plan = planPublish([product(1, 0), product(2, 250)], { price: 99, taxpercent: 5 }); const products = [product(1, 395), product(2, 30), product(3, 105)];
assert.equal(plan[0]?.price, 99); const typed = asOpened(products);
assert.equal(plan[1]?.price, 250); typed.set(1, { price: '999', taxpercent: '5' });
const plan = planPublishEach(products, typed);
assert.deepEqual(
plan.map((line) => line.price),
[999, 30, 105],
);
}); });
/* // One product is an edit of that product: the merchant is looking at its price,
The per-outlet price and the tenant master are two columns and either can be the // has typed another, and means it. `own > 0 ? own : typed` made that impossible
one that holds the figure — an imported product carries both, a hand-priced one // — a price could be set once and never corrected.
may carry only the master. `effectivePrice` already resolves that, and publish test('a typed price replaces the one the product already had', () => {
has to use the same resolution or it would "fill in" a price that exists. const products = [product(7, 395)];
*/ const typed = new Map([[7, { price: '250', taxpercent: '12' }]]);
test('the master price counts as having a price', () => { const [line] = planPublishEach(products, typed);
// Two products, because this is the BATCH rule: with one, the typed figure
// is an edit of that product and wins by design. The behaviour under test —
// that retailprice counts as "has a price" — is unchanged.
const plan = planPublish([product(1, 0, 175), product(2, 0)], { price: 99, taxpercent: 0 });
assert.equal(plan[0]?.price, 175);
});
test('tax follows the same rule as price', () => {
const plan = planPublish([product(1, 100, 0, 12), product(2, 100)], { price: 0, taxpercent: 5 });
assert.equal(plan[0]?.taxpercent, 12);
assert.equal(plan[1]?.taxpercent, 5);
});
// Zero is a real tax rate and must survive, not fall through to the fallback.
test('a tax rate of zero is kept, not treated as missing', () => {
// Zero-rated goods are real — unpackaged staples are 0% GST — so 0 must not
// be read as "no tax set". Batch context, as above.
const plan = planPublish([product(1, 100, 0, 0), product(2, 100)], { price: 0, taxpercent: 18 });
assert.equal(plan[0]?.taxpercent, 0);
});
test('only the unpriced products are counted as needing one', () => {
assert.equal(needingPrice([product(1, 395), product(2, 0), product(3, 0, 88)]).length, 1);
});
/*
Changing a price that already exists.
`own > 0 ? own : typed` protected a bulk release — seventeen imported products
must not all take one typed figure — but it also made "Change price" impossible:
the dialog sent the product's existing price straight back, so a price could be
set once and never corrected.
One product is an edit of that product. Several is a release of a batch. The two
need opposite defaults, and these tests pin both.
*/
test('one product takes the typed price, even when it already has one', () => {
const [line] = planPublish([product(7, 395)], { price: 250, taxpercent: 5 });
assert.equal(line?.price, 250); assert.equal(line?.price, 250);
});
test('one product takes the typed tax too', () => {
const [line] = planPublish([product(7, 395)], { price: 250, taxpercent: 12 });
assert.equal(line?.taxpercent, 12); assert.equal(line?.taxpercent, 12);
}); });
test('a batch still protects the prices it already has', () => { /* ── Prefill: what a row starts at ────────────────────────────────────────── */
// The failure this guards: ₹395 rice, ₹30 crisps and ₹105 cleaner released
// together, all set to whichever figure prefilled from the first. // The per-outlet price and the tenant master are two columns and either can hold
const lines = planPublish( // the figure — an imported product carries both, a hand-priced one may carry
[product(1, 395), product(2, 30), product(3, 105)], // only the master. `effectivePrice` resolves that, and the prefill has to use
{ price: 999, taxpercent: 5 }, // the same resolution or it would ask for a price that already exists.
); test('the master price counts as having a price', () => {
assert.deepEqual(lines.map((line) => line.price), [395, 30, 105]); assert.equal(priceFieldFor(product(1, 0, 175)), '175');
assert.equal(priceFieldFor(product(2, 0)), '', 'nothing to prefill, so the field is empty');
}); });
test('a batch still fills in the ones with no price', () => { // Zero-rated goods are real — unpackaged staples are 0% GST — so 0 must show as
const lines = planPublish([product(1, 395), product(2, 0)], { price: 60, taxpercent: 5 }); // a rate rather than as an empty field meaning "not set".
assert.deepEqual(lines.map((line) => line.price), [395, 60]); test('a tax rate of zero is shown, not treated as missing', () => {
assert.equal(taxFieldFor(product(1, 100, 0, 0)), '0');
const plan = planPublishEach([product(1, 100, 0, 0)], asOpened([product(1, 100, 0, 0)]));
assert.equal(plan[0]?.taxpercent, 0);
});
// An empty tax field is zero-rated, which is a real answer. An empty PRICE
// field is not — see below.
test('an empty tax field releases at zero per cent rather than blocking', () => {
const plan = planPublishEach([product(1, 100)], new Map([[1, { price: '100', taxpercent: '' }]]));
assert.equal(plan[0]?.taxpercent, 0);
});
/* ── Nothing is released without a price ──────────────────────────────────── */
test('a product with no price of its own is named as needing one', () => {
assert.equal(needingPrice([product(1, 395), product(2, 0), product(3, 0, 88)]).length, 1);
});
test('an unpriced row blocks the release until it is filled', () => {
const products = [product(1, 395), product(2, 0)];
const typed = asOpened(products);
assert.deepEqual(
unpricedIn(products, typed).map((p) => p.productid),
[2],
);
typed.set(2, { price: '60', taxpercent: '5' });
assert.deepEqual(unpricedIn(products, typed), []);
});
// Clearing a field is not "keep what it had". What the row shows is what gets
// written, so an emptied field is no price and the release stops.
test('clearing a prefilled price blocks the release rather than reverting', () => {
const products = [product(1, 395)];
const typed = new Map([[1, { price: '', taxpercent: '5' }]]);
assert.equal(unpricedIn(products, typed).length, 1);
});
test('zero and a negative are refused as prices, not sent', () => {
const products = [product(1, 0), product(2, 0)];
const typed = new Map([
[1, { price: '0', taxpercent: '0' }],
[2, { price: '-5', taxpercent: '0' }],
]);
assert.equal(unpricedIn(products, typed).length, 2, 'a till cannot ring up ₹0 or less');
});
test('a price that is not a number at all is refused', () => {
const products = [product(1, 0)];
assert.equal(unpricedIn(products, new Map([[1, { price: 'abc', taxpercent: '0' }]])).length, 1);
}); });

View File

@@ -8,17 +8,25 @@ import { effectivePrice } from './productState';
* failing. `publishproduct` writes `products.retailprice` as well as the * failing. `publishproduct` writes `products.retailprice` as well as the
* per-outlet price, at every branch — so releasing seventeen imported products * per-outlet price, at every branch — so releasing seventeen imported products
* with one typed figure sets all seventeen to it and loses the originals, with * with one typed figure sets all seventeen to it and loses the originals, with
* nothing to restore them from. * nothing to restore them from. That is not hypothetical: R mart's import on
* 2026-08-31 was ₹395 rice, ₹30 crisps and ₹105 cleaner, and the dialog used to
* prefill one field from the first row.
* *
* The rule for a BATCH: a product that has a price is released at its own, and * ── One field per product ───────────────────────────────────────────────────
* the typed figure fills in only for one that has none — which after a
* spreadsheet import is usually nothing at all.
* *
* The rule for ONE product is the opposite, and it has to be: "Change price" * The shape that removes the hazard rather than guarding against it: every
* opens on a single product, and a form whose typed value is discarded because * selected product has its OWN field, prefilled with its OWN price, and a figure
* the field already had a value is not a form. That was the bug — a price could * typed into one row cannot reach another because there is nowhere for it to go.
* be set once and never corrected, because `own > 0` sent the old figure back * The earlier design — one shared field plus a rule about when it applied — was
* every time. * correct in the end but had to be got right; this one cannot be got wrong.
*
* ── The field is the value ──────────────────────────────────────────────────
*
* What a row shows is what will be written. An empty field is not "keep what it
* had", it is no price — and a product with no price is not released at all.
* That is the whole point of `unpricedIn`: the backend refuses `price <= 0`
* anyway ("a product with no price cannot be sold at a till"), and a product
* released at ₹0 could be ordered for nothing.
*/ */
export interface PublishLine { export interface PublishLine {
productid: number; productid: number;
@@ -26,26 +34,74 @@ export interface PublishLine {
taxpercent: number; taxpercent: number;
} }
export function planPublish( /** One row's fields, exactly as typed — strings, because a field can be empty. */
products: readonly Product[], export interface TypedPrice {
typed: { price: number; taxpercent: number }, price: string;
): PublishLine[] { taxpercent: string;
// One product is an edit of that product: the merchant is looking at its }
// price, has typed another, and means it.
const isSingle = products.length === 1;
/** What a row's price field starts at: the product's own price, or empty. */
export function priceFieldFor(product: Product): string {
const own = effectivePrice(product);
return own > 0 ? String(own) : '';
}
/** What a row's tax field starts at. Zero is a real rate and is shown as one. */
export function taxFieldFor(product: Product): string {
const own = Number(product.taxpercent ?? NaN);
return Number.isFinite(own) && own >= 0 ? String(own) : '';
}
/** The number a field holds, or NaN when it holds nothing usable. */
function parse(field: string | undefined): number {
if (field === undefined || field.trim() === '') return NaN;
const value = Number(field);
return Number.isFinite(value) ? value : NaN;
}
/**
* The calls to make, one per product, from what each row holds.
*
* A row nobody touched still carries its prefill, so "release everything as it
* stands" needs no typing at all.
*/
export function planPublishEach(
products: readonly Product[],
typed: ReadonlyMap<number, TypedPrice>,
): PublishLine[] {
return products.map((product) => { return products.map((product) => {
const own = effectivePrice(product); const row = typed.get(product.productid);
const ownTax = Number(product.taxpercent ?? NaN); const price = parse(row?.price ?? priceFieldFor(product));
const tax = parse(row?.taxpercent ?? taxFieldFor(product));
return { return {
productid: product.productid, productid: product.productid,
price: isSingle || own <= 0 ? typed.price : own, price: Number.isFinite(price) ? price : 0,
taxpercent: isSingle || !Number.isFinite(ownTax) ? typed.taxpercent : ownTax, // An empty tax field means zero-rated, which is a real rate for
// unpackaged staples — not a reason to refuse the release.
taxpercent: Number.isFinite(tax) && tax >= 0 ? tax : 0,
}; };
}); });
} }
/** The products that still need a price typed in — usually none. */ /**
* The selected products that cannot be released yet, and why they cannot.
*
* Anything this returns blocks the whole release rather than being skipped:
* releasing eight of ten and saying so afterwards leaves the merchant to work
* out which two, when the two are visible on screen right now.
*/
export function unpricedIn(
products: readonly Product[],
typed: ReadonlyMap<number, TypedPrice>,
): Product[] {
return products.filter((product) => {
const row = typed.get(product.productid);
const price = parse(row?.price ?? priceFieldFor(product));
return !Number.isFinite(price) || price <= 0;
});
}
/** The products that arrive with no price of their own — the ones to type in. */
export function needingPrice(products: readonly Product[]): Product[] { export function needingPrice(products: readonly Product[]): Product[] {
return products.filter((product) => effectivePrice(product) <= 0); return products.filter((product) => effectivePrice(product) <= 0);
} }