bulk release
This commit is contained in:
@@ -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 ?? ''} />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user