From f7e0bf5e2ccba22c80de41d57bc6c7e143c196fb Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 27 Jul 2026 23:05:14 -0500 Subject: [PATCH 1/2] feat(sponsor-reports): show-wide By Item view for the warehouse pull sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The By Item report only rolled up within a sponsor, so there was no way to read a show-wide total for an item. The warehouse pulls the bulk number for a show first, then assigns that inventory out, and had no view for the first half of that. Adds a layout toggle to the By Item view: - By Sponsor (as before): sponsor accordions -> per-item aggregates - All Sponsors: one row per item_code across every sponsor, drilling down to the contributing orders with the sponsor and destination each unit goes to Both layouts share one accumulator, so what counts as purchased cannot drift between them: canceled lines stay excluded from qty/money/orders while still showing struck-through, and every payment status including Pending counts toward the pull total. Both render through one group container, so the card surface, summary divider and table inset match by construction. Also here: - Sortable Item Code / Item Name / Qty / Orders columns, defaulting to Item Code ascending; order/orderDir live in the by-item slice so the choice survives the Orders toggle. Sorting runs before paging in the all-sponsors layout, and is stable, so the canonical qty-desc rollup survives as the tiebreak. - Expand All / Collapse All over both the group accordions and the per-item drill-downs. Collapse All returns to the default view rather than closing the groups as well. - Export CSV in this view now orders the per-line manifest by item code, so every line for one item lands together with its sponsor and booth — the OVERALL sheet the logistics team reconciles against. item_code was already in the endpoint's ordering_fields; no backend change. The rollup is derived client-side from rows the whole-set fetch already loads, so none of this adds an API call. Co-Authored-By: Claude --- .../__tests__/sponsor-reports-actions.test.js | 18 + src/actions/sponsor-reports-actions.js | 26 +- src/components/sponsors/reports/ByItemView.js | 801 ++++++++++++------ .../reports/__tests__/ByItemView.test.js | 456 +++++++++- src/i18n/en.json | 6 +- .../__tests__/index.test.js | 13 +- .../purchase-details-report-page/index.js | 47 +- ...s-purchase-details-by-item-reducer.test.js | 20 +- ...eports-purchase-details-by-item-reducer.js | 21 +- 9 files changed, 1113 insertions(+), 295 deletions(-) diff --git a/src/actions/__tests__/sponsor-reports-actions.test.js b/src/actions/__tests__/sponsor-reports-actions.test.js index e0c02dc61..0b5493eba 100644 --- a/src/actions/__tests__/sponsor-reports-actions.test.js +++ b/src/actions/__tests__/sponsor-reports-actions.test.js @@ -18,6 +18,7 @@ import { getSponsorAssetSponsor, exportPurchaseDetailsCsv, exportPurchaseDetailsLinesCsv, + LINES_ORDER_BY_ITEM, exportSponsorAssetCsv, exportSponsorAssetSectionCsv, REQUEST_PURCHASE_DETAILS, @@ -1021,6 +1022,23 @@ describe("sponsor-reports-actions", () => { expect(params).not.toHaveProperty("order"); expect(filename).toBe("purchase-details-lines-summit-42.csv"); }); + + it("exportPurchaseDetailsLinesCsv passes an ordering alias through to the query", async () => { + await exportPurchaseDetailsLinesCsv( + { status: "Paid" }, + LINES_ORDER_BY_ITEM + )(dispatch, getState); + const [url, params] = getCSV.mock.calls[0]; + expect(url).toBe( + "http://test-api/api/v1/summits/42/reports/purchase-details/lines/csv" + ); + // item_code is declared in the endpoint's ordering_fields; the warehouse + // sheet needs every line for one item grouped together. + expect(params.order).toBe("item_code"); + expect(params["filter[]"]).toEqual( + expect.arrayContaining(["status==Paid"]) + ); + }); }); // ─── exportSponsorAssetCsv / exportSponsorAssetSectionCsv ─────────────────── diff --git a/src/actions/sponsor-reports-actions.js b/src/actions/sponsor-reports-actions.js index b6fefcc62..3fa35df8d 100644 --- a/src/actions/sponsor-reports-actions.js +++ b/src/actions/sponsor-reports-actions.js @@ -57,6 +57,12 @@ export const PURCHASE_DETAILS_BY_ITEM_READ_ERROR = "PURCHASE_DETAILS_BY_ITEM_READ_ERROR"; export const SET_PURCHASE_DETAILS_BY_ITEM_PAGING = "SET_PURCHASE_DETAILS_BY_ITEM_PAGING"; +export const SET_PURCHASE_DETAILS_BY_ITEM_SORT = + "SET_PURCHASE_DETAILS_BY_ITEM_SORT"; + +// Ordering alias on the lines endpoint (ordering_fields.item_code). Groups every +// line for one item together — the shape the warehouse pull sheet needs. +export const LINES_ORDER_BY_ITEM = "item_code"; // Per-thunk sequence-token factory guarding against stale-response commits. // Two concurrent invocations of the same thunk (different filters/page/sponsor) @@ -596,6 +602,16 @@ export const setPurchaseDetailsByItemPaging = ); }; +// Client-side sort of the derived item rows — no server ordering exists for this +// rollup. Same pure-dispatch shape as the paging sibling above. +export const setPurchaseDetailsByItemSort = + ({ order, orderDir }) => + (dispatch) => { + dispatch( + createAction(SET_PURCHASE_DETAILS_BY_ITEM_SORT)({ order, orderDir }) + ); + }; + // Orders CSV export — owns URL + params + filename (cf. exportEventRsvpsCSV). // Keeps the on-screen sort so the exported rows match what the user sees. // No page/perPage → buildPurchaseQuery emits neither; backend exports the full @@ -618,17 +634,19 @@ export const exportPurchaseDetailsCsv = ); }; -// Per-line CSV export — no order param (backend default ordering keeps sponsor -// groups intact; see lines query comment in the page). +// Per-line CSV export. `order` is an ordering alias declared by the endpoint +// (sponsor | order_date | item_code | quantity); omit it to keep the backend +// default, which groups by sponsor name. The By Item view passes item_code so +// every line for one item lands together — the warehouse pull sheet. export const exportPurchaseDetailsLinesCsv = - (filters = {}) => + (filters = {}, order = null) => async (dispatch, getState) => { const { currentSummit } = getState().currentSummitState; if (!currentSummit?.id) return Promise.resolve(); const accessToken = await getAccessTokenSafely(); const params = { access_token: accessToken, - ...buildPurchaseLinesQuery(filters, {}) + ...buildPurchaseLinesQuery({ ...filters, order }, {}) }; return dispatch( getCSV( diff --git a/src/components/sponsors/reports/ByItemView.js b/src/components/sponsors/reports/ByItemView.js index db1a60f66..29a923fad 100644 --- a/src/components/sponsors/reports/ByItemView.js +++ b/src/components/sponsors/reports/ByItemView.js @@ -17,6 +17,7 @@ import { AccordionDetails, AccordionSummary, Box, + Button, Chip, IconButton, Table, @@ -26,8 +27,12 @@ import { TableHead, TablePagination, TableRow, + TableSortLabel, + ToggleButton, + ToggleButtonGroup, Typography } from "@mui/material"; +import { visuallyHidden } from "@mui/utils"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; @@ -43,8 +48,8 @@ import { } from "../../../utils/constants"; import { isEmptyString } from "../../../utils/methods"; -// Pure rollup for the Purchase Details "By Item" view (Layout A): flat line rows -// → sponsor groups → per-item aggregates. Named export from the component that +// Pure rollup for the Purchase Details "By Item" view: flat line rows → sponsor +// groups → per-item aggregates. Named export from the component that // owns the concept (cf. statusTone in StatusPill) — the page imports it from // here for its useMemo. NO data filtering: qty-0 lines and canceled lines flow // through — the report's contract is "show everything, visually annotate". @@ -55,6 +60,97 @@ import { isEmptyString } from "../../../utils/methods"; const NO_SPONSOR_KEY = Symbol("no_sponsor"); const NO_CODE_KEY = Symbol("no_code"); +// Folds one line row into an item bucket. Shared by both layouts: "by sponsor" +// calls it with a per-sponsor map, "all sponsors" with a single show-wide map. +// The two layouts must never diverge on what counts as purchased. +const accumulateRow = (itemMap, row) => { + const code = isEmptyString(row.item_code) + ? null + : String(row.item_code).trim(); + const itemKey = code === null ? NO_CODE_KEY : code; + if (!itemMap.has(itemKey)) { + itemMap.set(itemKey, { + itemCode: code, + label: "", + qty: 0, + lines: 0, + totalCents: null, + orderIds: new Set(), + statusOrderIds: new Map(), + contributors: [] + }); + } + const item = itemMap.get(itemKey); + if (isEmptyString(item.label) && !isEmptyString(row.description)) { + item.label = row.description.trim(); + } + item.lines += 1; + // Canceled lines are shown struck-through in the drill-down (as contributors + // below) but excluded from ALL "purchased" aggregates: qty, money, orders, + // and statusMix. Counting them would let a canceled-only line report an item + // as purchased (Qty 0 next to Orders 1 / Paid 1). A mixed order keeps its + // count because the live line for the same item still adds the order id. + if (!row.is_canceled) { + item.qty += row.quantity ?? 0; + // Null-safe money: all-null stays null (renders "—"); mixed sums non-nulls. + if (row.line_total != null) { + item.totalCents = (item.totalCents ?? 0) + row.line_total; + } + const purchaseId = row.purchase?.id ?? null; + if (purchaseId != null) { + item.orderIds.add(purchaseId); + const status = row.purchase?.status ?? ""; + if (!item.statusOrderIds.has(status)) { + item.statusOrderIds.set(status, new Set()); + } + item.statusOrderIds.get(status).add(purchaseId); + } + } + item.contributors.push({ + // Rendered only by the all-sponsors layout, where the sponsor is no longer + // the parent row: "10 monitors to Intel, 3 to Nvidia". + sponsorName: row.sponsor?.name ?? "", + number: row.purchase?.number ?? "", + formCode: row.form?.code ?? "", + addOnName: row.add_on_name ?? null, + sponsorBooth: row.sponsor_booth ?? null, + checkoutAt: row.purchase?.checkout_at ?? null, + rateName: row.rate_name ?? "", + status: row.purchase?.status ?? "", + qty: row.quantity ?? 0, + lineTotalCents: row.line_total ?? null, + isCanceled: Boolean(row.is_canceled) + }); +}; + +// Item buckets → sorted display rows. Drops the accumulator's Sets/Maps so no +// mutable collection reaches props. +const finalizeItems = (itemMap) => { + const items = [...itemMap.values()].map((it) => ({ + itemCode: it.itemCode, + label: it.label, + qty: it.qty, + orders: it.orderIds.size, + lines: it.lines, + totalCents: it.totalCents, + statusMix: Object.fromEntries( + [...it.statusOrderIds.entries()].map(([status, ids]) => [ + status, + ids.size + ]) + ), + contributors: it.contributors + })); + // Canonical order: qty desc, then orders desc, label asc as a deterministic + // tiebreak. The view sorts on top of this; because that sort is stable, this + // ordering survives underneath as the tiebreak for equal values. + items.sort( + (a, b) => + b.qty - a.qty || b.orders - a.orders || a.label.localeCompare(b.label) + ); + return items; +}; + export const groupLinesBySponsorItem = (rows = []) => { const sponsorMap = new Map(); rows.forEach((row) => { @@ -67,90 +163,16 @@ export const groupLinesBySponsorItem = (rows = []) => { itemMap: new Map() }); } - const group = sponsorMap.get(sponsorKey); - const code = isEmptyString(row.item_code) - ? null - : String(row.item_code).trim(); - const itemKey = code === null ? NO_CODE_KEY : code; - if (!group.itemMap.has(itemKey)) { - group.itemMap.set(itemKey, { - itemCode: code, - label: "", - qty: 0, - lines: 0, - totalCents: null, - orderIds: new Set(), - statusOrderIds: new Map(), - contributors: [] - }); - } - const item = group.itemMap.get(itemKey); - if (isEmptyString(item.label) && !isEmptyString(row.description)) { - item.label = row.description.trim(); - } - item.lines += 1; - // Canceled lines are shown struck-through in the drill-down (as contributors - // below) but excluded from ALL "purchased" aggregates: qty, money, orders, - // and statusMix. Counting them would let a canceled-only line report an item - // as purchased (Qty 0 next to Orders 1 / Paid 1). A mixed order keeps its - // count because the live line for the same item still adds the order id. - if (!row.is_canceled) { - item.qty += row.quantity ?? 0; - // Null-safe money: all-null stays null (renders "—"); mixed sums non-nulls. - if (row.line_total != null) { - item.totalCents = (item.totalCents ?? 0) + row.line_total; - } - const purchaseId = row.purchase?.id ?? null; - if (purchaseId != null) { - item.orderIds.add(purchaseId); - const status = row.purchase?.status ?? ""; - if (!item.statusOrderIds.has(status)) { - item.statusOrderIds.set(status, new Set()); - } - item.statusOrderIds.get(status).add(purchaseId); - } - } - item.contributors.push({ - number: row.purchase?.number ?? "", - formCode: row.form?.code ?? "", - addOnName: row.add_on_name ?? null, - sponsorBooth: row.sponsor_booth ?? null, - checkoutAt: row.purchase?.checkout_at ?? null, - rateName: row.rate_name ?? "", - status: row.purchase?.status ?? "", - qty: row.quantity ?? 0, - lineTotalCents: row.line_total ?? null, - isCanceled: Boolean(row.is_canceled) - }); + accumulateRow(sponsorMap.get(sponsorKey).itemMap, row); }); const groups = [...sponsorMap.values()].map((g) => { - const items = [...g.itemMap.values()].map((it) => ({ - itemCode: it.itemCode, - label: it.label, - qty: it.qty, - orders: it.orderIds.size, - lines: it.lines, - totalCents: it.totalCents, - statusMix: Object.fromEntries( - [...it.statusOrderIds.entries()].map(([status, ids]) => [ - status, - ids.size - ]) - ), - contributors: it.contributors - })); - // "Sorted by Qty ↓, then Orders"; label asc as a deterministic tiebreak. - items.sort( - (a, b) => - b.qty - a.qty || b.orders - a.orders || a.label.localeCompare(b.label) - ); - const totalQty = items.reduce((acc, it) => acc + it.qty, 0); + const items = finalizeItems(g.itemMap); return { sponsorId: g.sponsorId, sponsorName: g.sponsorName, items, - totalQty, + totalQty: items.reduce((acc, it) => acc + it.qty, 0), itemCount: items.length, purchasedCount: items.filter((it) => it.qty > 0).length }; @@ -162,15 +184,54 @@ export const groupLinesBySponsorItem = (rows = []) => { return groups; }; +// Show-wide rollup: one row per item_code across every sponsor — the warehouse +// pull sheet ("how many 43\" monitors for this show"). Same item aggregate as +// the by-sponsor layout, with the sponsor level removed; each contributing +// order carries its sponsor so the drill-down answers where they go. Reports +// are always summit-scoped (route + queryset), so "the show" needs no filter. +export const groupLinesByItem = (rows = []) => { + const itemMap = new Map(); + rows.forEach((row) => accumulateRow(itemMap, row)); + return finalizeItems(itemMap); +}; + +// sortKey names the DERIVED item-row field, not an API ordering field — this +// rollup has no server-side sort. Columns without one are not sortable. const ITEM_HEADERS = [ - { key: "col_item_code" }, - { key: "col_item_name" }, - { key: "col_quantity", align: "right" }, - { key: "byitem_col_orders", align: "right" }, + { key: "col_item_code", sortKey: "itemCode" }, + { key: "col_item_name", sortKey: "label" }, + { key: "col_quantity", align: "right", sortKey: "qty" }, + { key: "byitem_col_orders", align: "right", sortKey: "orders" }, { key: "byitem_col_total", align: "right" }, { key: "col_status" } ]; +// orderDir is the repo's numeric 1 / -1 (MuiTable contract), MUI wants a word. +const DIRECTION = { 1: "asc", "-1": "desc" }; + +const SORT_ACCESSORS = { + // Null code (the no-code bucket, rendered "—") sorts as empty: first ascending. + itemCode: (it) => it.itemCode ?? "", + label: (it) => it.label, + qty: (it) => it.qty, + orders: (it) => it.orders +}; + +// Re-sorts already-canonical rows (finalizeItems emits qty↓, orders↓, label↑). +// Array.prototype.sort is stable, so ties keep that canonical order instead of +// landing arbitrarily — no explicit tiebreak needed here. Unknown key = no-op. +export const sortItems = (items, order, orderDir) => { + const pick = SORT_ACCESSORS[order]; + if (!pick) return items; + return [...items].sort((a, b) => { + const left = pick(a); + const right = pick(b); + const cmp = + typeof left === "number" ? left - right : left.localeCompare(right); + return orderDir === 1 ? cmp : -cmp; + }); +}; + const CONTRIB_HEADERS = [ { key: "col_order" }, { key: "col_form_code" }, @@ -188,24 +249,275 @@ const CONTRIB_HEADERS = [ const itemKey = (group, item) => JSON.stringify([group.sponsorId ?? null, item.itemCode ?? null]); -// Rollup of the whole-set By Item data (Layout A: sponsor accordions → item -// table → contributing-orders drill-down). Pagination is CLIENT-side over the -// sponsor groups; the parent owns page/perPage (redux) — this component only -// clamps the display when the group list shrinks under the current page. +// The item table both layouts render: one row per item_code with the purchased +// aggregates, expanding to the orders that contributed. `showSponsor` adds the +// Sponsor column to the drill-down for the all-sponsors layout, where the +// sponsor is no longer carried by a parent accordion. +// The group container BOTH layouts render: an accordion whose summary carries +// the title, the items chip and the Σ qty. One component, not two call sites, so +// the card surface, summary divider and AccordionDetails inset stay identical +// between the layouts instead of being matched by hand. +const ItemGroup = ({ + title, + itemCount, + purchasedCount, + totalQty, + expanded, + onToggle, + children +}) => ( + + }> + {title} + + + {T.translate("sponsor_reports_page.byitem_sum_qty", { qty: totalQty })} + + + {children} + +); + +const ItemTable = ({ + items, + keyFor, + expandedItems, + onToggle, + order, + orderDir, + onSort, + showSponsor = false +}) => { + const contribHeaders = showSponsor + ? [{ key: "col_sponsor" }, ...CONTRIB_HEADERS] + : CONTRIB_HEADERS; + return ( + + + + + + {ITEM_HEADERS.map((h) => { + const label = T.translate(`sponsor_reports_page.${h.key}`); + const active = order === h.sortKey; + return ( + + {h.sortKey ? ( + onSort(h.sortKey, orderDir * -1)} + > + {label} + {active ? ( + + {T.translate( + orderDir === 1 + ? "mui_table.sorted_asc" + : "mui_table.sorted_desc" + )} + + ) : null} + + ) : ( + label + )} + + ); + })} + + + + {items.map((item) => { + const key = keyFor(item); + const expanded = expandedItems.has(key); + return ( + + onToggle(key)} + sx={{ cursor: "pointer" }} + > + + { + // Row click also toggles; don't double-fire. + e.stopPropagation(); + onToggle(key); + }} + > + {expanded ? ( + + ) : ( + + )} + + + {item.itemCode ?? "—"} + {item.label} + + {item.qty} + + {item.orders} + + {item.totalCents == null + ? "—" + : currencyAmountFromCents(item.totalCents)} + + + `${status}: ${count}` + )} + maxLength={Object.keys(item.statusMix).length} + /> + + + {expanded && ( + + + + {T.translate( + "sponsor_reports_page.byitem_contributing_orders" + )} + +
+ + + {contribHeaders.map((h) => ( + + {T.translate(`sponsor_reports_page.${h.key}`)} + + ))} + + + + {item.contributors.map((c, idx) => ( + + {showSponsor && ( + {c.sponsorName} + )} + {c.number} + {c.formCode} + + + + + {formatCheckoutTime(c.checkoutAt)} + + {c.rateName} + + + + {c.qty} + + {c.lineTotalCents == null + ? "—" + : currencyAmountFromCents(c.lineTotalCents)} + + + ))} + +
+ + + )} + + ); + })} + + +
+ ); +}; + +// The all-sponsors layout is a single group; by-sponsor keys on the sponsor id. +const ALL_SPONSORS_GROUP_KEY = "__all__"; +const groupKeyOf = (group) => group.sponsorId ?? "__null__"; + +// Expansion key for the all-sponsors layout. Distinct first element so a +// show-wide item can never collide with a by-sponsor key whose sponsor id is +// null (the unknown-sponsor bucket). +const allItemKey = (item) => JSON.stringify(["all", item.itemCode ?? null]); + +// Rollup of the whole-set By Item data, in two layouts over the same rows: +// "sponsor" — sponsor accordions → item table (who ordered what) +// "item" — one row per item across every sponsor (what to pull for the +// show), drilling down to which sponsor/destination each goes to +// Pagination is CLIENT-side over whichever list the active layout shows; the +// parent owns page/perPage (redux) — this component only clamps the display +// when that list shrinks under the current page. const ByItemView = ({ groups = [], + items = [], + layout = "sponsor", + onLayoutChange, + order, + orderDir, + onSort, currentPage = DEFAULT_CURRENT_PAGE, perPage = DEFAULT_PER_PAGE, onPageChange, onPerPageChange }) => { const [expandedItems, setExpandedItems] = useState(() => new Set()); - const lastPage = Math.max(1, Math.ceil(groups.length / perPage)); + // Groups are expanded by default, so track the COLLAPSED ones — an empty set + // is the initial state either way, and new groups arrive expanded. + const [collapsedGroups, setCollapsedGroups] = useState(() => new Set()); + const byItem = layout === "item"; + // Sort BEFORE paging: the all-sponsors layout pages over items, so sorting + // has to decide which items land on the page, not just their order within it. + const sortedItems = byItem ? sortItems(items, order, orderDir) : items; + const list = byItem ? sortedItems : groups; + const lastPage = Math.max(1, Math.ceil(list.length / perPage)); const displayPage = Math.min(currentPage, lastPage); - const paged = groups.slice( - (displayPage - 1) * perPage, - displayPage * perPage - ); + const paged = list.slice((displayPage - 1) * perPage, displayPage * perPage); + // Σ over the WHOLE item list, not the page: the pull total is the point. + const totalQty = items.reduce((acc, it) => acc + it.qty, 0); + const purchasedCount = items.filter((it) => it.qty > 0).length; const toggleItem = (key) => { setExpandedItems((prev) => { @@ -219,202 +531,135 @@ const ByItemView = ({ }); }; + const toggleGroup = (key) => { + setCollapsedGroups((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }; + + // Expand/collapse everything expandable: the group accordion(s) AND every + // item's contributing-orders drill-down. Keys cover the WHOLE list, not just + // the current page, so paging forward keeps the state you asked for. + const allItemKeys = () => + byItem + ? sortedItems.map(allItemKey) + : groups.flatMap((group) => + group.items.map((item) => itemKey(group, item)) + ); + + const expandAll = () => { + setCollapsedGroups(new Set()); + setExpandedItems(new Set(allItemKeys())); + }; + + // Collapse All closes the DRILL-DOWNS only, landing you back on the default + // view — item rows visible inside their groups. Collapsing the groups too + // overshoots it: you get a wall of sponsor headers and no data. Group state is + // left untouched rather than reset, so a group you closed by hand stays closed. + const collapseAll = () => setExpandedItems(new Set()); + return ( - - {T.translate("sponsor_reports_page.byitem_sorted_caption")} - - {paged.map((group) => ( - - }> - - {group.sponsorName || - T.translate("sponsor_reports_page.pivot_unknown_sponsor")} - - { + // MUI passes null when the active button is re-clicked in exclusive + // mode; ignore it so a layout is always selected (cf. + // ReportViewToggle). + if (next) onLayoutChange(next); + }} + aria-label={T.translate("sponsor_reports_page.group_by")} + > + + {T.translate("sponsor_reports_page.byitem_layout_sponsor")} + + + {T.translate("sponsor_reports_page.byitem_layout_all_sponsors")} + + + + + + + + {byItem && ( + toggleGroup(ALL_SPONSORS_GROUP_KEY)} + > + + + )} + {!byItem && + paged.map((group) => ( + toggleGroup(groupKeyOf(group))} + title={ + group.sponsorName || + T.translate("sponsor_reports_page.pivot_unknown_sponsor") + } + itemCount={group.itemCount} + purchasedCount={group.purchasedCount} + totalQty={group.totalQty} + > + itemKey(group, item)} + expandedItems={expandedItems} + onToggle={toggleItem} + order={order} + orderDir={orderDir} + onSort={onSort} /> - - {T.translate("sponsor_reports_page.byitem_sum_qty", { - qty: group.totalQty - })} - - - - - - - - - {ITEM_HEADERS.map((h) => ( - - {T.translate(`sponsor_reports_page.${h.key}`)} - - ))} - - - - {group.items.map((item) => { - const key = itemKey(group, item); - const expanded = expandedItems.has(key); - return ( - - toggleItem(key)} - sx={{ cursor: "pointer" }} - > - - { - // Row click also toggles; don't double-fire. - e.stopPropagation(); - toggleItem(key); - }} - > - {expanded ? ( - - ) : ( - - )} - - - {item.itemCode ?? "—"} - {item.label} - - {item.qty} - - {item.orders} - - {item.totalCents == null - ? "—" - : currencyAmountFromCents(item.totalCents)} - - - `${status}: ${count}` - )} - maxLength={Object.keys(item.statusMix).length} - /> - - - {expanded && ( - - - - {T.translate( - "sponsor_reports_page.byitem_contributing_orders" - )} - -
- - - {CONTRIB_HEADERS.map((h) => ( - - {T.translate( - `sponsor_reports_page.${h.key}` - )} - - ))} - - - - {item.contributors.map((c, idx) => ( - - {c.number} - {c.formCode} - - - - - {formatCheckoutTime(c.checkoutAt)} - - {c.rateName} - - - - - {c.qty} - - - {c.lineTotalCents == null - ? "—" - : currencyAmountFromCents( - c.lineTotalCents - )} - - - ))} - -
- - - )} - - ); - })} - - -
-
- - ))} +
+ ))} onPageChange(zeroBased + 1)} onRowsPerPageChange={(e) => onPerPageChange(Number(e.target.value))} diff --git a/src/components/sponsors/reports/__tests__/ByItemView.test.js b/src/components/sponsors/reports/__tests__/ByItemView.test.js index a6bdfa810..46aafdaf6 100644 --- a/src/components/sponsors/reports/__tests__/ByItemView.test.js +++ b/src/components/sponsors/reports/__tests__/ByItemView.test.js @@ -1,7 +1,11 @@ import "@testing-library/jest-dom"; import React from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; -import ByItemView, { groupLinesBySponsorItem } from "../ByItemView"; +import { render, screen, fireEvent, within } from "@testing-library/react"; +import ByItemView, { + groupLinesByItem, + groupLinesBySponsorItem, + sortItems +} from "../ByItemView"; jest.mock("i18n-react/dist/i18n-react", () => ({ translate: (k, opts) => (opts ? `${k}:${Object.values(opts).join(",")}` : k) @@ -130,6 +134,7 @@ describe("groupLinesBySponsorItem", () => { const [group] = groupLinesBySponsorItem(rows); const [contrib] = group.items[0].contributors; expect(contrib).toEqual({ + sponsorName: "Acme", number: "OCP-1", formCode: "AV", addOnName: "Meeting Room T", @@ -218,6 +223,91 @@ describe("groupLinesBySponsorItem", () => { }); }); +describe("groupLinesByItem", () => { + it("merges an item_code ACROSS sponsors into one row and names each sponsor in the drill-down", () => { + const rows = [ + line({ sponsor: { id: 1, name: "Intel" }, quantity: 10 }), + line({ + sponsor: { id: 2, name: "Nvidia" }, + purchase: { id: 5002, number: "OCP-2", status: "Paid" }, + quantity: 3, + add_on_name: "Meeting Room T" + }) + ]; + const items = groupLinesByItem(rows); + expect(items).toHaveLength(1); + const [av1] = items; + expect(av1.itemCode).toBe("AV1"); + expect(av1.qty).toBe(13); + expect(av1.orders).toBe(2); + expect(av1.contributors.map((c) => c.sponsorName)).toEqual([ + "Intel", + "Nvidia" + ]); + }); + + it("counts PENDING orders toward the pull total, not just Paid", () => { + const rows = [ + line({ quantity: 4, purchase: { id: 1, number: "A", status: "Paid" } }), + line({ + quantity: 6, + purchase: { id: 2, number: "B", status: "Pending Payment" } + }) + ]; + const [av1] = groupLinesByItem(rows); + expect(av1.qty).toBe(10); + expect(av1.statusMix).toEqual({ Paid: 1, "Pending Payment": 1 }); + }); + + it("excludes canceled lines from qty but keeps them as contributors (parity with the by-sponsor layout)", () => { + const rows = [ + line({ quantity: 4 }), + line({ + quantity: 9, + is_canceled: true, + purchase: { id: 2, number: "B", status: "Paid" } + }) + ]; + const [av1] = groupLinesByItem(rows); + expect(av1.qty).toBe(4); + expect(av1.orders).toBe(1); + expect(av1.contributors).toHaveLength(2); + }); + + it("reconciles with the by-sponsor layout AND with the raw input total", () => { + const rows = [ + line({ sponsor: { id: 1, name: "Intel" }, quantity: 10 }), + line({ sponsor: { id: 2, name: "Nvidia" }, quantity: 3 }), + line({ + sponsor: { id: 2, name: "Nvidia" }, + item_code: "B1", + quantity: 7 + }), + // Pending counts toward the pull total, so it must survive BOTH groupings + // and the independent sum below. + line({ + sponsor: { id: 3, name: "Meta" }, + quantity: 6, + purchase: { id: 9, number: "N9", status: "Pending Payment" } + }), + line({ quantity: 5, is_canceled: true }) + ]; + const flatQty = groupLinesByItem(rows).reduce((a, it) => a + it.qty, 0); + const nestedQty = groupLinesBySponsorItem(rows).reduce( + (a, g) => a + g.totalQty, + 0 + ); + // Computed from the fixture, NOT from the shared accumulator: cross-layout + // equality alone would stay green if accumulateRow dropped a whole status. + const inputQty = rows + .filter((r) => !r.is_canceled) + .reduce((a, r) => a + r.quantity, 0); + expect(flatQty).toBe(nestedQty); + expect(flatQty).toBe(inputQty); + expect(flatQty).toBe(26); + }); +}); + const item = (over = {}) => ({ itemCode: "AV1", label: "Audio mixer", @@ -228,6 +318,7 @@ const item = (over = {}) => ({ statusMix: { Paid: 1, "Pending Payment": 1 }, contributors: [ { + sponsorName: "Intel", number: "OCP-1", formCode: "AV", addOnName: "Meeting Room T", @@ -239,6 +330,7 @@ const item = (over = {}) => ({ isCanceled: false }, { + sponsorName: "Nvidia", number: "OCP-2", formCode: "AV", addOnName: null, @@ -376,6 +468,366 @@ describe("ByItemView", () => { }); }); +describe("sortItems", () => { + const row = (over) => item(over); + + it("sorts by each supported key in both directions", () => { + const rows = [ + row({ itemCode: "B1", label: "Beta", qty: 5, orders: 1 }), + row({ itemCode: "A1", label: "Alpha", qty: 9, orders: 3 }), + row({ itemCode: "C1", label: "Gamma", qty: 1, orders: 2 }) + ]; + const codes = (o, d) => sortItems(rows, o, d).map((r) => r.itemCode); + expect(codes("itemCode", 1)).toEqual(["A1", "B1", "C1"]); + expect(codes("itemCode", -1)).toEqual(["C1", "B1", "A1"]); + expect(codes("label", 1)).toEqual(["A1", "B1", "C1"]); + expect(codes("qty", 1)).toEqual(["C1", "B1", "A1"]); + expect(codes("qty", -1)).toEqual(["A1", "B1", "C1"]); + expect(codes("orders", 1)).toEqual(["B1", "C1", "A1"]); + }); + + it("is stable, so ties keep the rollup's canonical order", () => { + // >10 rows so the engine takes its merge path, not just binary insertion, + // and every row ties on the sort key: any reordering here is instability. + const canonical = Array.from({ length: 16 }, (_, i) => `T${i}`); + const rows = canonical.map((code) => row({ itemCode: code, qty: 4 })); + expect(sortItems(rows, "qty", -1).map((r) => r.itemCode)).toEqual( + canonical + ); + expect(sortItems(rows, "qty", 1).map((r) => r.itemCode)).toEqual(canonical); + }); + + it("does not mutate the input and no-ops on an unknown key", () => { + const rows = [row({ itemCode: "B1", qty: 1 }), row({ itemCode: "A1" })]; + const sorted = sortItems(rows, "itemCode", 1); + expect(rows.map((r) => r.itemCode)).toEqual(["B1", "A1"]); + expect(sorted).not.toBe(rows); + expect(sortItems(rows, "nope", 1)).toBe(rows); + }); + + it("sorts the null item_code bucket as empty rather than throwing", () => { + const rows = [row({ itemCode: "A1" }), row({ itemCode: null })]; + expect(sortItems(rows, "itemCode", 1).map((r) => r.itemCode)).toEqual([ + null, + "A1" + ]); + }); +}); + +describe("ByItemView sorting", () => { + // The ACTIVE column's accessible name also carries the visually-hidden + // direction announcement, so match on a prefix rather than the bare label. + const SORTABLE = [ + /^sponsor_reports_page\.col_item_code/, + /^sponsor_reports_page\.col_item_name/, + /^sponsor_reports_page\.col_quantity/, + /^sponsor_reports_page\.byitem_col_orders/ + ]; + + it("offers a sort control on exactly the four sortable columns", () => { + renderView({ order: "qty", orderDir: -1, onSort: jest.fn() }); + SORTABLE.forEach((name) => { + expect(screen.getByRole("button", { name })).toBeInTheDocument(); + }); + // The active column announces its direction to screen readers. + expect( + screen.getByRole("button", { + name: /col_quantity mui_table\.sorted_desc/ + }) + ).toBeInTheDocument(); + // Total and Status are not sortable — plain header text, no button. + expect( + screen.queryByRole("button", { + name: "sponsor_reports_page.byitem_col_total" + }) + ).not.toBeInTheDocument(); + }); + + it("flips the direction when the active column is clicked again", () => { + const onSort = jest.fn(); + renderView({ order: "qty", orderDir: -1, onSort }); + fireEvent.click( + screen.getByRole("button", { + name: /^sponsor_reports_page\.col_quantity/ + }) + ); + expect(onSort).toHaveBeenCalledWith("qty", 1); + }); + + it("reports the new column with the current direction", () => { + const onSort = jest.fn(); + renderView({ order: "qty", orderDir: -1, onSort }); + fireEvent.click( + screen.getByRole("button", { name: "sponsor_reports_page.col_item_code" }) + ); + expect(onSort).toHaveBeenCalledWith("itemCode", 1); + }); + + it("marks only the active column with a sort direction", () => { + renderView({ order: "label", orderDir: 1, onSort: jest.fn() }); + const active = screen + .getByRole("button", { name: /col_item_name/ }) + .closest("th"); + expect(active).toHaveAttribute("aria-sort", "ascending"); + const inactive = screen + .getByRole("button", { name: /col_quantity/ }) + .closest("th"); + expect(inactive).not.toHaveAttribute("aria-sort"); + }); + + it("sorts BEFORE paging in the all-sponsors layout", () => { + // 12 items arriving in DESCENDING qty (the rollup's canonical order), page + // size 10. Ascending sort must pull the two smallest onto page 1 — which + // only happens if the sort runs before the slice, not within the page. + const items = Array.from({ length: 12 }, (_, i) => + item({ itemCode: `SKU${11 - i}`, label: `Item ${11 - i}`, qty: 11 - i }) + ); + renderView({ + layout: "item", + items, + groups: [], + onLayoutChange: jest.fn(), + order: "qty", + orderDir: 1, + onSort: jest.fn() + }); + expect(screen.getByText("SKU0")).toBeInTheDocument(); + // The two largest fall to page 2. + expect(screen.queryByText("SKU11")).not.toBeInTheDocument(); + expect(screen.queryByText("SKU10")).not.toBeInTheDocument(); + }); + + it("reorders items within a sponsor without reordering the sponsors", () => { + const groups = [ + group({ + sponsorId: 1, + sponsorName: "Big", + items: [ + item({ itemCode: "B1", label: "Beta", qty: 9 }), + item({ itemCode: "A1", label: "Alpha", qty: 1 }) + ] + }), + group({ sponsorId: 2, sponsorName: "Small", items: [] }) + ]; + renderView({ groups, order: "label", orderDir: 1, onSort: jest.fn() }); + const codes = screen + .getAllByText(/^(A1|B1)$/) + .map((node) => node.textContent); + expect(codes).toEqual(["A1", "B1"]); + // Sponsor accordion order is independent of the item sort. + const names = screen + .getAllByText(/^(Big|Small)$/) + .map((node) => node.textContent); + expect(names).toEqual(["Big", "Small"]); + }); +}); + +describe("ByItemView expand/collapse all", () => { + const clickBtn = (name) => + fireEvent.click(screen.getByRole("button", { name })); + // The group accordion exposes its state as aria-expanded on the summary — + // assert that rather than MUI's internal Mui-expanded class. Matched on the + // Σ-units label, which only a group summary carries: the layout toggle shares + // its name with the all-sponsors group title. + const groupHeader = () => + screen.getByRole("button", { name: /byitem_sum_qty/ }); + + it("expands every item drill-down AND the group, in the by-sponsor layout", () => { + renderView({ + groups: [ + group({ + sponsorId: 1, + sponsorName: "Intel", + items: [item({ itemCode: "A1" }), item({ itemCode: "B1" })] + }) + ] + }); + // Drill-downs start closed: no contributing order visible. + expect(screen.queryByText("OCP-1")).not.toBeInTheDocument(); + clickBtn("sponsor_reports_page.byitem_expand_all"); + // Both items' contributing orders now render (2 items x 2 contributors). + expect(screen.getAllByText("OCP-1")).toHaveLength(2); + }); + + it("closes the drill-downs but LEAVES the group open — back to the default view", () => { + renderView(); + clickBtn("sponsor_reports_page.byitem_expand_all"); + expect(screen.getByText("OCP-1")).toBeInTheDocument(); + clickBtn("sponsor_reports_page.byitem_collapse_all"); + expect(screen.queryByText("OCP-1")).not.toBeInTheDocument(); + // Collapsing the group too would leave a wall of headers and no data. + expect(groupHeader()).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText("AV1")).toBeInTheDocument(); + }); + + it("does not reopen a group the user closed by hand", () => { + renderView(); + fireEvent.click(groupHeader()); + expect(groupHeader()).toHaveAttribute("aria-expanded", "false"); + clickBtn("sponsor_reports_page.byitem_collapse_all"); + expect(groupHeader()).toHaveAttribute("aria-expanded", "false"); + }); + + it("works the same in the all-sponsors layout", () => { + renderView({ + layout: "item", + items: [item()], + groups: [], + onLayoutChange: jest.fn() + }); + clickBtn("sponsor_reports_page.byitem_expand_all"); + expect(screen.getByText("OCP-1")).toBeInTheDocument(); + clickBtn("sponsor_reports_page.byitem_collapse_all"); + expect(screen.queryByText("OCP-1")).not.toBeInTheDocument(); + expect(groupHeader()).toHaveAttribute("aria-expanded", "true"); + }); + + it("expands items beyond the current page, so paging keeps the state", () => { + // 12 items over a 10-row page: the 11th is not rendered yet, but Expand All + // must have keyed it too or it would come back collapsed on page 2. + const items = Array.from({ length: 12 }, (_, i) => + item({ itemCode: `SKU${i}`, label: `Item ${i}` }) + ); + const { rerender } = renderView({ + layout: "item", + items, + groups: [], + onLayoutChange: jest.fn() + }); + clickBtn("sponsor_reports_page.byitem_expand_all"); + rerender( + + ); + expect(screen.getByText("SKU10")).toBeInTheDocument(); + // Page 2's rows arrive already expanded. + expect(screen.getAllByText("OCP-1").length).toBeGreaterThan(0); + }); + + it("re-expands a group the user closed by hand, not just the drill-downs", () => { + renderView(); + fireEvent.click(groupHeader()); + expect(groupHeader()).toHaveAttribute("aria-expanded", "false"); + clickBtn("sponsor_reports_page.byitem_expand_all"); + // The group must come back open — otherwise Expand All strands the user + // with an expanded drill-down inside a collapsed accordion. + expect(groupHeader()).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText("OCP-1")).toBeInTheDocument(); + }); + + it("leaves individual toggles working after a bulk action", () => { + renderView(); + clickBtn("sponsor_reports_page.byitem_expand_all"); + expect(screen.getByText("OCP-1")).toBeInTheDocument(); + fireEvent.click(screen.getByText("AV1")); + expect(screen.queryByText("OCP-1")).not.toBeInTheDocument(); + }); +}); + +describe("ByItemView all-sponsors layout", () => { + const renderAll = (props = {}) => + renderView({ + layout: "item", + items: [item()], + groups: [], + onLayoutChange: jest.fn(), + ...props + }); + + // Styling parity by construction: one shared container => one card surface, + // summary divider and details inset, with no sx values to keep in step. + // Asserted through the accordion's ACCESSIBLE contract — a collapsible group + // header, and the item table living inside that group's region — so a MUI + // class rename cannot fail this while behaviour is unchanged. + it.each([ + ["by-sponsor", () => renderView()], + ["all-sponsors", () => renderAll()] + ])("renders the %s layout in the same group container", (_name, mount) => { + mount(); + const header = screen.getByRole("button", { name: /byitem_sum_qty/ }); + expect(header).toHaveAttribute("aria-expanded", "true"); + expect( + within(screen.getByRole("region")).getByRole("table") + ).toBeInTheDocument(); + }); + + it("drops the sponsor accordions and totals the show in one header", () => { + renderAll({ items: [item(), item({ itemCode: "B1", qty: 7 })] }); + expect(screen.queryByText("Acme")).not.toBeInTheDocument(); + // 2 items, both purchased; Σ qty over the WHOLE list, not the page. + expect( + screen.getByText("sponsor_reports_page.byitem_sponsor_items_chip:2,2") + ).toBeInTheDocument(); + expect( + screen.getByText("sponsor_reports_page.byitem_sum_qty:12") + ).toBeInTheDocument(); + }); + + it("names the sponsor per contributing order in the drill-down", () => { + renderAll(); + fireEvent.click(screen.getByText("AV1")); + expect(screen.getByText("Intel")).toBeInTheDocument(); + expect(screen.getByText("Nvidia")).toBeInTheDocument(); + expect(screen.getByText("Meeting Room T")).toBeInTheDocument(); + expect( + screen.getByText("sponsor_reports_page.col_sponsor") + ).toBeInTheDocument(); + }); + + it("omits the Sponsor column in the by-sponsor layout", () => { + renderView(); + fireEvent.click(screen.getByText("AV1")); + expect( + screen.queryByText("sponsor_reports_page.col_sponsor") + ).not.toBeInTheDocument(); + expect(screen.queryByText("Intel")).not.toBeInTheDocument(); + }); + + it("pages over items, not sponsors, and labels the selector accordingly", () => { + const onPageChange = jest.fn(); + renderAll({ + // perPage stays a real PER_PAGE_OPTIONS value (10) so MUI does not warn. + items: Array.from({ length: 12 }, (_, i) => + item({ itemCode: `SKU${i}`, label: `Item ${i}` }) + ), + onPageChange + }); + expect(screen.getByText("1–10 of 12")).toBeInTheDocument(); + expect( + screen.getByText("sponsor_reports_page.byitem_items_per_page") + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /next page/i })); + expect(onPageChange).toHaveBeenCalledWith(2); + }); + + it("reports the picked layout to the parent, ignoring a null deselect", () => { + const onLayoutChange = jest.fn(); + renderAll({ onLayoutChange }); + fireEvent.click( + screen.getByRole("button", { + name: "sponsor_reports_page.byitem_layout_sponsor" + }) + ); + expect(onLayoutChange).toHaveBeenCalledWith("sponsor"); + onLayoutChange.mockClear(); + // Re-clicking the active button: MUI emits null, no layout change. + fireEvent.click( + screen.getByRole("button", { + name: "sponsor_reports_page.byitem_layout_all_sponsors" + }) + ); + expect(onLayoutChange).not.toHaveBeenCalled(); + }); +}); + describe("Destination booth fallback (By Item drill-down)", () => { it("carries sponsor_booth through grouping as contributor sponsorBooth", () => { const [withBooth] = groupLinesBySponsorItem([ diff --git a/src/i18n/en.json b/src/i18n/en.json index 90d959ede..26ee9d088 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -4364,9 +4364,13 @@ "byitem_col_total": "Total", "byitem_sponsor_items_chip": "{purchased} of {items} items purchased", "byitem_sum_qty": "{qty} units", - "byitem_sorted_caption": "Sorted by Qty ↓, then Orders", "byitem_contributing_orders": "Contributing orders", "byitem_sponsors_per_page": "Sponsors per page:", + "byitem_items_per_page": "Items per page:", + "byitem_layout_sponsor": "By Sponsor", + "byitem_layout_all_sponsors": "All Sponsors", + "byitem_expand_all": "Expand All", + "byitem_collapse_all": "Collapse All", "pivot_sponsor_page_component": "Sponsor → Page → Component", "pivot_page_component_sponsor": "Page → Component → Sponsor", "pivot_page_sponsor_component": "Page → Sponsor → Component", diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js index 5d6ffd4c5..95dc3a6fd 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js @@ -83,6 +83,8 @@ jest.mock("../../../../../actions/sponsor-reports-actions", () => ({ setPurchaseDetailsByItemPaging: jest.fn(() => ({ type: "SET_PURCHASE_DETAILS_BY_ITEM_PAGING" })), + // Re-exported constant, not an action — the page passes it as the CSV order. + LINES_ORDER_BY_ITEM: "item_code", PURCHASE_DETAILS_VALIDATION_CLEAR: "PURCHASE_DETAILS_VALIDATION_CLEAR", PURCHASE_DETAILS_READ_ERROR: "PURCHASE_DETAILS_READ_ERROR" })); @@ -492,7 +494,7 @@ describe("PurchaseDetailsReportPage", () => { expect(getPurchaseDetailsByItemRows).toHaveBeenCalledWith({}); }); - it("Export CSV in the By Item view dispatches the LINES csv export with the byitem slice filters", async () => { + it("Export CSV in the By Item view exports the per-line manifest ORDERED BY ITEM", async () => { const history = createMemoryHistory({ initialEntries: [PAGE_URL] }); renderWithRedux( @@ -509,9 +511,12 @@ describe("PurchaseDetailsReportPage", () => { await act(async () => { fireEvent.click(screen.getByText("sponsor_reports_page.export_csv")); }); - expect(exportPurchaseDetailsLinesCsv).toHaveBeenCalledWith({ - status: "Paid" - }); + // Same per-line exporter as the Line Items view, but ordered by item code so + // every line for one item groups together (the warehouse pull sheet). + expect(exportPurchaseDetailsLinesCsv).toHaveBeenCalledWith( + { status: "Paid" }, + "item_code" + ); }); it("changing rows-per-page in the By Item view dispatches SET paging reset to page 1", async () => { diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js index eafe21aac..aa6df674c 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js @@ -31,6 +31,7 @@ import OrdersTable from "../../../../components/sponsors/reports/OrdersTable"; import LinesManifestView from "../../../../components/sponsors/reports/LinesManifestView"; import ReportViewToggle from "../../../../components/sponsors/reports/ReportViewToggle"; import ByItemView, { + groupLinesByItem, groupLinesBySponsorItem } from "../../../../components/sponsors/reports/ByItemView"; import usePrint from "../../../../hooks/usePrint"; @@ -41,8 +42,10 @@ import { clearPurchaseDetailsValidation, exportPurchaseDetailsCsv, exportPurchaseDetailsLinesCsv, + LINES_ORDER_BY_ITEM, getPurchaseDetailsByItemRows, - setPurchaseDetailsByItemPaging + setPurchaseDetailsByItemPaging, + setPurchaseDetailsByItemSort } from "../../../../actions/sponsor-reports-actions"; import { DEFAULT_CURRENT_PAGE } from "../../../../utils/constants"; @@ -106,9 +109,12 @@ const PurchaseDetailsReportPage = ({ byItemReadError, byItemCurrentPage, byItemPerPage, + byItemOrder, + byItemOrderDir, byItemFilters, getPurchaseDetailsByItemRows: fetchByItemRows, setPurchaseDetailsByItemPaging: setByItemPaging, + setPurchaseDetailsByItemSort: setByItemSort, // From mapDispatchToProps (object form — bound action creators) getPurchaseDetailsReport: fetchReport, getPurchaseDetailsLinesReport: fetchLinesReport, @@ -123,6 +129,9 @@ const PurchaseDetailsReportPage = ({ // "orders" | "lines" | "byitem" — a transient UI toggle (NOT server state), so // it stays local. Everything else is sourced from the reducer slices above. const [view, setView] = useState("orders"); + // Which shape the By Item view shows: per-sponsor, or the show-wide pull + // sheet. Local like `view` — a display choice, not report state. + const [byItemLayout, setByItemLayout] = useState("sponsor"); const prevViewRef = useRef(view); // Show a global snackbar toast when the backend returns a 412 validation error, @@ -263,6 +272,16 @@ const PurchaseDetailsReportPage = ({ }; const handleClear = () => handleApply({}); + // Client-side sort of the By Item rollup. Page resets to 1 like the Orders + // sort handler below — a re-sort changes which rows land on the first page. + const handleByItemSort = (columnKey, dir) => { + setByItemSort({ order: columnKey, orderDir: dir }); + setByItemPaging({ + currentPage: DEFAULT_CURRENT_PAGE, + perPage: byItemPerPage + }); + }; + // ── Orders sort/pagination handlers ────────────────────────────────────────── const handleSort = (columnKey, dir) => { fetchReport(filters, { @@ -418,6 +437,7 @@ const PurchaseDetailsReportPage = ({ () => groupLinesBySponsorItem(byItemData), [byItemData] ); + const byItemItems = useMemo(() => groupLinesByItem(byItemData), [byItemData]); return ( exportOrdersCsv(filters, order, orderDir), lines: () => exportLinesCsv(linesFilters), - byitem: () => exportLinesCsv(byItemFilters) + // Per-LINE manifest ordered by item code, not the on-screen + // rollup: the warehouse needs every occurrence of an item with + // its sponsor and booth, which is the drill-down flattened. + byitem: () => exportLinesCsv(byItemFilters, LINES_ORDER_BY_ITEM) })() } > @@ -492,6 +515,21 @@ const PurchaseDetailsReportPage = ({ byitem: ( { + setByItemLayout(next); + // The two layouts page over different lists (sponsors vs + // items); carrying the page number across would land on an + // arbitrary offset. + setByItemPaging({ + currentPage: DEFAULT_CURRENT_PAGE, + perPage: byItemPerPage + }); + }} + order={byItemOrder} + orderDir={byItemOrderDir} + onSort={handleByItemSort} currentPage={byItemCurrentPage} perPage={byItemPerPage} onPageChange={(page) => @@ -529,6 +567,8 @@ const mapStateToProps = ({ byItemReadError: sponsorReportsPurchaseDetailsByItemState.readError, byItemCurrentPage: sponsorReportsPurchaseDetailsByItemState.currentPage, byItemPerPage: sponsorReportsPurchaseDetailsByItemState.perPage, + byItemOrder: sponsorReportsPurchaseDetailsByItemState.order, + byItemOrderDir: sponsorReportsPurchaseDetailsByItemState.orderDir, byItemFilters: sponsorReportsPurchaseDetailsByItemState.filters }); @@ -540,7 +580,8 @@ const mapDispatchToProps = { exportPurchaseDetailsCsv, exportPurchaseDetailsLinesCsv, getPurchaseDetailsByItemRows, - setPurchaseDetailsByItemPaging + setPurchaseDetailsByItemPaging, + setPurchaseDetailsByItemSort }; export default withRouter( diff --git a/src/reducers/sponsors/__tests__/sponsor-reports-purchase-details-by-item-reducer.test.js b/src/reducers/sponsors/__tests__/sponsor-reports-purchase-details-by-item-reducer.test.js index 79bf30252..8b0191a5b 100644 --- a/src/reducers/sponsors/__tests__/sponsor-reports-purchase-details-by-item-reducer.test.js +++ b/src/reducers/sponsors/__tests__/sponsor-reports-purchase-details-by-item-reducer.test.js @@ -5,10 +5,28 @@ import reducer, { import { SET_CURRENT_SUMMIT } from "../../../actions/summit-actions"; import { REQUEST_PURCHASE_DETAILS_BY_ITEM, - RECEIVE_PURCHASE_DETAILS_BY_ITEM_ROWS + RECEIVE_PURCHASE_DETAILS_BY_ITEM_ROWS, + SET_PURCHASE_DETAILS_BY_ITEM_SORT } from "../../../actions/sponsor-reports-actions"; describe("sponsor-reports-purchase-details-by-item-reducer", () => { + it("defaults the rollup sort to Item Code ascending", () => { + // Both By Item layouts read as a pull sheet; a warehouse scans by code. + expect(DEFAULT_STATE.order).toBe("itemCode"); + expect(DEFAULT_STATE.orderDir).toBe(1); + }); + + it("SET_SORT records the column and direction, leaving the rows untouched", () => { + const prev = { ...DEFAULT_STATE, data: [{ item_code: "A1" }] }; + const state = reducer(prev, { + type: SET_PURCHASE_DETAILS_BY_ITEM_SORT, + payload: { order: "label", orderDir: 1 } + }); + expect(state.order).toBe("label"); + expect(state.orderDir).toBe(1); + expect(state.data).toBe(prev.data); + }); + it("REQUEST records the active filters and clears readError", () => { const prev = { ...DEFAULT_STATE, readError: { status: 403 } }; const state = reducer(prev, { diff --git a/src/reducers/sponsors/sponsor-reports-purchase-details-by-item-reducer.js b/src/reducers/sponsors/sponsor-reports-purchase-details-by-item-reducer.js index b7aa833ef..6c9cde744 100644 --- a/src/reducers/sponsors/sponsor-reports-purchase-details-by-item-reducer.js +++ b/src/reducers/sponsors/sponsor-reports-purchase-details-by-item-reducer.js @@ -12,15 +12,26 @@ * */ import { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions"; -import { DEFAULT_CURRENT_PAGE, DEFAULT_PER_PAGE } from "../../utils/constants"; +import { + DEFAULT_CURRENT_PAGE, + DEFAULT_ORDER_DIR, + DEFAULT_PER_PAGE +} from "../../utils/constants"; import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions"; import { REQUEST_PURCHASE_DETAILS_BY_ITEM, RECEIVE_PURCHASE_DETAILS_BY_ITEM_ROWS, PURCHASE_DETAILS_BY_ITEM_READ_ERROR, - SET_PURCHASE_DETAILS_BY_ITEM_PAGING + SET_PURCHASE_DETAILS_BY_ITEM_PAGING, + SET_PURCHASE_DETAILS_BY_ITEM_SORT } from "../../actions/sponsor-reports-actions"; +// Sort column key: a DERIVED item-row field, not an API ordering field. Item +// Code ascending because both By Item layouts read as a pull sheet and a +// warehouse scans them by code. (The rollup is still emitted qty-desc, which +// the view's stable sort preserves underneath as the tiebreak.) +const DEFAULT_ORDER = "itemCode"; + export const DEFAULT_STATE = { data: [], // ALL filtered line rows (whole-set fetch; client-side rollup) summary: null, @@ -30,6 +41,8 @@ export const DEFAULT_STATE = { // recorded on REQUEST like the sibling slices. currentPage: DEFAULT_CURRENT_PAGE, perPage: DEFAULT_PER_PAGE, + order: DEFAULT_ORDER, + orderDir: DEFAULT_ORDER_DIR, filters: {}, readError: null }; @@ -57,6 +70,10 @@ const reducer = (state = DEFAULT_STATE, action) => { const { currentPage, perPage } = payload; return { ...state, currentPage, perPage }; } + case SET_PURCHASE_DETAILS_BY_ITEM_SORT: { + const { order, orderDir } = payload; + return { ...state, order, orderDir }; + } case PURCHASE_DETAILS_BY_ITEM_READ_ERROR: return { ...state, readError: payload }; default: From 6ae3a93be51c4810ac344b0c6e34dba258f5658b Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 27 Jul 2026 23:22:51 -0500 Subject: [PATCH 2/2] fix(sponsor-reports): restore the sort action mock and cover page-level sorting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The revert of the client-side rollup CSV also took out the setPurchaseDetailsByItemSort entry it had added to the page test's action mock, so mapDispatchToProps bound the prop to undefined. Nothing caught it: there was no page-level test that sorts. Adding one first reproduced it as "TypeError: setByItemSort is not a function". Restores the mock, mirrors the reducer's order/orderDir defaults in the buildState fixture (without them no column renders active), and adds the page-level test that clicks a sort header and asserts the thunk fires. Also moves the ItemTable doc comment back above ItemTable — inserting ItemGroup had orphaned it above the wrong function. --- src/components/sponsors/reports/ByItemView.js | 8 ++--- .../__tests__/index.test.js | 31 ++++++++++++++++++- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/components/sponsors/reports/ByItemView.js b/src/components/sponsors/reports/ByItemView.js index 29a923fad..dce029a0a 100644 --- a/src/components/sponsors/reports/ByItemView.js +++ b/src/components/sponsors/reports/ByItemView.js @@ -249,10 +249,6 @@ const CONTRIB_HEADERS = [ const itemKey = (group, item) => JSON.stringify([group.sponsorId ?? null, item.itemCode ?? null]); -// The item table both layouts render: one row per item_code with the purchased -// aggregates, expanding to the orders that contributed. `showSponsor` adds the -// Sponsor column to the drill-down for the all-sponsors layout, where the -// sponsor is no longer carried by a parent accordion. // The group container BOTH layouts render: an accordion whose summary carries // the title, the items chip and the Σ qty. One component, not two call sites, so // the card surface, summary divider and AccordionDetails inset stay identical @@ -285,6 +281,10 @@ const ItemGroup = ({ ); +// The item table both layouts render: one row per item_code with the purchased +// aggregates, expanding to the orders that contributed. `showSponsor` adds the +// Sponsor column to the drill-down for the all-sponsors layout, where the +// sponsor is no longer carried by a parent accordion. const ItemTable = ({ items, keyFor, diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js index 95dc3a6fd..4a7b0f662 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js @@ -83,6 +83,9 @@ jest.mock("../../../../../actions/sponsor-reports-actions", () => ({ setPurchaseDetailsByItemPaging: jest.fn(() => ({ type: "SET_PURCHASE_DETAILS_BY_ITEM_PAGING" })), + setPurchaseDetailsByItemSort: jest.fn(() => ({ + type: "SET_PURCHASE_DETAILS_BY_ITEM_SORT" + })), // Re-exported constant, not an action — the page passes it as the CSV order. LINES_ORDER_BY_ITEM: "item_code", PURCHASE_DETAILS_VALIDATION_CLEAR: "PURCHASE_DETAILS_VALIDATION_CLEAR", @@ -95,7 +98,8 @@ const { getPurchaseDetailsLinesReport, exportPurchaseDetailsLinesCsv, getPurchaseDetailsByItemRows, - setPurchaseDetailsByItemPaging + setPurchaseDetailsByItemPaging, + setPurchaseDetailsByItemSort } = require("../../../../../actions/sponsor-reports-actions"); // ──────────────────────────────────────────────────────────────────────────── @@ -202,6 +206,10 @@ function buildState( summary: null, currentPage: 1, perPage: 10, + // Mirror the reducer's defaults, or no column renders as active and the + // page-level sort wiring goes untested. + order: "itemCode", + orderDir: 1, filters: byItemFilters, readError: byItemReadError } @@ -519,6 +527,27 @@ describe("PurchaseDetailsReportPage", () => { ); }); + it("clicking a By Item sort header dispatches the sort thunk", async () => { + const history = createMemoryHistory({ initialEntries: [PAGE_URL] }); + renderWithRedux( + + + , + { initialState: buildState({}, { byItemData: [SAMPLE_LINE] }) } + ); + await act(async () => {}); + await act(async () => { + fireEvent.click(screen.getByText("sponsor_reports_page.view_by_item")); + }); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /col_quantity/ })); + }); + expect(setPurchaseDetailsByItemSort).toHaveBeenCalledWith({ + order: "qty", + orderDir: -1 + }); + }); + it("changing rows-per-page in the By Item view dispatches SET paging reset to page 1", async () => { const history = createMemoryHistory({ initialEntries: [PAGE_URL] }); renderWithRedux(