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")}
+
+
+
+
+ {T.translate("sponsor_reports_page.byitem_expand_all")}
+
+
+ {T.translate("sponsor_reports_page.byitem_collapse_all")}
+
+
+
+ {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: