bulk request

This commit is contained in:
2026-09-02 16:49:47 +05:30
parent d22b79e109
commit aad72cfe42
9 changed files with 862 additions and 26 deletions

View File

@@ -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 ? (
<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 on this page'
}
/>
<Text type="body" size="sm">
{selection.count > 0
? `${selection.count} selected`
: `Select all ${selectableIds.length} on this page`}
</Text>
</label>
{selection.count > 0 ? (
<HStack gap={1} align="center" wrap="wrap">
<Button
label="Clear"
variant="ghost"
size="sm"
isDisabled={importMany.isPending}
onClick={selection.clear}
/>
<Button
label={
importMany.isPending
? 'Adding…'
: `${actionLabel} (${selection.count})`
}
variant="primary"
size="sm"
isLoading={importMany.isPending}
isDisabled={importMany.isPending || hasNoCategory}
onClick={() =>
importMany.mutate(
rows.filter((product) => selection.has(product.id)),
)
}
/>
</HStack>
) : null}
</HStack>
</div>
) : null}
<div className="product-grid">
{rows.map((product) => {
const key = catalogueKey(product);
const isImported = importedKeys.has(key);
return (
<CatalogueCard
key={key}
product={product}
isImported={importedKeys.has(key)}
isBusy={busy === key}
isImported={isImported}
isBusy={busy === key || importMany.isPending}
isDisabled={!canImport}
actionLabel={actionLabel}
onOpen={() => setOpen(product)}
{...(canImport && !onImport && !isImported
? {
isSelected: selection.has(product.id),
onSelect: () => selection.toggle(product.id),
}
: {})}
{...(isReadOnly ? {} : { onImport: () => run(product) })}
/>
);

View File

@@ -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.
*/}
<div className="pcard-media">
{onSelect ? (
<label
className="pcard-tick"
/* The card body is a click target that opens the drawer. Ticking
must not also open it, so the label swallows the event before it
reaches the overlay underneath. */
onClick={(event) => event.stopPropagation()}
>
<input
type="checkbox"
checked={Boolean(isSelected)}
onChange={onSelect}
aria-label={`Select ${product.product_name}`}
/>
</label>
) : null}
{image ? (
<img
// Keyed by the URL so a fall-through to the next photo actually

View File

@@ -30,6 +30,7 @@ import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import { queryKeys } from '@/queries/keys';
import { useStockRequests, useStockStatement } from '@/queries/hooks';
import { useSelection } from '@/components/useSelection';
import { useBranchScope } from '../BranchScope';
import { CataloguePanel } from '../CataloguePanel';
import { ProductsPanel } from '../ProductsPanel';
@@ -206,19 +207,50 @@ function RequestsPanel({
}) {
const queryClient = useQueryClient();
const [acting, setActing] = useState<number | null>(null);
const [outcome, setOutcome] = useState<string | null>(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({
</Card>
) : (
<VStack gap={1}>
{/* 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. */}
<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) => {
// The third state: some ticked, not all. Only settable
// from script, and without it "select all" looks off when
// two of nine are chosen.
if (el) el.indeterminate = selection.someChosen;
}}
onChange={selection.toggleAll}
aria-label={selection.allChosen ? 'Clear selection' : 'Select every request shown'}
/>
<Text type="body" size="sm">
{selection.count > 0
? `${count(selection.count)} selected`
: `Select all ${count(pending.length)}`}
</Text>
</label>
{selection.count > 0 ? (
<HStack gap={1} align="center" wrap="wrap">
<Button
label="Clear"
variant="ghost"
size="sm"
isDisabled={decideBatch.isPending}
onClick={selection.clear}
/>
<Button
label={`Reject ${count(selection.count)}`}
variant="secondary"
size="sm"
icon={<X size={14} />}
isDisabled={decideBatch.isPending}
onClick={() => decideBatch.mutate({ ids: selection.chosen, approve: false })}
/>
<Button
label={
decideBatch.isPending
? 'Working…'
: `Approve ${count(selection.count)}`
}
variant="primary"
size="sm"
icon={<Check size={14} />}
isLoading={decideBatch.isPending}
isDisabled={decideBatch.isPending}
onClick={() => decideBatch.mutate({ ids: selection.chosen, approve: true })}
/>
</HStack>
) : null}
</HStack>
</div>
{outcome ? (
<Card padding={2} variant="transparent">
<Text type="body" size="sm" role="status">
{outcome}
</Text>
</Card>
) : null}
{pending.map((request) => (
<Card key={request.requestid} padding={0} elevation="low">
<HStack justify="between" align="center" gap={2} style={{ padding: 16 }}>
<HStack gap={1.5} align="center" style={{ minWidth: 0 }}>
<input
type="checkbox"
className="bulkbar-tick"
checked={selection.has(request.requestid)}
onChange={() => selection.toggle(request.requestid)}
aria-label={`Select ${request.productname ?? `product ${request.productid}`}`}
/>
<span
style={{
width: 40,

View File

@@ -0,0 +1,181 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { Minus, Plus, X } from 'lucide-react';
import { errorMessage } from '@/api/client';
import { stockApi } from '@/api/stock';
import type { Product } from '@/api/types';
import { Drawer } from '@/features/store-admin/Drawer';
import { queryKeys } from '@/queries/keys';
/**
* Asking for several products at once.
*
* Restocking after a delivery is one errand. Doing it a product at a time means
* a dialog per line, and the branch either gives up halfway or raises twenty
* separate requests the admin then has to decide twenty times.
*
* ── Why the quantities are still per product ────────────────────────────────
*
* The obvious shortcut is one quantity for the whole batch. It is wrong: a shop
* asks for two crates of oil and twenty packets of biscuits, and a single box
* would either be ignored or produce a request nobody meant. So every line
* keeps its own number, defaulted to 1 and adjustable in place.
*
* A line can be dropped here too. Picking eight and realising one was a mistake
* should not mean closing this and starting the selection again.
*/
export interface BulkRequestDialogProps {
products: Product[];
tenantid: number;
locationid: number;
locationname: string;
onClose: () => 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<Record<number, number>>(() =>
Object.fromEntries(products.map((product) => [product.productid, 1])),
);
const [dropped, setDropped] = useState<ReadonlySet<number>>(() => new Set());
const [problem, setProblem] = useState<string | null>(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 (
<Drawer
title="Ask for stock"
subtitle={`${lines.length} product${lines.length === 1 ? '' : 's'}`}
width={520}
onClose={onClose}
>
<VStack gap={3}>
<Text type="body" size="sm" color="secondary">
Set how much of each you need. Your admin decides each one, and nothing reaches the shelf
until they approve it.
</Text>
<div className="bulkreq-list">
{lines.map((product) => (
<div key={product.productid} className="bulkreq-line">
<VStack gap={0} style={{ minWidth: 0 }}>
<Text type="label" size="sm" weight="semibold" maxLines={1}>
{product.productname ?? `Product ${product.productid}`}
</Text>
<Text type="body" size="xsm" color="secondary">
{[product.unitvalue, product.productunit].filter(Boolean).join(' ') || '—'}
</Text>
</VStack>
<HStack gap={0.5} align="center">
<button
type="button"
className="qty-step"
aria-label={`One fewer ${product.productname ?? 'unit'}`}
onClick={() => setLine(product.productid, (qty[product.productid] ?? 1) - 1)}
>
<Minus size={13} />
</button>
<input
className="qty-input"
inputMode="numeric"
value={String(qty[product.productid] ?? 1)}
aria-label={`Quantity of ${product.productname ?? 'product'}`}
onChange={(event) =>
setLine(product.productid, Number(event.target.value.replace(/\D/g, '')) || 1)
}
/>
<button
type="button"
className="qty-step"
aria-label={`One more ${product.productname ?? 'unit'}`}
onClick={() => setLine(product.productid, (qty[product.productid] ?? 1) + 1)}
>
<Plus size={13} />
</button>
<button
type="button"
className="qty-step"
aria-label={`Remove ${product.productname ?? 'product'} from this request`}
onClick={() =>
setDropped((prev) => new Set(prev).add(product.productid))
}
>
<X size={13} />
</button>
</HStack>
</div>
))}
</div>
{problem ? (
<Card padding={2} variant="transparent">
<Text type="body" size="sm" role="alert" style={{ color: 'var(--color-error, #d64545)' }}>
{problem}
</Text>
</Card>
) : null}
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Button label="Cancel" variant="ghost" isDisabled={send.isPending} onClick={onClose} />
<Button
label={send.isPending ? 'Sending…' : `Send ${lines.length} request${lines.length === 1 ? '' : 's'}`}
variant="primary"
isLoading={send.isPending}
isDisabled={send.isPending || lines.length === 0}
onClick={() => {
setProblem(null);
send.mutate();
}}
/>
</HStack>
</VStack>
</Drawer>
);
}

View File

@@ -22,6 +22,8 @@ import { useBranchScope } from '@/features/store-admin/BranchScope';
import { ProductDrawer } from '@/features/store-admin/ProductDrawer';
import { count, money } from '@/features/store-admin/format';
import { effectivePrice, imagesOf, isPublished, stockOf } from '@/features/store-admin/productState';
import { useSelection } from '@/components/useSelection';
import { BulkRequestDialog } from '../BulkRequestDialog';
import { RequestDialog } from '../RequestDialog';
import {
hasMoved,
@@ -59,6 +61,7 @@ export function StoreProductsPage() {
const [keyword, setKeyword] = useState('');
const [open, setOpen] = useState<Product | null>(null);
const [asking, setAsking] = useState<{ product: Product; qty: number } | null>(null);
const [askingMany, setAskingMany] = useState<Product[] | null>(null);
const products = useLocationProducts(tenantid || undefined, locationid, 0);
const categories = useTenantCategories(tenantid || undefined);
@@ -190,6 +193,7 @@ export function StoreProductsPage() {
onCategory={setCategory}
onOpen={setOpen}
onAsk={(product, qty) => setAsking({ product, qty })}
onAskMany={setAskingMany}
/>
) : tab === 'stock' ? (
<StockPanel rows={stockRows} isLoading={statement.isLoading} />
@@ -199,6 +203,17 @@ export function StoreProductsPage() {
{open ? <ProductDrawer product={open} canManage={false} onClose={() => setOpen(null)} /> : null}
{askingMany && askingMany.length > 0 && tenantid && locationid ? (
<BulkRequestDialog
products={askingMany}
tenantid={tenantid}
locationid={locationid}
locationname={current?.locationname ?? ''}
onClose={() => setAskingMany(null)}
onDone={() => setAskingMany(null)}
/>
) : null}
{asking && tenantid && locationid ? (
<RequestDialog
product={asking.product}
@@ -224,6 +239,7 @@ function CataloguePanel({
onCategory,
onOpen,
onAsk,
onAskMany,
}: {
rows: Product[];
isLoading: boolean;
@@ -233,7 +249,21 @@ function CataloguePanel({
onCategory: (next: string) => void;
onOpen: (product: Product) => void;
onAsk: (product: Product, qty: number) => void;
onAskMany: (products: Product[]) => void;
}) {
/**
* Only rows with no open request can be ticked.
*
* Asking twice for the same product gives the admin two rows to decide and
* receives the stock twice if they approve both. The single-row button is
* already disabled in that state; the tick follows the same rule.
*/
const selectableIds = useMemo(
() => rows.filter((product) => !latest.get(product.productid)).map((product) => product.productid),
[rows, latest],
);
const selection = useSelection(selectableIds);
return (
<VStack gap={1.5}>
<HStack justify="between" align="center" gap={1} wrap="wrap">
@@ -263,6 +293,44 @@ function CataloguePanel({
body="Your store administrator prices a product and releases it before it can be sold or requested here. Clear the category or the search if you were expecting rows."
/>
) : (
<VStack gap={1.5}>
{selectableIds.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 ${selectableIds.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={`Ask for ${selection.count}`}
variant="primary"
size="sm"
onClick={() =>
onAskMany(rows.filter((product) => selection.has(product.productid)))
}
/>
</HStack>
) : null}
</HStack>
</div>
) : null}
<Card padding={0} elevation="low">
<div className="table-scroll">
<table
@@ -298,6 +366,12 @@ function CataloguePanel({
product={product}
request={latest.get(product.productid)}
onOpen={() => 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({
</table>
</div>
</Card>
</VStack>
)}
</VStack>
);
@@ -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({
>
<Td>
<HStack gap={1.5} align="center">
{onSelect ? (
<input
type="checkbox"
className="bulkbar-tick"
checked={Boolean(isSelected)}
onChange={onSelect}
/* The row opens the drawer on click. Ticking must not also open
it, so the event stops here. */
onClick={(event) => event.stopPropagation()}
aria-label={`Select ${product.productname ?? 'product'}`}
/>
) : null}
<Thumb images={imagesOf(product)} alt={product.productname ?? ''} />
<VStack gap={0} style={{ minWidth: 0 }}>
<Text type="label" size="sm" weight="semibold" maxLines={1}>