diff --git a/src/features/store-admin/PublishDialog.tsx b/src/features/store-admin/PublishDialog.tsx
index bc3a891..35d8871 100644
--- a/src/features/store-admin/PublishDialog.tsx
+++ b/src/features/store-admin/PublishDialog.tsx
@@ -10,6 +10,7 @@ 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';
/**
* Set a price and release to the shops.
@@ -58,6 +59,17 @@ export function PublishDialog({ products, tenantid, branchCount, onClose }: Publ
return () => window.removeEventListener('keydown', onKey);
}, [onClose, running]);
+ /**
+ * 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);
+ const askForPrice = needPrice.length > 0;
+
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
@@ -72,7 +84,11 @@ export function PublishDialog({ products, tenantid, branchCount, onClose }: Publ
const taxError =
!Number.isFinite(taxValue) || taxValue < 0 ? 'Tax cannot be negative.' : null;
const canSubmit =
- !running && price.trim() !== '' && priceError === null && taxError === null;
+ !running &&
+ // A price is required only when something in the selection lacks one.
+ (!askForPrice || price.trim() !== '') &&
+ priceError === null &&
+ taxError === null;
async function run() {
setRunning(true);
@@ -83,13 +99,25 @@ 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.
- for (const product of products) {
+ const plan = planPublish(products, { price: priceValue, taxpercent: taxValue });
+
+ 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({
tenantid,
- productid: product.productid,
- price: priceValue,
- taxpercent: taxValue,
+ productid: line.productid,
+ price: line.price,
+ taxpercent: line.taxpercent,
});
outcomes.push({ product, ok: true });
} catch (error) {
@@ -140,30 +168,54 @@ export function PublishDialog({ products, tenantid, branchCount, onClose }: Publ
{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
- ? 'The same price is set on every product selected.'
+ ? 'Each keeps its own price. Only products without one use the figure below.'
: (first?.productname ?? '')}
{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 ? (
+ <>
+
+
+ {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)}.`}
+
+ )}
{/* The blast radius, stated before the button is pressed. */}
diff --git a/src/features/store-admin/publishPlan.test.ts b/src/features/store-admin/publishPlan.test.ts
new file mode 100644
index 0000000..b5d4285
--- /dev/null
+++ b/src/features/store-admin/publishPlan.test.ts
@@ -0,0 +1,62 @@
+/**
+ * Releasing several products at once must not give them all one price.
+ *
+ * `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.
+ */
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { needingPrice, planPublish } 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;
+
+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 },
+ );
+ 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 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', () => {
+ const plan = planPublish([product(1, 0, 175)], { 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', () => {
+ const plan = planPublish([product(1, 100, 0, 0)], { 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);
+});
diff --git a/src/features/store-admin/publishPlan.ts b/src/features/store-admin/publishPlan.ts
new file mode 100644
index 0000000..bdd0980
--- /dev/null
+++ b/src/features/store-admin/publishPlan.ts
@@ -0,0 +1,41 @@
+import type { Product } from '@/api/types';
+import { effectivePrice } from './productState';
+
+/**
+ * What price and tax each product is released at.
+ *
+ * Its own function because getting it wrong destroys data rather than merely
+ * 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.
+ *
+ * The rule: a product that HAS a price is released at its own. The typed figure
+ * fills in only for one that has none, which after a spreadsheet import is
+ * usually nothing at all.
+ */
+export interface PublishLine {
+ productid: number;
+ price: number;
+ taxpercent: number;
+}
+
+export function planPublish(
+ products: readonly Product[],
+ fallback: { price: number; taxpercent: number },
+): PublishLine[] {
+ return products.map((product) => {
+ const own = effectivePrice(product);
+ const ownTax = Number(product.taxpercent ?? NaN);
+ return {
+ productid: product.productid,
+ price: own > 0 ? own : fallback.price,
+ taxpercent: Number.isFinite(ownTax) ? ownTax : fallback.taxpercent,
+ };
+ });
+}
+
+/** The products that still need a price typed in — usually none. */
+export function needingPrice(products: readonly Product[]): Product[] {
+ return products.filter((product) => effectivePrice(product) <= 0);
+}