diff --git a/src/features/store-admin/ProductsPanel.tsx b/src/features/store-admin/ProductsPanel.tsx index 66a5772..b63040d 100644 --- a/src/features/store-admin/ProductsPanel.tsx +++ b/src/features/store-admin/ProductsPanel.tsx @@ -14,6 +14,7 @@ import { count, money } from './format'; import { ProductDrawer } from './ProductDrawer'; import { PublishDialog } from './PublishDialog'; import { SheetUploadDrawer } from './SheetUploadDrawer'; +import { useSelection } from '@/components/useSelection'; import { TablePager } from '@/components/TablePager'; import { usePaged } from '@/components/usePaged'; import { @@ -108,6 +109,16 @@ export function ProductsPanel({ }); }, [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( () => [ { value: '', label: 'All aisles' }, @@ -141,9 +152,49 @@ export function ProductsPanel({ } /> + {rows.length > 0 ? ( +
0 ? 'yes' : 'no'}> + + + + {selection.count > 0 ? ( + +
+ ) : null} + 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} @@ -180,6 +237,7 @@ export function ProductsPanel({ function ProductTable({ rows, aisleNames, + selection, isLoading, onOpen, onPrice, @@ -188,6 +246,8 @@ function ProductTable({ rows: Product[]; /** Subcategory id to the aisle name the customer app shows it under. */ aisleNames: ReadonlyMap; + /** The tick state, owned by the panel so its bar can act on the choice. */ + selection: ReturnType; isLoading: boolean; onOpen: (product: Product) => void; onPrice: (product: Product) => void; @@ -250,7 +310,8 @@ function ProductTable({ }} > - + + @@ -260,6 +321,7 @@ function ProductTable({ + Product Category Unit @@ -275,6 +337,8 @@ function ProductTable({ key={product.productid} product={product} aisleNames={aisleNames} + isSelected={selection.has(product.productid)} + onSelect={() => selection.toggle(product.productid)} onOpen={() => onOpen(product)} onPrice={() => onPrice(product)} /> @@ -301,11 +365,15 @@ function ProductTable({ function Row({ product, aisleNames, + isSelected, + onSelect, onOpen, onPrice, }: { product: Product; aisleNames: ReadonlyMap; + isSelected: boolean; + onSelect: () => void; onOpen: () => void; onPrice: () => void; }) { @@ -331,10 +399,26 @@ function Row({ }} style={{ cursor: 'pointer', - background: isHovered ? 'var(--color-surface-sunken)' : 'transparent', + background: isSelected + ? 'var(--color-brand-tint)' + : isHovered + ? 'var(--color-surface-sunken)' + : 'transparent', 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. */} + + event.stopPropagation()} + onChange={onSelect} + aria-label={`Select ${product.productname ?? 'product'}`} + /> + diff --git a/src/features/store-admin/PublishDialog.tsx b/src/features/store-admin/PublishDialog.tsx index ce6341a..1c5bc3b 100644 --- a/src/features/store-admin/PublishDialog.tsx +++ b/src/features/store-admin/PublishDialog.tsx @@ -1,22 +1,25 @@ -import { useEffect, useState } from 'react'; +import { useMemo, useState } from 'react'; 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 { VStack } from '@astryxdesign/core/VStack'; -import { AlertTriangle, Check } from 'lucide-react'; +import { AlertTriangle, Check, IndianRupee } from 'lucide-react'; import { productsApi } from '@/api/products'; import type { Product } from '@/api/types'; import { queryKeys } from '@/queries/keys'; -import { effectivePrice } from './productState'; -import { needingPrice, planPublish } from './publishPlan'; +import { Drawer } from './Drawer'; +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. * - * Two things about this are the backend's shape, not a choice, and both are - * stated on screen rather than hidden: + * Three things about this are the backend's shape, not a choice, and all three + * are stated on screen rather than hidden: * * 1. **It is all-or-nothing across branches.** `PublishProduct` reads the * 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 * calls. So the loop is here, with real progress and a per-row result, * 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 { products: Product[]; @@ -42,65 +60,55 @@ export function PublishDialog({ products, tenantid, branchCount, onClose }: Publ const isBulk = products.length > 1; const first = products[0]; - const [price, setPrice] = useState(() => { - const existing = first ? effectivePrice(first) : 0; - return existing > 0 ? String(existing) : ''; - }); - const [tax, setTax] = useState(() => String(first?.taxpercent ?? 0)); + /** + * One row per product, prefilled with that product's own figures. + * + * Seeded once from the selection: re-seeding on every render would throw away + * 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>( + () => + new Map( + products.map((product) => [ + product.productid, + { price: priceFieldFor(product), taxpercent: taxFieldFor(product) }, + ]), + ), + ); + const [fill, setFill] = useState(''); const [done, setDone] = useState(0); const [running, setRunning] = useState(false); const [results, setResults] = useState(null); - useEffect(() => { - const onKey = (event: KeyboardEvent) => { - if (event.key === 'Escape' && !running) onClose(); - }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [onClose, running]); + function setRow(productid: number, patch: Partial) { + setRows((current) => { + const next = new Map(current); + const row = next.get(productid) ?? { price: '', taxpercent: '' }; + next.set(productid, { ...row, ...patch }); + return next; + }); + } - /** - * The products that still need a price typed in. - * - * Usually none. A spreadsheet import carries a price on every row, so asking - * for one again is asking a question that has already been answered — and it - * was doing more than annoy: the typed figure was applied to every product in - * the selection. - */ - const needPrice = needingPrice(products); - /** - * Whether to show the price field. - * - * 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 - * 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; + /** Fills only the EMPTY price fields — it never overwrites a real price. */ + function applyToEmpty() { + if (fill.trim() === '') return; + setRows((current) => { + const next = new Map(current); + for (const product of products) { + const row = next.get(product.productid); + if (!row || row.price.trim() === '') { + next.set(product.productid, { price: fill, taxpercent: row?.taxpercent ?? '' }); + } + } + return next; + }); + setFill(''); + } - const priceValue = Number(price); - // The backend refuses `price <= 0` with "set a price before publishing — a - // 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; + const blocked = useMemo(() => unpricedIn(products, rows), [products, rows]); + const canSubmit = !running && blocked.length === 0; async function run() { 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 // a single Go instance to save a few seconds risks a half-published // 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()) { - // 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]!; try { await productsApi.publish({ @@ -149,163 +148,219 @@ export function PublishDialog({ products, tenantid, branchCount, onClose }: Publ const failed = results?.filter((entry) => !entry.ok) ?? []; + const footer = + results === null ? ( + <> + + } + isDisabled={!canSubmit} + onClick={() => void run()} + /> + + ) : ( + <> + {failed.length > 0 ? ( + { + setResults(null); + setDone(0); + }} + /> + ) : null} + + + ); + return ( - <> -
-
- - - - {isBulk ? `Price ${products.length} products` : 'Price and release'} - - - {/* 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 ?? '')} - - + undefined : onClose} + isFooterSpread + footer={footer} + > + {results === null ? ( + <> + {/* The blast radius, before the button rather than after it. */} + }> + {branchCount === 1 ? ( + <> + This sets the price at your branch and puts{' '} + {isBulk ? 'these products' : 'this product'} on sale there. Open a second outlet and + the same release reaches it too — publishing is business-wide in this system, never + per branch. + + ) : ( + <> + This sets the price at all {branchCount} branches 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 + system. + + )} + - {results === null ? ( - <> - {/* Only asked when something in the selection actually lacks a - price. A sheet import carries one on every row, and asking - again reads as though the price were missing when it is on - screen two lines above. */} - {askForPrice ? ( - <> - 0 ? ( + + {blocked.length} of {products.length} still {blocked.length === 1 ? 'has' : 'have'} no + price. Nothing is released until every one is priced — a product at ₹0 can be ordered + for nothing. + + ) : null} + + {/* Filling several empty rows without typing the same figure into + each. Deliberately only the EMPTY ones: a control that overwrote + real prices is the failure this whole screen was rebuilt around. */} + {isBulk && blocked.length > 1 ? ( +
+ +
+
+ +
+ - - {isBulk ? ( - - {needPrice.length} of {products.length} have no price yet and will use this - one. The rest keep their own. - - ) : null} - - ) : ( - - {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)}.`} - - )} +
+
+
+ ) : null} - {/* The blast radius, stated before the button is pressed. */} - - - - - - This sets the price at all {branchCount} branches and releases - the 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 system. - - - - {running ? ( - - Publishing {done} of {products.length}… - - ) : null} - - -
- + + )} + ); } diff --git a/src/features/store-admin/publishPlan.test.ts b/src/features/store-admin/publishPlan.test.ts index 971af94..c2e2f42 100644 --- a/src/features/store-admin/publishPlan.test.ts +++ b/src/features/store-admin/publishPlan.test.ts @@ -4,101 +4,135 @@ * `publishproduct` writes `products.retailprice` as well as the per-outlet * 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 - * the dialog prefilled from the first row would have set all seventeen to ₹395 - * and left nothing to restore the real prices from. + * a dialog prefilled from the first row would have set all seventeen to ₹395 and + * 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 { 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'; 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 => + 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', () => { - const plan = planPublish( - [product(7089, 395), product(7103, 30), product(7104, 105)], - { price: 395, taxpercent: 0 }, - ); + const products = [product(7089, 395), product(7103, 30), product(7104, 105)]; + const plan = planPublishEach(products, asOpened(products)); assert.deepEqual( plan.map((line) => line.price), [395, 30, 105], ); }); -// The whole point of the fallback: a product with no price of its own is the -// only one the typed figure may touch. -test('only a product with no price uses the typed figure', () => { - const plan = planPublish([product(1, 0), product(2, 250)], { price: 99, taxpercent: 5 }); - assert.equal(plan[0]?.price, 99); - assert.equal(plan[1]?.price, 250); +// The failure this guards: three products released together, all set to +// whichever figure prefilled from the first. +test('editing one row does not move the other prices', () => { + const products = [product(1, 395), product(2, 30), product(3, 105)]; + const typed = asOpened(products); + typed.set(1, { price: '999', taxpercent: '5' }); + const plan = planPublishEach(products, typed); + assert.deepEqual( + plan.map((line) => line.price), + [999, 30, 105], + ); }); -/* -The per-outlet price and the tenant master are two columns and either can be the -one that holds the figure — an imported product carries both, a hand-priced one -may carry only the master. `effectivePrice` already resolves that, and publish -has to use the same resolution or it would "fill in" a price that exists. -*/ -test('the master price counts as having a price', () => { - // 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 }); +// One product is an edit of that product: the merchant is looking at its price, +// has typed another, and means it. `own > 0 ? own : typed` made that impossible +// — a price could be set once and never corrected. +test('a typed price replaces the one the product already had', () => { + const products = [product(7, 395)]; + const typed = new Map([[7, { price: '250', taxpercent: '12' }]]); + const [line] = planPublishEach(products, typed); 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); }); -test('a batch still protects the prices it already has', () => { - // The failure this guards: ₹395 rice, ₹30 crisps and ₹105 cleaner released - // together, all set to whichever figure prefilled from the first. - const lines = planPublish( - [product(1, 395), product(2, 30), product(3, 105)], - { price: 999, taxpercent: 5 }, - ); - assert.deepEqual(lines.map((line) => line.price), [395, 30, 105]); +/* ── Prefill: what a row starts at ────────────────────────────────────────── */ + +// The per-outlet price and the tenant master are two columns and either can hold +// the figure — an imported product carries both, a hand-priced one may carry +// only the master. `effectivePrice` resolves that, and the prefill has to use +// the same resolution or it would ask for a price that already exists. +test('the master price counts as having a price', () => { + 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', () => { - const lines = planPublish([product(1, 395), product(2, 0)], { price: 60, taxpercent: 5 }); - assert.deepEqual(lines.map((line) => line.price), [395, 60]); +// Zero-rated goods are real — unpackaged staples are 0% GST — so 0 must show as +// a rate rather than as an empty field meaning "not set". +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); }); diff --git a/src/features/store-admin/publishPlan.ts b/src/features/store-admin/publishPlan.ts index acad19a..319295f 100644 --- a/src/features/store-admin/publishPlan.ts +++ b/src/features/store-admin/publishPlan.ts @@ -8,17 +8,25 @@ import { effectivePrice } from './productState'; * failing. `publishproduct` writes `products.retailprice` as well as the * 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 - * 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 - * the typed figure fills in only for one that has none — which after a - * spreadsheet import is usually nothing at all. + * ── One field per product ─────────────────────────────────────────────────── * - * The rule for ONE product is the opposite, and it has to be: "Change price" - * opens on a single product, and a form whose typed value is discarded because - * the field already had a value is not a form. That was the bug — a price could - * be set once and never corrected, because `own > 0` sent the old figure back - * every time. + * The shape that removes the hazard rather than guarding against it: every + * selected product has its OWN field, prefilled with its OWN price, and a figure + * typed into one row cannot reach another because there is nowhere for it to go. + * The earlier design — one shared field plus a rule about when it applied — was + * 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 { productid: number; @@ -26,26 +34,74 @@ export interface PublishLine { taxpercent: number; } -export function planPublish( - products: readonly Product[], - typed: { price: number; taxpercent: number }, -): PublishLine[] { - // 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; +/** One row's fields, exactly as typed — strings, because a field can be empty. */ +export interface TypedPrice { + price: string; + taxpercent: string; +} +/** 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, +): PublishLine[] { return products.map((product) => { - const own = effectivePrice(product); - const ownTax = Number(product.taxpercent ?? NaN); + const row = typed.get(product.productid); + const price = parse(row?.price ?? priceFieldFor(product)); + const tax = parse(row?.taxpercent ?? taxFieldFor(product)); return { productid: product.productid, - price: isSingle || own <= 0 ? typed.price : own, - taxpercent: isSingle || !Number.isFinite(ownTax) ? typed.taxpercent : ownTax, + price: Number.isFinite(price) ? price : 0, + // 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, +): 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[] { return products.filter((product) => effectivePrice(product) <= 0); }