bulk request
This commit is contained in:
@@ -40,6 +40,18 @@ export interface StockRequestQuery {
|
|||||||
pagesize?: number;
|
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 {
|
export interface CreateStockRequest {
|
||||||
tenantid: number;
|
tenantid: number;
|
||||||
locationid: number;
|
locationid: number;
|
||||||
@@ -92,6 +104,35 @@ export const stockApi = {
|
|||||||
status: STOCK_REQUEST_STATUS.received,
|
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<StockBatchOutcome>(`${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<StockBatchOutcome>(`${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 — a status write and nothing else. No stock moves, no reason stored. */
|
||||||
reject: (requestid: number) =>
|
reject: (requestid: number) =>
|
||||||
api.put<unknown>(`${WEB}/products/updatestockrequest`, {
|
api.put<unknown>(`${WEB}/products/updatestockrequest`, {
|
||||||
|
|||||||
60
src/components/useSelection.test.ts
Normal file
60
src/components/useSelection.test.ts
Normal file
@@ -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<number>) {
|
||||||
|
return visible.filter((id) => picked.has(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function afterToggleAll(visible: readonly number[], picked: ReadonlySet<number>) {
|
||||||
|
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);
|
||||||
|
});
|
||||||
88
src/components/useSelection.ts
Normal file
88
src/components/useSelection.ts
Normal file
@@ -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<ReadonlySet<number>>(() => 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { Token } from '@astryxdesign/core/Token';
|
|||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { Funnel, PackageSearch, Search, SearchX } from 'lucide-react';
|
import { Funnel, PackageSearch, Search, SearchX } from 'lucide-react';
|
||||||
import { catalogueKey, catalogueKeysOf } from '@/api/catalogue';
|
import { catalogueKey, catalogueKeysOf } from '@/api/catalogue';
|
||||||
|
import { useSelection } from '@/components/useSelection';
|
||||||
import { productsApi } from '@/api/products';
|
import { productsApi } from '@/api/products';
|
||||||
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
|
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
|
||||||
import { queryKeys } from '@/queries/keys';
|
import { queryKeys } from '@/queries/keys';
|
||||||
@@ -196,6 +197,39 @@ export function CatalogueBrowser({
|
|||||||
return set;
|
return set;
|
||||||
}, [imported.data, justImported]);
|
}, [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({
|
const importOne = useMutation({
|
||||||
mutationFn: (row: ImportCatalogueProductRequest) => productsApi.importFromCatalogue([row]),
|
mutationFn: (row: ImportCatalogueProductRequest) => productsApi.importFromCatalogue([row]),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -227,16 +261,17 @@ export function CatalogueBrowser({
|
|||||||
*/
|
*/
|
||||||
const hasNoCategory = !isReadOnly && !onImport && chosenCategory === '';
|
const hasNoCategory = !isReadOnly && !onImport && chosenCategory === '';
|
||||||
|
|
||||||
async function importDirect(product: CatalogueProduct) {
|
/**
|
||||||
if (!tenantid || !locationid) return;
|
* One import row.
|
||||||
// Guarded rather than defaulted to 0 — see `hasNoCategory`.
|
*
|
||||||
if (chosenCategory === '') return;
|
* Extracted so the single-product button and the bulk action build the SAME
|
||||||
const key = catalogueKey(product);
|
* row. Two copies of this object is how a bulk import quietly writes a
|
||||||
setBusy(key);
|
* different category, or a price where the single one writes none.
|
||||||
try {
|
*/
|
||||||
await importOne.mutateAsync({
|
function importRowFor(product: CatalogueProduct): ImportCatalogueProductRequest {
|
||||||
tenantid,
|
return {
|
||||||
locationid,
|
tenantid: tenantid as number,
|
||||||
|
locationid: locationid as number,
|
||||||
brand: product.brand,
|
brand: product.brand,
|
||||||
catalogueid: product.id,
|
catalogueid: product.id,
|
||||||
/* This used to read `Number(importInto) || 0`, defended as
|
/* This used to read `Number(importInto) || 0`, defended as
|
||||||
@@ -254,7 +289,17 @@ export function CatalogueBrowser({
|
|||||||
retailprice: 0,
|
retailprice: 0,
|
||||||
productcost: 0,
|
productcost: 0,
|
||||||
taxpercent: 0,
|
taxpercent: 0,
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importDirect(product: CatalogueProduct) {
|
||||||
|
if (!tenantid || !locationid) return;
|
||||||
|
// Guarded rather than defaulted to 0 — see `hasNoCategory`.
|
||||||
|
if (chosenCategory === '') return;
|
||||||
|
const key = catalogueKey(product);
|
||||||
|
setBusy(key);
|
||||||
|
try {
|
||||||
|
await importOne.mutateAsync(importRowFor(product));
|
||||||
setJustImported((set) => new Set(set).add(key));
|
setJustImported((set) => new Set(set).add(key));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null);
|
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">
|
<div className="product-grid">
|
||||||
{rows.map((product) => {
|
{rows.map((product) => {
|
||||||
const key = catalogueKey(product);
|
const key = catalogueKey(product);
|
||||||
|
const isImported = importedKeys.has(key);
|
||||||
return (
|
return (
|
||||||
<CatalogueCard
|
<CatalogueCard
|
||||||
key={key}
|
key={key}
|
||||||
product={product}
|
product={product}
|
||||||
isImported={importedKeys.has(key)}
|
isImported={isImported}
|
||||||
isBusy={busy === key}
|
isBusy={busy === key || importMany.isPending}
|
||||||
isDisabled={!canImport}
|
isDisabled={!canImport}
|
||||||
actionLabel={actionLabel}
|
actionLabel={actionLabel}
|
||||||
onOpen={() => setOpen(product)}
|
onOpen={() => setOpen(product)}
|
||||||
|
{...(canImport && !onImport && !isImported
|
||||||
|
? {
|
||||||
|
isSelected: selection.has(product.id),
|
||||||
|
onSelect: () => selection.toggle(product.id),
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
{...(isReadOnly ? {} : { onImport: () => run(product) })}
|
{...(isReadOnly ? {} : { onImport: () => run(product) })}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export function CatalogueCard({
|
|||||||
actionLabel,
|
actionLabel,
|
||||||
onOpen,
|
onOpen,
|
||||||
onImport,
|
onImport,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
product: CatalogueProduct;
|
product: CatalogueProduct;
|
||||||
isImported: boolean;
|
isImported: boolean;
|
||||||
@@ -37,6 +39,13 @@ export function CatalogueCard({
|
|||||||
actionLabel: string;
|
actionLabel: string;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
onImport?: () => 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 images = product.images ?? [];
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
@@ -89,6 +98,22 @@ export function CatalogueCard({
|
|||||||
controls.
|
controls.
|
||||||
*/}
|
*/}
|
||||||
<div className="pcard-media">
|
<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 ? (
|
{image ? (
|
||||||
<img
|
<img
|
||||||
// Keyed by the URL so a fall-through to the next photo actually
|
// Keyed by the URL so a fall-through to the next photo actually
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { PageHeader } from '@/components/PageHeader';
|
|||||||
import { SectionHeader } from '@/components/SectionHeader';
|
import { SectionHeader } from '@/components/SectionHeader';
|
||||||
import { queryKeys } from '@/queries/keys';
|
import { queryKeys } from '@/queries/keys';
|
||||||
import { useStockRequests, useStockStatement } from '@/queries/hooks';
|
import { useStockRequests, useStockStatement } from '@/queries/hooks';
|
||||||
|
import { useSelection } from '@/components/useSelection';
|
||||||
import { useBranchScope } from '../BranchScope';
|
import { useBranchScope } from '../BranchScope';
|
||||||
import { CataloguePanel } from '../CataloguePanel';
|
import { CataloguePanel } from '../CataloguePanel';
|
||||||
import { ProductsPanel } from '../ProductsPanel';
|
import { ProductsPanel } from '../ProductsPanel';
|
||||||
@@ -206,19 +207,50 @@ function RequestsPanel({
|
|||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [acting, setActing] = useState<number | null>(null);
|
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({
|
const decide = useMutation({
|
||||||
mutationFn: ({ requestid, approve }: { requestid: number; approve: boolean }) =>
|
mutationFn: ({ requestid, approve }: { requestid: number; approve: boolean }) =>
|
||||||
approve ? stockApi.approve(requestid) : stockApi.reject(requestid),
|
approve ? stockApi.approve(requestid) : stockApi.reject(requestid),
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
setActing(null);
|
setActing(null);
|
||||||
await queryClient.invalidateQueries({ queryKey: queryKeys.stock.all });
|
await refreshAfterDecision();
|
||||||
// Approving moves stock, so anything counting stock is stale too.
|
|
||||||
await queryClient.invalidateQueries({ queryKey: queryKeys.products.all });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const pending = requests.filter((entry) => entry.status === 'Pending');
|
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');
|
const settled = requests.filter((entry) => entry.status !== 'Pending');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -270,10 +302,85 @@ function RequestsPanel({
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<VStack gap={1}>
|
<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) => (
|
{pending.map((request) => (
|
||||||
<Card key={request.requestid} padding={0} elevation="low">
|
<Card key={request.requestid} padding={0} elevation="low">
|
||||||
<HStack justify="between" align="center" gap={2} style={{ padding: 16 }}>
|
<HStack justify="between" align="center" gap={2} style={{ padding: 16 }}>
|
||||||
<HStack gap={1.5} align="center" style={{ minWidth: 0 }}>
|
<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
|
<span
|
||||||
style={{
|
style={{
|
||||||
width: 40,
|
width: 40,
|
||||||
|
|||||||
181
src/features/store-user/BulkRequestDialog.tsx
Normal file
181
src/features/store-user/BulkRequestDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ import { useBranchScope } from '@/features/store-admin/BranchScope';
|
|||||||
import { ProductDrawer } from '@/features/store-admin/ProductDrawer';
|
import { ProductDrawer } from '@/features/store-admin/ProductDrawer';
|
||||||
import { count, money } from '@/features/store-admin/format';
|
import { count, money } from '@/features/store-admin/format';
|
||||||
import { effectivePrice, imagesOf, isPublished, stockOf } from '@/features/store-admin/productState';
|
import { effectivePrice, imagesOf, isPublished, stockOf } from '@/features/store-admin/productState';
|
||||||
|
import { useSelection } from '@/components/useSelection';
|
||||||
|
import { BulkRequestDialog } from '../BulkRequestDialog';
|
||||||
import { RequestDialog } from '../RequestDialog';
|
import { RequestDialog } from '../RequestDialog';
|
||||||
import {
|
import {
|
||||||
hasMoved,
|
hasMoved,
|
||||||
@@ -59,6 +61,7 @@ export function StoreProductsPage() {
|
|||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [open, setOpen] = useState<Product | null>(null);
|
const [open, setOpen] = useState<Product | null>(null);
|
||||||
const [asking, setAsking] = useState<{ product: Product; qty: number } | 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 products = useLocationProducts(tenantid || undefined, locationid, 0);
|
||||||
const categories = useTenantCategories(tenantid || undefined);
|
const categories = useTenantCategories(tenantid || undefined);
|
||||||
@@ -190,6 +193,7 @@ export function StoreProductsPage() {
|
|||||||
onCategory={setCategory}
|
onCategory={setCategory}
|
||||||
onOpen={setOpen}
|
onOpen={setOpen}
|
||||||
onAsk={(product, qty) => setAsking({ product, qty })}
|
onAsk={(product, qty) => setAsking({ product, qty })}
|
||||||
|
onAskMany={setAskingMany}
|
||||||
/>
|
/>
|
||||||
) : tab === 'stock' ? (
|
) : tab === 'stock' ? (
|
||||||
<StockPanel rows={stockRows} isLoading={statement.isLoading} />
|
<StockPanel rows={stockRows} isLoading={statement.isLoading} />
|
||||||
@@ -199,6 +203,17 @@ export function StoreProductsPage() {
|
|||||||
|
|
||||||
{open ? <ProductDrawer product={open} canManage={false} onClose={() => setOpen(null)} /> : null}
|
{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 ? (
|
{asking && tenantid && locationid ? (
|
||||||
<RequestDialog
|
<RequestDialog
|
||||||
product={asking.product}
|
product={asking.product}
|
||||||
@@ -224,6 +239,7 @@ function CataloguePanel({
|
|||||||
onCategory,
|
onCategory,
|
||||||
onOpen,
|
onOpen,
|
||||||
onAsk,
|
onAsk,
|
||||||
|
onAskMany,
|
||||||
}: {
|
}: {
|
||||||
rows: Product[];
|
rows: Product[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -233,7 +249,21 @@ function CataloguePanel({
|
|||||||
onCategory: (next: string) => void;
|
onCategory: (next: string) => void;
|
||||||
onOpen: (product: Product) => void;
|
onOpen: (product: Product) => void;
|
||||||
onAsk: (product: Product, qty: number) => 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 (
|
return (
|
||||||
<VStack gap={1.5}>
|
<VStack gap={1.5}>
|
||||||
<HStack justify="between" align="center" gap={1} wrap="wrap">
|
<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."
|
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">
|
<Card padding={0} elevation="low">
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table
|
<table
|
||||||
@@ -298,6 +366,12 @@ function CataloguePanel({
|
|||||||
product={product}
|
product={product}
|
||||||
request={latest.get(product.productid)}
|
request={latest.get(product.productid)}
|
||||||
onOpen={() => onOpen(product)}
|
onOpen={() => onOpen(product)}
|
||||||
|
{...(latest.get(product.productid)
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
isSelected: selection.has(product.productid),
|
||||||
|
onSelect: () => selection.toggle(product.productid),
|
||||||
|
})}
|
||||||
onAsk={(qty) => onAsk(product, qty)}
|
onAsk={(qty) => onAsk(product, qty)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -305,6 +379,7 @@ function CataloguePanel({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
</VStack>
|
||||||
)}
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
@@ -322,11 +397,16 @@ function CatalogueRow({
|
|||||||
request,
|
request,
|
||||||
onOpen,
|
onOpen,
|
||||||
onAsk,
|
onAsk,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
product: Product;
|
product: Product;
|
||||||
request: StockRequest | undefined;
|
request: StockRequest | undefined;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
onAsk: (qty: number) => 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 [isHovered, setIsHovered] = useState(false);
|
||||||
const state = requestState(request);
|
const state = requestState(request);
|
||||||
@@ -355,6 +435,18 @@ function CatalogueRow({
|
|||||||
>
|
>
|
||||||
<Td>
|
<Td>
|
||||||
<HStack gap={1.5} align="center">
|
<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 ?? ''} />
|
<Thumb images={imagesOf(product)} alt={product.productname ?? ''} />
|
||||||
<VStack gap={0} style={{ minWidth: 0 }}>
|
<VStack gap={0} style={{ minWidth: 0 }}>
|
||||||
<Text type="label" size="sm" weight="semibold" maxLines={1}>
|
<Text type="label" size="sm" weight="semibold" maxLines={1}>
|
||||||
|
|||||||
133
src/index.css
133
src/index.css
@@ -1599,3 +1599,136 @@ main {
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
flex: none;
|
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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user