;
+ 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}
-
-
-
- void run()}
- isDisabled={!canSubmit}
- />
-
- >
- ) : (
- <>
-
-
-
-
-
- {results.length - failed.length} of {results.length} released to all{' '}
- {branchCount} branches.
-
-
-
- {failed.length > 0 ? (
-
-
- {failed.length} could not be published
-
- {failed.map((entry) => (
-
- {entry.product.productname} — {entry.reason}
-
- ))}
-
- ) : null}
-
-
- {failed.length > 0 ? (
- {
- setResults(null);
- setDone(0);
+
+
+ {products.map((product) => {
+ const row = rows.get(product.productid) ?? { price: '', taxpercent: '' };
+ const isBlocked = blocked.some((p) => p.productid === product.productid);
+ return (
+
+
+
+ {product.productname}
+
+
+ {product.productsku || `#${product.productid}`}
+
+ {isBlocked ? (
+
+ Needs a price above zero
+
+ ) : null}
+
+
setRow(product.productid, { price: value })}
+ placeholder="0.00"
+ isDisabled={running}
+ />
+ setRow(product.productid, { taxpercent: value })}
+ placeholder="0"
+ isDisabled={running}
+ />
+
+ );
+ })}
+
+
+
+ {running ? (
+
+ Releasing {done} of {products.length}. One call per product — leave this open until it
+ finishes.
+
+ ) : null}
+ >
+ ) : (
+ <>
+
+
+ 0 ? 'var(--color-warning, #b7860b)' : '#0f8a5f'}
/>
- ) : null}
-
-
- >
+ }
+ {...(failed.length === 0 ? { tone: 'success' as const } : {})}
+ />
+
+
+
+
+ {failed.length > 0 ? (
+
+
+ {failed.map((entry) => (
+
+ ))}
+
+
+ ) : (
+ } 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.`}
+
)}
-
-
- >
+ >
+ )}
+
);
}
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);
}