From aad72cfe4227a12e03a939ad2f5fbf9888df1dc9 Mon Sep 17 00:00:00 2001 From: abhishek Date: Wed, 2 Sep 2026 16:49:47 +0530 Subject: [PATCH] bulk request --- src/api/stock.ts | 41 ++++ src/components/useSelection.test.ts | 60 ++++++ src/components/useSelection.ts | 88 +++++++++ src/features/catalogue/CatalogueBrowser.tsx | 155 ++++++++++++--- src/features/catalogue/CatalogueCard.tsx | 25 +++ .../store-admin/pages/InventoryPage.tsx | 113 ++++++++++- src/features/store-user/BulkRequestDialog.tsx | 181 ++++++++++++++++++ .../store-user/pages/StoreProductsPage.tsx | 92 +++++++++ src/index.css | 133 +++++++++++++ 9 files changed, 862 insertions(+), 26 deletions(-) create mode 100644 src/components/useSelection.test.ts create mode 100644 src/components/useSelection.ts create mode 100644 src/features/store-user/BulkRequestDialog.tsx diff --git a/src/api/stock.ts b/src/api/stock.ts index a167273..025b3bd 100644 --- a/src/api/stock.ts +++ b/src/api/stock.ts @@ -40,6 +40,18 @@ export interface StockRequestQuery { pagesize?: number; } +/** + * What a batch actually did. + * + * Both lists are always read: a batch that half-worked is the case worth + * reporting, and the failures name the row so somebody can go and look. + */ +export interface StockBatchOutcome { + updated?: number[]; + created?: unknown[]; + failed?: { requestid?: number; productid?: number; reason: string }[]; +} + export interface CreateStockRequest { tenantid: number; locationid: number; @@ -92,6 +104,35 @@ export const stockApi = { status: STOCK_REQUEST_STATUS.received, }), + /** + * Several requests at once. + * + * One call rather than a loop of them, because approving MOVES STOCK: a loop + * that dies halfway leaves some deliveries received and some not, with nothing + * to say which. The backend applies each id separately and reports both lists, + * so a partial outcome is a fact the screen can show rather than a guess. + * + * The same status for the whole batch, never a mix. "Approve these" and + * "reject these" are two decisions, and one call that could do both is how a + * mis-click approves what it meant to refuse. + */ + decideMany: (requestids: number[], status: StockRequestStatus) => + api.put(`${WEB}/products/updatestockrequest`, { requestids, status }), + + approveMany: (requestids: number[]) => stockApi.decideMany(requestids, STOCK_REQUEST_STATUS.received), + rejectMany: (requestids: number[]) => stockApi.decideMany(requestids, STOCK_REQUEST_STATUS.rejected), + + /** + * A branch asks for several products in one go. + * + * Restocking after a delivery is one errand, not twenty. Sending it as twenty + * calls is slow, and a dropped connection leaves a half-made request list that + * nobody can tell apart from a deliberate one. + */ + createMany: (rows: CreateStockRequest[]) => + api.post(`${WEB}/products/createstockrequest`, + rows.map((row) => ({ ...row, status: STOCK_REQUEST_STATUS.pending }))), + /** Reject — a status write and nothing else. No stock moves, no reason stored. */ reject: (requestid: number) => api.put(`${WEB}/products/updatestockrequest`, { diff --git a/src/components/useSelection.test.ts b/src/components/useSelection.test.ts new file mode 100644 index 0000000..8467315 --- /dev/null +++ b/src/components/useSelection.test.ts @@ -0,0 +1,60 @@ +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; + +/* +The selection logic, lifted out of the hook so it can be tested without React. + +The rule that matters: a bulk action must only ever touch rows the person could +see when they chose them. Approving stock moves it, so acting on a row hidden +behind a filter is not a cosmetic bug. +*/ + +function chosenOf(visible: readonly number[], picked: ReadonlySet) { + return visible.filter((id) => picked.has(id)); +} + +function afterToggleAll(visible: readonly number[], picked: ReadonlySet) { + const next = new Set(picked); + const everyVisibleChosen = visible.length > 0 && visible.every((id) => next.has(id)); + for (const id of visible) { + if (everyVisibleChosen) next.delete(id); + else next.add(id); + } + return next; +} + +test('a bulk action never touches a row that was filtered away', () => { + // Ticked while the list showed everything, then the list was narrowed. + const picked = new Set([1, 2, 3]); + assert.deepEqual(chosenOf([2], picked), [2]); +}); + +test('narrowing and widening again does not untick the work', () => { + // The hidden ids stay in the set; they are simply not acted on while hidden. + const picked = new Set([1, 2, 3]); + assert.deepEqual(chosenOf([1, 2, 3], picked), [1, 2, 3]); +}); + +test('select all covers only what is on screen', () => { + const next = afterToggleAll([2, 3], new Set()); + assert.deepEqual([...next].sort(), [2, 3]); +}); + +test('select all a second time clears exactly what it added', () => { + const picked = afterToggleAll([2, 3], new Set([9])); + const cleared = afterToggleAll([2, 3], picked); + // 9 was chosen elsewhere and is not on screen, so it survives. + assert.deepEqual([...cleared], [9]); +}); + +test('select all on an empty list does nothing', () => { + assert.equal(afterToggleAll([], new Set()).size, 0); +}); + +test('the header is only fully ticked when every visible row is', () => { + const visible = [1, 2]; + const partial = chosenOf(visible, new Set([1])); + assert.equal(partial.length === visible.length, false); + const full = chosenOf(visible, new Set([1, 2])); + assert.equal(full.length === visible.length, true); +}); diff --git a/src/components/useSelection.ts b/src/components/useSelection.ts new file mode 100644 index 0000000..4a0e327 --- /dev/null +++ b/src/components/useSelection.ts @@ -0,0 +1,88 @@ +import { useCallback, useMemo, useState } from 'react'; + +/** + * Which rows a person has ticked, and the header checkbox that follows. + * + * Shared by the three screens that grew a bulk action — importing from the + * catalogue, requesting stock, and deciding requests — because the fiddly parts + * are the same every time and getting them subtly different between screens is + * how a merchant learns to distrust the tick boxes. + * + * ── The part that is easy to get wrong ────────────────────────────────────── + * + * A selection is kept against the ROWS CURRENTLY VISIBLE. Filter a list down, + * tick everything, clear the filter, and press the button: a naive + * implementation acts on rows the person could not see when they chose. So + * "select all" only ever covers what is on screen, and `chosen` is intersected + * with the visible ids before it is handed back. + * + * Ids that scroll out of view are NOT dropped from the set, because narrowing a + * search and widening it again should not silently untick the work. They are + * simply not acted on while they are hidden. + */ +export interface Selection { + /** Visible ids that are ticked — what a bulk action should act on. */ + chosen: number[]; + count: number; + has: (id: number) => boolean; + toggle: (id: number) => void; + /** Tick or untick everything currently visible. */ + toggleAll: () => void; + clear: () => void; + /** Every visible row is ticked. Drives the header checkbox. */ + allChosen: boolean; + /** Some but not all — the indeterminate state. */ + someChosen: boolean; +} + +export function useSelection(visibleIds: readonly number[]): Selection { + const [picked, setPicked] = useState>(() => new Set()); + + const chosen = useMemo( + () => visibleIds.filter((id) => picked.has(id)), + [visibleIds, picked], + ); + + const toggle = useCallback((id: number) => { + setPicked((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + const allChosen = visibleIds.length > 0 && chosen.length === visibleIds.length; + + const toggleAll = useCallback(() => { + setPicked((prev) => { + const next = new Set(prev); + const everyVisibleChosen = + visibleIds.length > 0 && visibleIds.every((id) => next.has(id)); + for (const id of visibleIds) { + if (everyVisibleChosen) { + next.delete(id); + } else { + next.add(id); + } + } + return next; + }); + }, [visibleIds]); + + const clear = useCallback(() => setPicked(new Set()), []); + + return { + chosen, + count: chosen.length, + has: (id: number) => picked.has(id), + toggle, + toggleAll, + clear, + allChosen, + someChosen: chosen.length > 0 && !allChosen, + }; +} diff --git a/src/features/catalogue/CatalogueBrowser.tsx b/src/features/catalogue/CatalogueBrowser.tsx index bc07117..3eb4f8c 100644 --- a/src/features/catalogue/CatalogueBrowser.tsx +++ b/src/features/catalogue/CatalogueBrowser.tsx @@ -12,6 +12,7 @@ import { Token } from '@astryxdesign/core/Token'; import { VStack } from '@astryxdesign/core/VStack'; import { Funnel, PackageSearch, Search, SearchX } from 'lucide-react'; import { catalogueKey, catalogueKeysOf } from '@/api/catalogue'; +import { useSelection } from '@/components/useSelection'; import { productsApi } from '@/api/products'; import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types'; import { queryKeys } from '@/queries/keys'; @@ -196,6 +197,39 @@ export function CatalogueBrowser({ return set; }, [imported.data, justImported]); + /** + * Which rows are ticked for a bulk import. + * + * Only rows that are NOT already imported can be selected. A product already + * on the shelf has nothing to do, and letting it be ticked would put it in + * the count on the button — "Add 12" that adds nine is worse than no count. + */ + const selectableIds = useMemo( + () => rows.filter((product) => !importedKeys.has(catalogueKey(product))).map((product) => product.id), + [rows, importedKeys], + ); + const selection = useSelection(selectableIds); + + const importMany = useMutation({ + mutationFn: (products: CatalogueProduct[]) => + productsApi.importFromCatalogue(products.map(importRowFor)), + onSuccess: async (_result, products) => { + // Ticked locally as well as refetched: the imported list is a separate + // query and the grid would otherwise show them as un-imported until it + // came back, tempting a second click. + setJustImported((set) => { + const next = new Set(set); + for (const product of products) next.add(catalogueKey(product)); + return next; + }); + selection.clear(); + await Promise.all([ + client.invalidateQueries({ queryKey: queryKeys.catalogue.all }), + client.invalidateQueries({ queryKey: queryKeys.products.all }), + ]); + }, + }); + const importOne = useMutation({ mutationFn: (row: ImportCatalogueProductRequest) => productsApi.importFromCatalogue([row]), onSuccess: async () => { @@ -227,6 +261,37 @@ export function CatalogueBrowser({ */ const hasNoCategory = !isReadOnly && !onImport && chosenCategory === ''; + /** + * One import row. + * + * Extracted so the single-product button and the bulk action build the SAME + * row. Two copies of this object is how a bulk import quietly writes a + * different category, or a price where the single one writes none. + */ + function importRowFor(product: CatalogueProduct): ImportCatalogueProductRequest { + return { + tenantid: tenantid as number, + locationid: locationid as number, + brand: product.brand, + catalogueid: product.id, + /* This used to read `Number(importInto) || 0`, defended as + "uncategorised beats wrongly categorised — an unclassified product is + visibly unfinished". It is not visible anywhere: the console lists it + like any other product and only the customer app knows it is gone. + A wrongly filed product is at least findable and fixable; one under + category 0 is returned by no query the app makes. */ + categoryid: Number(chosenCategory), + subcategoryid: 0, + quantity: 0, + stocktype: 'in', + status: 'Draft', + // Zero on purpose. Import is not pricing. + retailprice: 0, + productcost: 0, + taxpercent: 0, + }; + } + async function importDirect(product: CatalogueProduct) { if (!tenantid || !locationid) return; // Guarded rather than defaulted to 0 — see `hasNoCategory`. @@ -234,27 +299,7 @@ export function CatalogueBrowser({ const key = catalogueKey(product); setBusy(key); try { - await importOne.mutateAsync({ - tenantid, - locationid, - brand: product.brand, - catalogueid: product.id, - /* This used to read `Number(importInto) || 0`, defended as - "uncategorised beats wrongly categorised — an unclassified product is - visibly unfinished". It is not visible anywhere: the console lists it - like any other product and only the customer app knows it is gone. - A wrongly filed product is at least findable and fixable; one under - category 0 is returned by no query the app makes. */ - categoryid: Number(chosenCategory), - subcategoryid: 0, - quantity: 0, - stocktype: 'in', - status: 'Draft', - // Zero on purpose. Import is not pricing. - retailprice: 0, - productcost: 0, - taxpercent: 0, - }); + await importOne.mutateAsync(importRowFor(product)); setJustImported((set) => new Set(set).add(key)); } finally { setBusy(null); @@ -400,18 +445,82 @@ export function CatalogueBrowser({ /> ) : ( <> + {/* Bulk import. Hidden entirely when there is nothing to import + into — a merchant with no outlet selected is already told why + by `blockedReason`, and a second dead control below it adds + nothing. */} + {canImport && !onImport && selectableIds.length > 0 ? ( +
0 ? 'yes' : 'no'}> + + + + {selection.count > 0 ? ( + +
+ ) : null} +
{rows.map((product) => { const key = catalogueKey(product); + const isImported = importedKeys.has(key); return ( setOpen(product)} + {...(canImport && !onImport && !isImported + ? { + isSelected: selection.has(product.id), + onSelect: () => selection.toggle(product.id), + } + : {})} {...(isReadOnly ? {} : { onImport: () => run(product) })} /> ); diff --git a/src/features/catalogue/CatalogueCard.tsx b/src/features/catalogue/CatalogueCard.tsx index af8f894..b1e99bc 100644 --- a/src/features/catalogue/CatalogueCard.tsx +++ b/src/features/catalogue/CatalogueCard.tsx @@ -29,6 +29,8 @@ export function CatalogueCard({ actionLabel, onOpen, onImport, + isSelected, + onSelect, }: { product: CatalogueProduct; isImported: boolean; @@ -37,6 +39,13 @@ export function CatalogueCard({ actionLabel: string; onOpen: () => void; onImport?: () => void; + /** + * Present only when this card can take part in a bulk import. Absent for a + * product already on the shelf, and absent entirely on read-only views — so + * the tick box appears exactly where it does something. + */ + isSelected?: boolean; + onSelect?: () => void; }) { const images = product.images ?? []; const [index, setIndex] = useState(0); @@ -89,6 +98,22 @@ export function CatalogueCard({ controls. */}
+ {onSelect ? ( + + ) : null} {image ? ( (null); + const [outcome, setOutcome] = useState(null); + + const refreshAfterDecision = async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.stock.all }); + // Approving moves stock, so anything counting stock is stale too. + await queryClient.invalidateQueries({ queryKey: queryKeys.products.all }); + }; const decide = useMutation({ mutationFn: ({ requestid, approve }: { requestid: number; approve: boolean }) => approve ? stockApi.approve(requestid) : stockApi.reject(requestid), onSettled: async () => { setActing(null); - await queryClient.invalidateQueries({ queryKey: queryKeys.stock.all }); - // Approving moves stock, so anything counting stock is stale too. - await queryClient.invalidateQueries({ queryKey: queryKeys.products.all }); + await refreshAfterDecision(); }, }); const pending = requests.filter((entry) => entry.status === 'Pending'); + const selection = useSelection(pending.map((entry) => entry.requestid)); + + /** + * The batch decision. + * + * Reports what actually happened rather than assuming it all worked, because + * approving moves stock: if three of ten fail, those seven deliveries have + * really been received and the merchant must not approve them again. + */ + const decideBatch = useMutation({ + mutationFn: ({ ids, approve }: { ids: number[]; approve: boolean }) => + approve ? stockApi.approveMany(ids) : stockApi.rejectMany(ids), + onSuccess: (result, { ids, approve }) => { + const done = result?.updated?.length ?? ids.length; + const failed = result?.failed?.length ?? 0; + const verb = approve ? 'approved' : 'rejected'; + setOutcome( + failed === 0 + ? `${count(done)} ${verb}.` + : `${count(done)} ${verb}. ${count(failed)} could not be — they are still waiting.`, + ); + selection.clear(); + }, + onError: () => setOutcome('Nothing was changed. Try again, or decide them one at a time.'), + onSettled: refreshAfterDecision, + }); const settled = requests.filter((entry) => entry.status !== 'Pending'); return ( @@ -270,10 +302,85 @@ function RequestsPanel({ ) : ( + {/* The bulk bar. Present but quiet at rest: a row of checkboxes with + no visible action reads as broken, and a permanently loud bar + pushes the queue itself down the page. */} +
0 ? 'yes' : 'no'}> + + + + {selection.count > 0 ? ( + +
+ + {outcome ? ( + + + {outcome} + + + ) : null} + {pending.map((request) => ( + selection.toggle(request.requestid)} + aria-label={`Select ${request.productname ?? `product ${request.productid}`}`} + /> void; + /** Called when at least one request was raised, so the caller can clear its selection. */ + onDone: () => void; +} + +export function BulkRequestDialog({ + products, + tenantid, + locationid, + locationname, + onClose, + onDone, +}: BulkRequestDialogProps) { + const client = useQueryClient(); + const [qty, setQty] = useState>(() => + Object.fromEntries(products.map((product) => [product.productid, 1])), + ); + const [dropped, setDropped] = useState>(() => new Set()); + const [problem, setProblem] = useState(null); + + const lines = products.filter((product) => !dropped.has(product.productid)); + + const send = useMutation({ + mutationFn: () => + stockApi.createMany( + lines.map((product) => ({ + tenantid, + locationid, + productid: product.productid, + qty: qty[product.productid] ?? 1, + locationname, + productname: product.productname ?? '', + })), + ), + onSuccess: async (result) => { + const failed = result?.failed?.length ?? 0; + await client.invalidateQueries({ queryKey: queryKeys.stock.all }); + if (failed > 0) { + // Some landed. Saying so beats a bare success that hides the gap, and + // beats an error that implies none of them were raised. + setProblem( + `${lines.length - failed} of ${lines.length} raised. The rest could not be — try them again.`, + ); + return; + } + onDone(); + onClose(); + }, + onError: (cause) => setProblem(errorMessage(cause)), + }); + + const setLine = (productid: number, next: number) => + setQty((prev) => ({ ...prev, [productid]: Math.max(1, Math.min(9999, next)) })); + + return ( + + + + Set how much of each you need. Your admin decides each one, and nothing reaches the shelf + until they approve it. + + +
+ {lines.map((product) => ( +
+ + + {product.productname ?? `Product ${product.productid}`} + + + {[product.unitvalue, product.productunit].filter(Boolean).join(' ') || '—'} + + + + + + + setLine(product.productid, Number(event.target.value.replace(/\D/g, '')) || 1) + } + /> + + + +
+ ))} +
+ + {problem ? ( + + + {problem} + + + ) : null} + + +
+ ) : null} +
onOpen(product)} + {...(latest.get(product.productid) + ? {} + : { + isSelected: selection.has(product.productid), + onSelect: () => selection.toggle(product.productid), + })} onAsk={(qty) => onAsk(product, qty)} /> ))} @@ -305,6 +379,7 @@ function CataloguePanel({
+ )} ); @@ -322,11 +397,16 @@ function CatalogueRow({ request, onOpen, onAsk, + isSelected, + onSelect, }: { product: Product; request: StockRequest | undefined; onOpen: () => void; onAsk: (qty: number) => void; + /** Present only when this row can join a bulk request. */ + isSelected?: boolean; + onSelect?: () => void; }) { const [isHovered, setIsHovered] = useState(false); const state = requestState(request); @@ -355,6 +435,18 @@ function CatalogueRow({ > + {onSelect ? ( + event.stopPropagation()} + aria-label={`Select ${product.productname ?? 'product'}`} + /> + ) : null} diff --git a/src/index.css b/src/index.css index 565d4fe..2a1ea3e 100644 --- a/src/index.css +++ b/src/index.css @@ -1599,3 +1599,136 @@ main { margin-top: 2px; flex: none; } + +/* ── Bulk selection ──────────────────────────────────────────────────────── + A row of checkboxes with no visible action reads as broken, and a bar that + shouts at rest pushes the list itself down the page. So it sits quiet until + something is picked, then takes the brand tint and shows its actions. */ +.bulkbar { + padding: 10px 14px; + border: 1px solid var(--color-line); + border-radius: 12px; + background: var(--color-surface); + transition: background .15s ease, border-color .15s ease; +} + +.bulkbar[data-active='yes'] { + border-color: var(--color-brand); + background: var(--color-brand-tint); +} + +.bulkbar-all { + display: inline-flex; + align-items: center; + gap: 9px; + cursor: pointer; + user-select: none; +} + +/* Sized up from the browser default: these are hit on a phone, and a 13px + target next to a 40px product thumbnail is the hardest thing on the row. */ +.bulkbar-all input[type='checkbox'], +.bulkbar-tick { + width: 17px; + height: 17px; + flex: none; + accent-color: var(--color-brand); + cursor: pointer; + margin: 0; +} + +.bulkbar-all input[type='checkbox']:focus-visible, +.bulkbar-tick:focus-visible { + outline: 2px solid var(--color-brand); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + .bulkbar { transition: none; } +} + +/* The tick on a catalogue card. Top-left: the photo arrows own the right edge + and the "Imported" badge owns the top-right. Given its own opaque chip so it + stays visible against a light product photo. */ +.pcard-tick { + position: absolute; + top: 8px; + left: 8px; + z-index: 3; + display: grid; + place-items: center; + width: 26px; + height: 26px; + border-radius: 8px; + background: var(--color-surface); + border: 1px solid var(--color-line); + cursor: pointer; +} + +.pcard-tick input { + width: 15px; + height: 15px; + margin: 0; + accent-color: var(--color-brand); + cursor: pointer; +} + +.pcard-tick:focus-within { + outline: 2px solid var(--color-brand); + outline-offset: 1px; +} + +/* ── Bulk stock request ──────────────────────────────────────────────────── + One line per product, each with its own quantity: a shop asks for two crates + of oil and twenty packets of biscuits, so a single figure for the batch + would be ignored or wrong. Scrolls internally so the send button stays put + however many products were picked. */ +.bulkreq-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 46vh; + overflow-y: auto; + padding-right: 4px; +} + +.bulkreq-line { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 10px 12px; + border: 1px solid var(--color-line); + border-radius: 10px; + background: var(--color-surface); +} + +.qty-step { + width: 28px; + height: 28px; + flex: none; + display: grid; + place-items: center; + border: 1px solid var(--color-line); + border-radius: 8px; + background: var(--color-surface); + color: var(--color-ink-2); + cursor: pointer; +} + +.qty-step:hover { border-color: var(--color-brand); color: var(--color-brand); } +.qty-step:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 1px; } + +.qty-input { + width: 46px; + height: 28px; + text-align: center; + border: 1px solid var(--color-line); + border-radius: 8px; + background: var(--color-surface); + color: var(--color-ink-1); + font: 500 13px/1 var(--font-sans); + font-variant-numeric: tabular-nums; +} + +.qty-input:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 1px; }