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..dce029a0a 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 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} + +); + +// 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, + 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..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,11 @@ 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", PURCHASE_DETAILS_READ_ERROR: "PURCHASE_DETAILS_READ_ERROR" })); @@ -93,7 +98,8 @@ const { getPurchaseDetailsLinesReport, exportPurchaseDetailsLinesCsv, getPurchaseDetailsByItemRows, - setPurchaseDetailsByItemPaging + setPurchaseDetailsByItemPaging, + setPurchaseDetailsByItemSort } = require("../../../../../actions/sponsor-reports-actions"); // ──────────────────────────────────────────────────────────────────────────── @@ -200,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 } @@ -492,7 +502,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,8 +519,32 @@ 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("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 }); }); 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: