updated on shelfon store catalogue

This commit is contained in:
2026-08-31 16:26:58 +05:30
parent 55ed13c284
commit 5304d9aeb6
3 changed files with 177 additions and 22 deletions

View File

@@ -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'}
</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
? 'The same price is set on every product selected.'
? 'Each keeps its own price. Only products without one use the figure below.'
: (first?.productname ?? '')}
</Text>
</VStack>
{results === null ? (
<>
<TextInput
label="Selling price (₹)"
size="md"
value={price}
onChange={setPrice}
placeholder="0.00"
{...(priceError ? { error: priceError } : {})}
/>
<TextInput
label="Tax %"
size="md"
value={tax}
onChange={setTax}
placeholder="0"
{...(taxError ? { error: taxError } : {})}
/>
{/* 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 ? (
<>
<TextInput
label="Selling price (₹)"
size="md"
value={price}
onChange={setPrice}
placeholder="0.00"
{...(priceError ? { error: priceError } : {})}
/>
<TextInput
label="Tax %"
size="md"
value={tax}
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. */}
<HStack gap={1.5} align="start">

View File

@@ -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);
});

View File

@@ -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);
}