bulk release
This commit is contained in:
@@ -14,6 +14,7 @@ import { count, money } from './format';
|
||||
import { ProductDrawer } from './ProductDrawer';
|
||||
import { PublishDialog } from './PublishDialog';
|
||||
import { SheetUploadDrawer } from './SheetUploadDrawer';
|
||||
import { useSelection } from '@/components/useSelection';
|
||||
import { TablePager } from '@/components/TablePager';
|
||||
import { usePaged } from '@/components/usePaged';
|
||||
import {
|
||||
@@ -108,6 +109,16 @@ export function ProductsPanel({
|
||||
});
|
||||
}, [products.data, category]);
|
||||
|
||||
/**
|
||||
* Every product listed can be ticked, not only the unpriced ones.
|
||||
*
|
||||
* Releasing is the obvious bulk job, but re-pricing a shelf is the other one
|
||||
* — a supplier raises a rate and eleven products move together — and the same
|
||||
* drawer does both. Restricting the ticks to "Not ready" would have made the
|
||||
* second impossible for no gain.
|
||||
*/
|
||||
const selection = useSelection(rows.map((product) => product.productid));
|
||||
|
||||
const categoryOptions = useMemo(
|
||||
() => [
|
||||
{ value: '', label: 'All aisles' },
|
||||
@@ -141,9 +152,49 @@ export function ProductsPanel({
|
||||
}
|
||||
/>
|
||||
|
||||
{rows.length > 0 ? (
|
||||
<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
|
||||
rows={rows}
|
||||
aisleNames={aisleNames}
|
||||
selection={selection}
|
||||
queryKey={category}
|
||||
isLoading={products.isLoading}
|
||||
onOpen={setOpen}
|
||||
@@ -168,7 +219,13 @@ export function ProductsPanel({
|
||||
products={publishing}
|
||||
tenantid={tenantid}
|
||||
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}
|
||||
</VStack>
|
||||
@@ -180,6 +237,7 @@ export function ProductsPanel({
|
||||
function ProductTable({
|
||||
rows,
|
||||
aisleNames,
|
||||
selection,
|
||||
isLoading,
|
||||
onOpen,
|
||||
onPrice,
|
||||
@@ -188,6 +246,8 @@ function ProductTable({
|
||||
rows: Product[];
|
||||
/** Subcategory id to the aisle name the customer app shows it under. */
|
||||
aisleNames: ReadonlyMap<number, string>;
|
||||
/** The tick state, owned by the panel so its bar can act on the choice. */
|
||||
selection: ReturnType<typeof useSelection>;
|
||||
isLoading: boolean;
|
||||
onOpen: (product: Product) => void;
|
||||
onPrice: (product: Product) => void;
|
||||
@@ -250,7 +310,8 @@ function ProductTable({
|
||||
}}
|
||||
>
|
||||
<colgroup>
|
||||
<col style={{ width: '28%' }} />
|
||||
<col style={{ width: '4%' }} />
|
||||
<col style={{ width: '24%' }} />
|
||||
<col style={{ width: '17%' }} />
|
||||
<col style={{ width: '9.5%' }} />
|
||||
<col style={{ width: '9.5%' }} />
|
||||
@@ -260,6 +321,7 @@ function ProductTable({
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th />
|
||||
<Th>Product</Th>
|
||||
<Th>Category</Th>
|
||||
<Th>Unit</Th>
|
||||
@@ -275,6 +337,8 @@ function ProductTable({
|
||||
key={product.productid}
|
||||
product={product}
|
||||
aisleNames={aisleNames}
|
||||
isSelected={selection.has(product.productid)}
|
||||
onSelect={() => selection.toggle(product.productid)}
|
||||
onOpen={() => onOpen(product)}
|
||||
onPrice={() => onPrice(product)}
|
||||
/>
|
||||
@@ -301,11 +365,15 @@ function ProductTable({
|
||||
function Row({
|
||||
product,
|
||||
aisleNames,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onOpen,
|
||||
onPrice,
|
||||
}: {
|
||||
product: Product;
|
||||
aisleNames: ReadonlyMap<number, string>;
|
||||
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. */}
|
||||
<Td>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="bulkbar-tick"
|
||||
checked={isSelected}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={onSelect}
|
||||
aria-label={`Select ${product.productname ?? 'product'}`}
|
||||
/>
|
||||
</Td>
|
||||
<Td>
|
||||
<HStack gap={1.5} align="center">
|
||||
<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 { 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<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 [running, setRunning] = useState(false);
|
||||
const [results, setResults] = useState<Outcome[] | null>(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<TypedPrice>) {
|
||||
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 ? (
|
||||
<>
|
||||
<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 (
|
||||
<>
|
||||
<div
|
||||
onClick={running ? undefined : onClose}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 60, background: 'rgb(16 24 40 / .35)' }}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal
|
||||
aria-label={isBulk ? 'Price and release products' : 'Price and release product'}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
zIndex: 61,
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 'min(520px, calc(100vw - 32px))',
|
||||
maxHeight: 'calc(100vh - 48px)',
|
||||
overflowY: 'auto',
|
||||
borderRadius: 16,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'var(--color-surface)',
|
||||
boxShadow: '0 24px 48px -12px rgb(16 24 40 / .25)',
|
||||
}}
|
||||
>
|
||||
<VStack gap={2} padding={3}>
|
||||
<VStack gap={0.5}>
|
||||
<Text type="label" size="lg" weight="semibold">
|
||||
{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
|
||||
? 'Each keeps its own price. Only products without one use the figure below.'
|
||||
: (first?.productname ?? '')}
|
||||
</Text>
|
||||
</VStack>
|
||||
<Drawer
|
||||
title={isBulk ? `Price and release ${products.length} products` : 'Price and release'}
|
||||
{...(isBulk ? {} : { subtitle: first?.productname ?? '' })}
|
||||
width={560}
|
||||
onClose={running ? () => undefined : onClose}
|
||||
isFooterSpread
|
||||
footer={footer}
|
||||
>
|
||||
{results === null ? (
|
||||
<>
|
||||
{/* The blast radius, before the button rather than after it. */}
|
||||
<Note icon={<AlertTriangle size={15} />}>
|
||||
{branchCount === 1 ? (
|
||||
<>
|
||||
This sets the price at <strong>your branch</strong> 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 <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
|
||||
system.
|
||||
</>
|
||||
)}
|
||||
</Note>
|
||||
|
||||
{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 ? (
|
||||
<>
|
||||
<TextInput
|
||||
label="Selling price (₹)"
|
||||
size="md"
|
||||
value={price}
|
||||
onChange={setPrice}
|
||||
placeholder="0.00"
|
||||
{...(priceError ? { error: priceError } : {})}
|
||||
{isBulk && blocked.length > 0 ? (
|
||||
<Note>
|
||||
{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.
|
||||
</Note>
|
||||
) : 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 ? (
|
||||
<Section title="Fill the empty ones">
|
||||
<DrawerCard>
|
||||
<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
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</DrawerCard>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{/* The blast radius, stated before the button is pressed. */}
|
||||
<HStack gap={1.5} align="start">
|
||||
<span style={{ color: 'var(--color-warning, #b7860b)', flex: 'none', marginTop: 2 }}>
|
||||
<AlertTriangle size={15} />
|
||||
</span>
|
||||
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
|
||||
This sets the price at <strong>all {branchCount} branches</strong> 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.
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{running ? (
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Publishing {done} of {products.length}…
|
||||
</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);
|
||||
<Section title={isBulk ? `Prices — ${products.length} selected` : undefined}>
|
||||
<DrawerCard>
|
||||
{products.map((product) => {
|
||||
const row = rows.get(product.productid) ?? { price: '', taxpercent: '' };
|
||||
const isBlocked = blocked.some((p) => p.productid === product.productid);
|
||||
return (
|
||||
<div
|
||||
key={product.productid}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr) 108px 84px',
|
||||
gap: 10,
|
||||
alignItems: 'end',
|
||||
padding: '12px 14px',
|
||||
borderTop: '1px solid var(--color-line)',
|
||||
}}
|
||||
>
|
||||
<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} />
|
||||
</HStack>
|
||||
</>
|
||||
}
|
||||
{...(failed.length === 0 ? { tone: 'success' as const } : {})}
|
||||
/>
|
||||
<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
|
||||
* 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<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', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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<number, TypedPrice>,
|
||||
): 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<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[] {
|
||||
return products.filter((product) => effectivePrice(product) <= 0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user