diff --git a/src/actions/__tests__/sponsor-reports-actions.test.js b/src/actions/__tests__/sponsor-reports-actions.test.js
index 97e141262..e0c02dc61 100644
--- a/src/actions/__tests__/sponsor-reports-actions.test.js
+++ b/src/actions/__tests__/sponsor-reports-actions.test.js
@@ -4,7 +4,9 @@ import flushPromises from "flush-promises";
import {
getRequest,
getCSV,
- authErrorHandler
+ authErrorHandler,
+ START_LOADING,
+ STOP_LOADING
} from "openstack-uicore-foundation/lib/utils/actions";
import * as methods from "../../utils/methods";
@@ -29,7 +31,11 @@ import {
SPONSOR_ASSET_READ_ERROR,
REQUEST_SPONSOR_DRILLDOWN,
RECEIVE_SPONSOR_DRILLDOWN,
- SPONSOR_DRILLDOWN_READ_ERROR
+ SPONSOR_DRILLDOWN_READ_ERROR,
+ getPurchaseDetailsByItemRows,
+ REQUEST_PURCHASE_DETAILS_BY_ITEM,
+ RECEIVE_PURCHASE_DETAILS_BY_ITEM_ROWS,
+ PURCHASE_DETAILS_BY_ITEM_READ_ERROR
} from "../sponsor-reports-actions";
jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({
@@ -1200,4 +1206,249 @@ describe("sponsor-reports-actions", () => {
}
);
});
+
+ // ─── getPurchaseDetailsByItemRows ────────────────────────────────────────────
+
+ describe("getPurchaseDetailsByItemRows", () => {
+ const rowA = { item_code: "A1", quantity: 1 };
+ const rowB = { item_code: "B1", quantity: 2 };
+ const rowC = { item_code: "C1", quantity: 3 };
+ const page1Summary = { total_orders: 11 };
+
+ it("records the active filters on REQUEST and hits the lines endpoint without paymentMethod", async () => {
+ const store = mockStore(MOCK_STATE);
+ const filters = { sponsorIds: [17], paymentMethod: "Card" };
+ await store.dispatch(getPurchaseDetailsByItemRows(filters));
+ await flushPromises();
+
+ const req = store
+ .getActions()
+ .find((a) => a.type === REQUEST_PURCHASE_DETAILS_BY_ITEM);
+ expect(req).toBeDefined();
+ expect(req.payload).toStrictEqual({ filters });
+ // toContain, NOT toBe with a hardcoded base: the export describes in this
+ // file set window.SPONSOR_REPORTS_API_URL in their beforeEach and never
+ // clear it, so the resolved base depends on declaration order.
+ expect(capturedUrl).toContain(
+ "/summits/42/reports/purchase-details/lines"
+ );
+ // buildPurchaseLinesQuery drops paymentMethod (order-level attribute).
+ const filterClauses = capturedParams["filter[]"] || [];
+ expect(
+ filterClauses.some((c) => String(c).includes("payment_method"))
+ ).toBe(false);
+ });
+
+ it("bulk-loads all pages (page 1, then the rest in parallel) into one atomic RECEIVE_PURCHASE_DETAILS_BY_ITEM_ROWS", async () => {
+ const capturedPages = [];
+ const capturedPerPage = [];
+ getRequest.mockImplementation(
+ (_requestAC, receiveActionCreator) => (params) => (dispatch) => {
+ capturedPages.push(params.page);
+ capturedPerPage.push(params.per_page);
+ const byPage = {
+ 1: { data: [rowA], last_page: 3, summary: page1Summary },
+ 2: { data: [rowB], last_page: 3 },
+ 3: { data: [rowC], last_page: 3 }
+ };
+ const response = byPage[params.page];
+ if (typeof receiveActionCreator === "function") {
+ dispatch(receiveActionCreator({ response }));
+ }
+ return Promise.resolve({ response });
+ }
+ );
+
+ const store = mockStore(MOCK_STATE);
+ await store.dispatch(getPurchaseDetailsByItemRows({}));
+ await flushPromises();
+
+ expect(getRequest).toHaveBeenCalledTimes(3);
+ expect([...capturedPages].sort((a, b) => a - b)).toEqual([1, 2, 3]);
+ expect(capturedPerPage).toEqual([100, 100, 100]);
+
+ const rowsActions = store
+ .getActions()
+ .filter((a) => a.type === RECEIVE_PURCHASE_DETAILS_BY_ITEM_ROWS);
+ expect(rowsActions).toHaveLength(1);
+ expect(rowsActions[0].payload.response.data).toEqual([rowA, rowB, rowC]);
+ // Summary rides page 1 (backend computes it over the whole filtered set).
+ expect(rowsActions[0].payload.response.summary).toEqual(page1Summary);
+ });
+
+ it("a superseded invocation does not fire its queued rest-page requests", async () => {
+ let resolveAPage1;
+ let callNum = 0;
+ const capturedPages = [];
+ getRequest.mockImplementation(
+ (_requestAC, receiveActionCreator) => (params) => (dispatch) => {
+ callNum += 1;
+ const isAPage1 = callNum === 1;
+ capturedPages.push(params.page);
+ if (isAPage1) {
+ const response = {
+ data: [{ id: "A1" }],
+ last_page: 2,
+ summary: null
+ };
+ return new Promise((resolve) => {
+ resolveAPage1 = () => resolve({ response });
+ });
+ }
+ const response = {
+ data: [{ id: `p${params.page}` }],
+ last_page: 1,
+ summary: null
+ };
+ if (typeof receiveActionCreator === "function") {
+ dispatch(receiveActionCreator({ response }));
+ }
+ return Promise.resolve({ response });
+ }
+ );
+
+ const store = mockStore(MOCK_STATE);
+ const stalePromise = store.dispatch(getPurchaseDetailsByItemRows({}));
+ await flushPromises();
+ await store.dispatch(getPurchaseDetailsByItemRows({}));
+ resolveAPage1();
+ await stalePromise;
+ await flushPromises();
+
+ expect(capturedPages).not.toContain(2);
+ });
+
+ it("drops a stale RECEIVE when a newer call supersedes it", async () => {
+ let resolveFirstPage;
+ let getRequestCallNum = 0;
+ getRequest.mockImplementation(
+ (_requestAC, receiveActionCreator) => () => (dispatch) => {
+ getRequestCallNum += 1;
+ const callNum = getRequestCallNum;
+ const data = callNum === 1 ? [{ id: "stale" }] : [{ id: "fresh" }];
+ const response = { data, last_page: 1, summary: null };
+ if (typeof receiveActionCreator === "function") {
+ dispatch(receiveActionCreator({ response }));
+ }
+ if (callNum === 1) {
+ return new Promise((resolve) => {
+ resolveFirstPage = () => resolve({ response });
+ });
+ }
+ return Promise.resolve({ response });
+ }
+ );
+
+ const store = mockStore(MOCK_STATE);
+ const stalePromise = store.dispatch(getPurchaseDetailsByItemRows({}));
+ await flushPromises();
+ await store.dispatch(getPurchaseDetailsByItemRows({}));
+ await flushPromises();
+ resolveFirstPage();
+ await stalePromise;
+ await flushPromises();
+
+ const rowsActions = store
+ .getActions()
+ .filter((a) => a.type === RECEIVE_PURCHASE_DETAILS_BY_ITEM_ROWS);
+ expect(rowsActions).toHaveLength(1);
+ expect(rowsActions[0].payload.response.data).toEqual([{ id: "fresh" }]);
+ });
+
+ it("drops the whole-set commit when the summit changes mid-flight (nothing re-invokes By Item to bump the seq)", async () => {
+ // The seq guard is per-thunk, not summit-aware, and a summit switch remounts
+ // on Orders — it never re-invokes By Item, so the seq is NOT bumped. Without
+ // the explicit summit guard the parked in-flight fetch would commit the old
+ // summit's rows over the reset slice (a stale cross-summit false cache hit).
+ let resolvePage1;
+ getRequest.mockImplementation(() => () => () => {
+ const response = {
+ data: [{ id: "old-summit" }],
+ last_page: 1,
+ summary: null
+ };
+ return new Promise((resolve) => {
+ resolvePage1 = () => resolve({ response });
+ });
+ });
+
+ // Function-backed getState so the summit id can flip under the in-flight thunk.
+ let summitId = 42;
+ const store = mockStore(() => ({
+ currentSummitState: { currentSummit: { id: summitId } }
+ }));
+ const pending = store.dispatch(getPurchaseDetailsByItemRows({}));
+ await flushPromises();
+ // Summit switches; no new By Item dispatch bumps the seq.
+ summitId = 43;
+ resolvePage1();
+ await pending;
+ await flushPromises();
+
+ const rowsActions = store
+ .getActions()
+ .filter((a) => a.type === RECEIVE_PURCHASE_DETAILS_BY_ITEM_ROWS);
+ expect(rowsActions).toHaveLength(0);
+ // The summit-bail must leave the global overlay CLEARED — otherwise a
+ // startLoading dispatched under the old summit sticks (the reducer sets
+ // loading to a flag, so nothing after it clears it). Assert the terminal
+ // loading transition is a stop, not just balanced counts.
+ const loadingActions = store
+ .getActions()
+ .filter((a) => a.type === START_LOADING || a.type === STOP_LOADING);
+ expect(loadingActions.at(-1)?.type).toBe(STOP_LOADING);
+ });
+
+ it("suppresses PURCHASE_DETAILS_BY_ITEM_READ_ERROR from a stale call's error handler", async () => {
+ let fireStaleError;
+ let callNum = 0;
+ getRequest.mockImplementation(
+ (_requestAC, receiveActionCreator, _url, errorHandler) =>
+ () =>
+ (guardedOrDispatch) => {
+ callNum += 1;
+ if (callNum === 1) {
+ // A's page 1 stays IN FLIGHT until fireStaleError, which routes a
+ // 403 through the error handler via A's guardedDispatch (stale →
+ // suppressed) and resolves with NO {response} → A's destructure
+ // then throws a TypeError → catch also guarded.
+ return new Promise((resolve) => {
+ fireStaleError = () => {
+ errorHandler({ status: 403 }, {})(guardedOrDispatch);
+ resolve();
+ };
+ });
+ }
+ const response = {
+ data: [{ id: "fresh" }],
+ last_page: 1,
+ summary: null
+ };
+ if (typeof receiveActionCreator === "function") {
+ guardedOrDispatch(receiveActionCreator({ response }));
+ }
+ return Promise.resolve({ response });
+ }
+ );
+
+ const store = mockStore(MOCK_STATE);
+ const stalePromise = store.dispatch(getPurchaseDetailsByItemRows({}));
+ // A is now past the token guard with its page-1 request in flight.
+ await flushPromises();
+ await store.dispatch(getPurchaseDetailsByItemRows({}));
+ fireStaleError();
+ await stalePromise;
+ await flushPromises();
+
+ const errors = store
+ .getActions()
+ .filter((a) => a.type === PURCHASE_DETAILS_BY_ITEM_READ_ERROR);
+ expect(errors).toHaveLength(0);
+ const receives = store
+ .getActions()
+ .filter((a) => a.type === RECEIVE_PURCHASE_DETAILS_BY_ITEM_ROWS);
+ expect(receives).toHaveLength(1);
+ expect(receives[0].payload.response.data).toEqual([{ id: "fresh" }]);
+ });
+ });
});
diff --git a/src/actions/sponsor-reports-actions.js b/src/actions/sponsor-reports-actions.js
index 23cb0f432..b6fefcc62 100644
--- a/src/actions/sponsor-reports-actions.js
+++ b/src/actions/sponsor-reports-actions.js
@@ -47,6 +47,17 @@ export const RECEIVE_PURCHASE_DETAILS_LINES = "RECEIVE_PURCHASE_DETAILS_LINES";
export const PURCHASE_DETAILS_LINES_READ_ERROR =
"PURCHASE_DETAILS_LINES_READ_ERROR";
+export const REQUEST_PURCHASE_DETAILS_BY_ITEM =
+ "REQUEST_PURCHASE_DETAILS_BY_ITEM";
+export const RECEIVE_PURCHASE_DETAILS_BY_ITEM_PAGE =
+ "RECEIVE_PURCHASE_DETAILS_BY_ITEM_PAGE"; // throwaway per-page action
+export const RECEIVE_PURCHASE_DETAILS_BY_ITEM_ROWS =
+ "RECEIVE_PURCHASE_DETAILS_BY_ITEM_ROWS";
+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";
+
// Per-thunk sequence-token factory guarding against stale-response commits.
// Two concurrent invocations of the same thunk (different filters/page/sponsor)
// carry different getRequest abort keys (only access_token is stripped from the
@@ -83,6 +94,7 @@ const purchaseFiltersSeq = sequenced();
const sponsorAssetRowsSeq = sequenced();
const assetFiltersSeq = sequenced();
const sponsorDrilldownSeq = sequenced();
+const purchaseByItemSeq = sequenced();
// Base URL helper — scoped to a specific summit's reports endpoint.
const base = (summitId) =>
@@ -479,6 +491,111 @@ export const getSponsorAssetRows =
return Promise.resolve();
};
+// Fetch the WHOLE filtered line set for the By Item rollup — same whole-set
+// client-side-pivot flow as getSponsorAssetRows above (see that thunk for the
+// full sequencing rationale). Page 1 yields last_page + the embedded summary
+// (the backend computes it over the unpaginated filtered set); pages 2..last
+// bulk-load with a bounded-concurrency pool. Rows commit atomically in ONE
+// RECEIVE so the rollup never renders a partial set.
+export const getPurchaseDetailsByItemRows =
+ (filters = {}) =>
+ async (dispatch, getState) => {
+ const { currentSummitState } = getState();
+ const { currentSummit } = currentSummitState;
+ if (!currentSummit?.id) return Promise.resolve();
+ const { isCurrent, guardedDispatch } = purchaseByItemSeq(dispatch);
+ const accessToken = await getAccessTokenSafely();
+ if (!isCurrent()) return Promise.resolve();
+ guardedDispatch(startLoading());
+ guardedDispatch(
+ createAction(REQUEST_PURCHASE_DETAILS_BY_ITEM)({ filters })
+ );
+ // Lines-grain query (drops paymentMethod); one arg → no page/per_page emitted.
+ const baseQuery = buildPurchaseLinesQuery(filters);
+ const url = `${base(currentSummit.id)}/purchase-details/lines`;
+ const fetchPage = (page) =>
+ getRequest(
+ null,
+ createAction(RECEIVE_PURCHASE_DETAILS_BY_ITEM_PAGE),
+ url,
+ reportReadErrorHandler({
+ onReadError: createAction(PURCHASE_DETAILS_BY_ITEM_READ_ERROR)
+ })
+ )({
+ access_token: accessToken,
+ ...baseQuery,
+ per_page: HUNDRED_PER_PAGE,
+ page
+ })(guardedDispatch);
+ try {
+ const { response } = await fetchPage(1);
+ const lastPage = response.last_page || 1;
+ const limit = pLimit(TEN);
+ const restPages = Array.from(
+ { length: lastPage },
+ (_, i) => i + DEFAULT_CURRENT_PAGE
+ ).slice(DEFAULT_CURRENT_PAGE);
+ // Re-check the seq INSIDE the pool callback: a superseded invocation's
+ // queued jobs must not fire (see getSponsorAssetRows).
+ const rest = await Promise.all(
+ restPages.map((p) =>
+ limit(() =>
+ isCurrent()
+ ? fetchPage(p)
+ : Promise.resolve({ response: { data: [] } })
+ )
+ )
+ );
+ // Drop a late commit if superseded OR the summit changed: the seq guard is
+ // per-thunk, and a summit switch remounts on Orders (never re-invoking By
+ // Item) so it never bumps the seq. stopLoading balances our own startLoading
+ // on that path (guardedDispatch no-ops when superseded — the newer call clears it).
+ if (
+ !isCurrent() ||
+ getState().currentSummitState.currentSummit?.id !== currentSummit.id
+ ) {
+ guardedDispatch(stopLoading());
+ return Promise.resolve();
+ }
+ const allRows = rest.reduce(
+ (acc, r) => acc.concat(r.response.data),
+ response.data
+ );
+ guardedDispatch(
+ createAction(RECEIVE_PURCHASE_DETAILS_BY_ITEM_ROWS)({
+ response: { ...response, data: allRows }
+ })
+ );
+ guardedDispatch(stopLoading());
+ } catch (e) {
+ // Only a genuine non-HTTP Error lands here (HTTP errors already routed
+ // through reportReadErrorHandler) — see getSponsorAssetRows.
+ if (e instanceof Error) {
+ guardedDispatch(
+ createAction(PURCHASE_DETAILS_BY_ITEM_READ_ERROR)({
+ message: e.message
+ })
+ );
+ }
+ guardedDispatch(stopLoading());
+ }
+ return Promise.resolve();
+ };
+
+// Client-side paging over the DERIVED By Item sponsor groups — recorded in redux
+// (not local state) so the user's place survives the Orders ↔ By Item toggle and
+// SPA navigation. Pure client dispatch (cf. clearPurchaseDetailsValidation).
+export const setPurchaseDetailsByItemPaging =
+ ({ currentPage, perPage }) =>
+ (dispatch) => {
+ dispatch(
+ createAction(SET_PURCHASE_DETAILS_BY_ITEM_PAGING)({
+ currentPage,
+ perPage
+ })
+ );
+ };
+
// 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
diff --git a/src/components/sponsors/reports/ByItemView.js b/src/components/sponsors/reports/ByItemView.js
new file mode 100644
index 000000000..16baa0541
--- /dev/null
+++ b/src/components/sponsors/reports/ByItemView.js
@@ -0,0 +1,422 @@
+/**
+ * Copyright 2026 OpenStack Foundation
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * */
+
+import React, { useState } from "react";
+import {
+ Accordion,
+ AccordionDetails,
+ AccordionSummary,
+ Box,
+ Chip,
+ IconButton,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TablePagination,
+ TableRow,
+ Typography
+} from "@mui/material";
+import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
+import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
+import T from "i18n-react/dist/i18n-react";
+import { currencyAmountFromCents } from "openstack-uicore-foundation/lib/utils/money";
+import StatusPill from "./StatusPill";
+import ChipList from "../../mui/chip-list";
+import { Destination, PER_PAGE_OPTIONS } from "./LinesManifestView";
+import { formatCheckoutTime } from "./OrdersTable";
+import {
+ DEFAULT_CURRENT_PAGE,
+ DEFAULT_PER_PAGE
+} 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
+// 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".
+// Bucket by sponsor.id / item_code, never adjacency: the backend orders lines by
+// sponsor NAME (not unique), so two sponsor ids sharing a name can interleave.
+// Symbol sentinels for the null buckets — collision-free with any real id/code
+// value (cf. UNKNOWN in build-pivot-tree.js).
+const NO_SPONSOR_KEY = Symbol("no_sponsor");
+const NO_CODE_KEY = Symbol("no_code");
+
+export const groupLinesBySponsorItem = (rows = []) => {
+ const sponsorMap = new Map();
+ rows.forEach((row) => {
+ const sponsorId = row.sponsor?.id ?? null;
+ const sponsorKey = sponsorId === null ? NO_SPONSOR_KEY : sponsorId;
+ if (!sponsorMap.has(sponsorKey)) {
+ sponsorMap.set(sponsorKey, {
+ sponsorId,
+ sponsorName: row.sponsor?.name ?? "",
+ 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,
+ 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)
+ });
+ });
+
+ 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);
+ return {
+ sponsorId: g.sponsorId,
+ sponsorName: g.sponsorName,
+ items,
+ totalQty,
+ itemCount: items.length,
+ purchasedCount: items.filter((it) => it.qty > 0).length
+ };
+ });
+ groups.sort(
+ (a, b) =>
+ b.totalQty - a.totalQty || a.sponsorName.localeCompare(b.sponsorName)
+ );
+ return groups;
+};
+
+const ITEM_HEADERS = [
+ { key: "col_item_code" },
+ { key: "col_item_name" },
+ { key: "col_quantity", align: "right" },
+ { key: "byitem_col_orders", align: "right" },
+ { key: "byitem_col_total", align: "right" },
+ { key: "col_status" }
+];
+
+const CONTRIB_HEADERS = [
+ { key: "col_order" },
+ { key: "col_form_code" },
+ { key: "col_destination" },
+ { key: "col_checkout_at" },
+ { key: "col_used_rate" },
+ { key: "col_status" },
+ { key: "col_quantity", align: "right" },
+ { key: "col_line_total", align: "right" }
+];
+
+// One expansion key per (sponsor, item) so the same item code under two
+// sponsors drills down independently. JSON-encoded so null buckets can never
+// collide with a real string code.
+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.
+const ByItemView = ({
+ groups = [],
+ 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));
+ const displayPage = Math.min(currentPage, lastPage);
+ const paged = groups.slice(
+ (displayPage - 1) * perPage,
+ displayPage * perPage
+ );
+
+ const toggleItem = (key) => {
+ setExpandedItems((prev) => {
+ const next = new Set(prev);
+ if (next.has(key)) {
+ next.delete(key);
+ } else {
+ next.add(key);
+ }
+ return next;
+ });
+ };
+
+ return (
+
+
+ {T.translate("sponsor_reports_page.byitem_sorted_caption")}
+
+ {paged.map((group) => (
+
+ }>
+
+ {group.sponsorName ||
+ T.translate("sponsor_reports_page.pivot_unknown_sponsor")}
+
+
+
+ {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))}
+ />
+
+ );
+};
+
+export default ByItemView;
diff --git a/src/components/sponsors/reports/LinesManifestView.js b/src/components/sponsors/reports/LinesManifestView.js
index 7b100e4ea..744f85fb5 100644
--- a/src/components/sponsors/reports/LinesManifestView.js
+++ b/src/components/sponsors/reports/LinesManifestView.js
@@ -40,7 +40,9 @@ import {
TWENTY_PER_PAGE
} from "../../../utils/constants";
-const PER_PAGE_OPTIONS = [
+// Shared with ByItemView (single source for the report views' page-size list,
+// cf. STATUS_KEYS in build-pivot-tree).
+export const PER_PAGE_OPTIONS = [
DEFAULT_PER_PAGE,
TWENTY_PER_PAGE,
FIFTY_PER_PAGE,
@@ -50,7 +52,7 @@ const PER_PAGE_OPTIONS = [
// Destination = the line's add-on (e.g. "Meeting Room T"); when absent, the
// logistics convention is the sponsor's booth. The booth NUMBER ships with
// slice #1 — until then show a muted "Booth" placeholder.
-const Destination = ({ name }) =>
+export const Destination = ({ name }) =>
name ? (
<>{name}>
) : (
diff --git a/src/components/sponsors/reports/ReportViewToggle.js b/src/components/sponsors/reports/ReportViewToggle.js
index 68985f394..d4811e3c0 100644
--- a/src/components/sponsors/reports/ReportViewToggle.js
+++ b/src/components/sponsors/reports/ReportViewToggle.js
@@ -25,6 +25,9 @@ const ReportViewToggle = ({ value, onChange }) => (
{T.translate("sponsor_reports_page.view_line_items")}
+
+ {T.translate("sponsor_reports_page.view_by_item")}
+
);
diff --git a/src/components/sponsors/reports/__tests__/ByItemView.test.js b/src/components/sponsors/reports/__tests__/ByItemView.test.js
new file mode 100644
index 000000000..4c3c2626c
--- /dev/null
+++ b/src/components/sponsors/reports/__tests__/ByItemView.test.js
@@ -0,0 +1,376 @@
+import "@testing-library/jest-dom";
+import React from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+import ByItemView, { groupLinesBySponsorItem } from "../ByItemView";
+
+jest.mock("i18n-react/dist/i18n-react", () => ({
+ translate: (k, opts) => (opts ? `${k}:${Object.values(opts).join(",")}` : k)
+}));
+
+const line = (over = {}) => ({
+ sponsor: { id: 17, name: "Acme" },
+ purchase: {
+ id: 5001,
+ number: "OCP-1",
+ status: "Paid",
+ checkout_at: 1735000000
+ },
+ form: { code: "AV", name: "Audio Visual" },
+ item_code: "AV1",
+ description: "Audio mixer",
+ rate_name: "Early",
+ quantity: 2,
+ unit_price: 50000,
+ line_total: 100000,
+ add_on_id: 3,
+ add_on_name: "Meeting Room T",
+ notes: "dock B",
+ is_canceled: false,
+ canceled_at: null,
+ ...over
+});
+
+describe("groupLinesBySponsorItem", () => {
+ it("buckets by sponsor.id, not adjacency (dup-name interleave)", () => {
+ const rows = [
+ line({ sponsor: { id: 1, name: "Acme" } }),
+ line({ sponsor: { id: 2, name: "Acme" }, item_code: "B1" }),
+ line({ sponsor: { id: 1, name: "Acme" }, item_code: "C1" })
+ ];
+ const groups = groupLinesBySponsorItem(rows);
+ expect(groups).toHaveLength(2);
+ const g1 = groups.find((g) => g.sponsorId === 1);
+ expect(g1.items).toHaveLength(2);
+ });
+
+ it("groups by trimmed item_code within a sponsor; blank code → one null bucket", () => {
+ const rows = [
+ line({ item_code: " AV1 " }),
+ line({ item_code: "AV1", quantity: 3 }),
+ line({ item_code: "", description: "Mystery A" }),
+ line({ item_code: null, description: "Mystery B" })
+ ];
+ const [group] = groupLinesBySponsorItem(rows);
+ expect(group.items).toHaveLength(2);
+ const av1 = group.items.find((i) => i.itemCode === "AV1");
+ expect(av1.qty).toBe(5);
+ expect(av1.lines).toBe(2);
+ const noCode = group.items.find((i) => i.itemCode === null);
+ expect(noCode.lines).toBe(2);
+ expect(noCode.label).toBe("Mystery A"); // first non-blank description
+ });
+
+ it("counts DISTINCT orders and builds statusMix as distinct orders per status", () => {
+ const rows = [
+ line({ purchase: { id: 1, number: "N1", status: "Paid" } }),
+ line({ purchase: { id: 1, number: "N1", status: "Paid" } }),
+ line({ purchase: { id: 2, number: "N2", status: "Pending Payment" } })
+ ];
+ const [group] = groupLinesBySponsorItem(rows);
+ const [item] = group.items;
+ expect(item.orders).toBe(2);
+ expect(item.lines).toBe(3);
+ expect(item.statusMix).toEqual({ Paid: 1, "Pending Payment": 1 });
+ });
+
+ it("RETAINS qty-0 lines and reports purchasedCount only over qty>0 items", () => {
+ const rows = [
+ line({ quantity: 3 }),
+ line({
+ item_code: "Z1",
+ description: "Unbought",
+ quantity: 0,
+ line_total: 0
+ })
+ ];
+ const [group] = groupLinesBySponsorItem(rows);
+ expect(group.items).toHaveLength(2);
+ expect(group.itemCount).toBe(2);
+ expect(group.purchasedCount).toBe(1);
+ expect(group.totalQty).toBe(3);
+ });
+
+ it("totalCents is null when every line_total is null, else the sum of non-nulls", () => {
+ const rows = [
+ line({ item_code: "A", line_total: null }),
+ line({ item_code: "A", line_total: null }),
+ line({ item_code: "B", line_total: 100, quantity: 1 }),
+ line({ item_code: "B", line_total: null, quantity: 1 })
+ ];
+ const [group] = groupLinesBySponsorItem(rows);
+ expect(group.items.find((i) => i.itemCode === "A").totalCents).toBeNull();
+ expect(group.items.find((i) => i.itemCode === "B").totalCents).toBe(100);
+ });
+
+ it("sorts items qty desc then orders desc, sponsors by totalQty desc", () => {
+ const rows = [
+ line({ sponsor: { id: 1, name: "Small" }, item_code: "A", quantity: 1 }),
+ line({
+ sponsor: { id: 2, name: "Big" },
+ item_code: "B",
+ quantity: 9,
+ purchase: { id: 7, number: "N7", status: "Paid" }
+ }),
+ line({ sponsor: { id: 2, name: "Big" }, item_code: "C", quantity: 9 }),
+ line({
+ sponsor: { id: 2, name: "Big" },
+ item_code: "C",
+ quantity: 0,
+ purchase: { id: 8, number: "N8", status: "Paid" }
+ })
+ ];
+ const groups = groupLinesBySponsorItem(rows);
+ expect(groups.map((g) => g.sponsorName)).toEqual(["Big", "Small"]);
+ // C: qty 9, orders 2 beats B: qty 9, orders 1
+ expect(groups[0].items.map((i) => i.itemCode)).toEqual(["C", "B"]);
+ });
+
+ it("passes canceled lines through as contributors with isCanceled", () => {
+ const rows = [line({ is_canceled: true })];
+ const [group] = groupLinesBySponsorItem(rows);
+ const [contrib] = group.items[0].contributors;
+ expect(contrib).toEqual({
+ number: "OCP-1",
+ formCode: "AV",
+ addOnName: "Meeting Room T",
+ checkoutAt: 1735000000,
+ rateName: "Early",
+ status: "Paid",
+ qty: 2,
+ lineTotalCents: 100000,
+ isCanceled: true
+ });
+ });
+
+ it("EXCLUDES canceled lines from qty/money/purchasedCount/Σqty but keeps them as contributors", () => {
+ const rows = [
+ line({
+ item_code: "AV1",
+ quantity: 2,
+ line_total: 100000,
+ is_canceled: true
+ }),
+ line({ item_code: "AV1", quantity: 3, line_total: 150000 }),
+ line({
+ item_code: "Z1",
+ description: "Canceled only",
+ quantity: 4,
+ line_total: 40000,
+ is_canceled: true
+ })
+ ];
+ const [group] = groupLinesBySponsorItem(rows);
+ const av1 = group.items.find((i) => i.itemCode === "AV1");
+ // canceled qty (2) and money (100000) excluded; live line still counts
+ expect(av1.qty).toBe(3);
+ expect(av1.totalCents).toBe(150000);
+ // both lines remain structurally, and the canceled one is still a contributor
+ expect(av1.lines).toBe(2);
+ expect(av1.contributors).toHaveLength(2);
+ const z1 = group.items.find((i) => i.itemCode === "Z1");
+ // an item whose only line is canceled reports qty 0 and null money (renders —)
+ expect(z1.qty).toBe(0);
+ expect(z1.totalCents).toBeNull();
+ // Z1 is not "purchased"; only AV1 counts
+ expect(group.purchasedCount).toBe(1);
+ expect(group.totalQty).toBe(3);
+ });
+
+ it("excludes a canceled-only line's order from orders/statusMix, same as qty/money", () => {
+ const rows = [
+ line({
+ item_code: "Z1",
+ description: "Canceled only",
+ quantity: 4,
+ line_total: 40000,
+ is_canceled: true,
+ purchase: { id: 5001, number: "OCP-1", status: "Paid" }
+ })
+ ];
+ const [group] = groupLinesBySponsorItem(rows);
+ const z1 = group.items.find((i) => i.itemCode === "Z1");
+ expect(z1.qty).toBe(0);
+ expect(z1.totalCents).toBeNull();
+ // no live line contributes this order, so it is not a distinct paid order
+ expect(z1.orders).toBe(0);
+ expect(z1.statusMix).toEqual({});
+ // the canceled line is still visible in the drill-down
+ expect(z1.lines).toBe(1);
+ expect(z1.contributors).toHaveLength(1);
+ });
+
+ it("reconciles non-canceled input: Σ item qty == Σ input quantity and Σ contributors == input line count", () => {
+ const rows = [
+ line({ quantity: 3 }),
+ line({ item_code: "B", quantity: 0 }),
+ line({ sponsor: { id: 9, name: "Other" }, item_code: "C", quantity: 4 })
+ ];
+ const groups = groupLinesBySponsorItem(rows);
+ const allItems = groups.flatMap((g) => g.items);
+ const qtySum = allItems.reduce((acc, i) => acc + i.qty, 0);
+ const contribCount = allItems.reduce(
+ (acc, i) => acc + i.contributors.length,
+ 0
+ );
+ expect(qtySum).toBe(7);
+ expect(contribCount).toBe(rows.length);
+ });
+});
+
+const item = (over = {}) => ({
+ itemCode: "AV1",
+ label: "Audio mixer",
+ qty: 5,
+ orders: 2,
+ lines: 3,
+ totalCents: 250000,
+ statusMix: { Paid: 1, "Pending Payment": 1 },
+ contributors: [
+ {
+ number: "OCP-1",
+ formCode: "AV",
+ addOnName: "Meeting Room T",
+ checkoutAt: 1735000000,
+ rateName: "Early",
+ status: "Paid",
+ qty: 3,
+ lineTotalCents: 150000,
+ isCanceled: false
+ },
+ {
+ number: "OCP-2",
+ formCode: "AV",
+ addOnName: null,
+ checkoutAt: null,
+ rateName: "Standard",
+ status: "Pending Payment",
+ qty: 2,
+ lineTotalCents: 100000,
+ isCanceled: true
+ }
+ ],
+ ...over
+});
+
+const group = (over = {}) => ({
+ sponsorId: 17,
+ sponsorName: "Acme",
+ items: [item()],
+ totalQty: 5,
+ itemCount: 1,
+ purchasedCount: 1,
+ ...over
+});
+
+const renderView = (props = {}) =>
+ render(
+
+ );
+
+describe("ByItemView", () => {
+ it("renders one accordion per sponsor with the items chip and Σ Qty", () => {
+ renderView({
+ groups: [
+ group(),
+ group({ sponsorId: 9, sponsorName: "Beta", totalQty: 1 })
+ ]
+ });
+ expect(screen.getByText("Acme")).toBeInTheDocument();
+ expect(screen.getByText("Beta")).toBeInTheDocument();
+ // Interpolated mock: key:values
+ expect(
+ screen.getAllByText("sponsor_reports_page.byitem_sponsor_items_chip:1,1")
+ ).toHaveLength(2);
+ expect(
+ screen.getAllByText("sponsor_reports_page.byitem_sum_qty:5")
+ ).toHaveLength(1);
+ });
+
+ it("item row click toggles the contributing-orders drill-down", () => {
+ renderView();
+ expect(screen.queryByText("OCP-1")).not.toBeInTheDocument();
+ fireEvent.click(screen.getByText("AV1"));
+ expect(screen.getByText("OCP-1")).toBeInTheDocument();
+ expect(screen.getByText("Meeting Room T")).toBeInTheDocument();
+ // Canceled contributor is rendered, marked, not filtered.
+ const canceledRow = screen.getByText("OCP-2").closest("tr");
+ expect(canceledRow).toHaveAttribute("data-canceled", "true");
+ fireEvent.click(screen.getByText("AV1"));
+ expect(screen.queryByText("OCP-1")).not.toBeInTheDocument();
+ });
+
+ it("expand button toggles the drill-down and reflects aria-expanded", () => {
+ renderView();
+ const toggle = screen.getByRole("button", {
+ name: "sponsor_reports_page.byitem_contributing_orders"
+ });
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
+ expect(screen.queryByText("OCP-1")).not.toBeInTheDocument();
+ fireEvent.click(toggle);
+ expect(toggle).toHaveAttribute("aria-expanded", "true");
+ expect(screen.getByText("OCP-1")).toBeInTheDocument();
+ fireEvent.click(toggle);
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
+ expect(screen.queryByText("OCP-1")).not.toBeInTheDocument();
+ });
+
+ it("renders the status mix as chips keyed by real purchase statuses", () => {
+ renderView();
+ expect(screen.getByText("Paid: 1")).toBeInTheDocument();
+ expect(screen.getByText("Pending Payment: 1")).toBeInTheDocument();
+ });
+
+ it("renders — for a null totalCents and keeps zero-qty items visible", () => {
+ renderView({
+ groups: [
+ group({
+ items: [
+ item(),
+ item({
+ itemCode: "Z1",
+ label: "Unbought",
+ qty: 0,
+ totalCents: null
+ })
+ ],
+ itemCount: 2
+ })
+ ]
+ });
+ expect(screen.getByText("Unbought")).toBeInTheDocument();
+ expect(screen.getByText("—")).toBeInTheDocument();
+ });
+
+ it("pages sponsor groups and clamps an out-of-range page", () => {
+ const groups = Array.from({ length: 12 }, (_, i) =>
+ group({ sponsorId: i + 1, sponsorName: `Sponsor ${i + 1}` })
+ );
+ // currentPage 5 is out of range for 12 groups @ 10/page → clamps to page 2.
+ renderView({ groups, currentPage: 5, perPage: 10 });
+ expect(screen.queryByText("Sponsor 1")).not.toBeInTheDocument();
+ expect(screen.getByText("Sponsor 11")).toBeInTheDocument();
+ expect(screen.getByText("Sponsor 12")).toBeInTheDocument();
+ });
+
+ it("wires TablePagination to the 1-based onPageChange / onPerPageChange", () => {
+ const onPageChange = jest.fn();
+ const onPerPageChange = jest.fn();
+ const groups = Array.from({ length: 12 }, (_, i) =>
+ group({ sponsorId: i + 1, sponsorName: `Sponsor ${i + 1}` })
+ );
+ renderView({ groups, onPageChange, onPerPageChange });
+ fireEvent.click(screen.getByRole("button", { name: /next page/i }));
+ expect(onPageChange).toHaveBeenCalledWith(2);
+ // Rows-per-page (MUI 6 Select: trigger role="combobox", options role="option").
+ fireEvent.mouseDown(screen.getByRole("combobox"));
+ fireEvent.click(screen.getByRole("option", { name: "20" }));
+ expect(onPerPageChange).toHaveBeenCalledWith(20);
+ });
+});
diff --git a/src/components/sponsors/reports/__tests__/ReportViewToggle.test.js b/src/components/sponsors/reports/__tests__/ReportViewToggle.test.js
index 1f9be3e92..2c4a2d48b 100644
--- a/src/components/sponsors/reports/__tests__/ReportViewToggle.test.js
+++ b/src/components/sponsors/reports/__tests__/ReportViewToggle.test.js
@@ -17,4 +17,13 @@ describe("ReportViewToggle", () => {
fireEvent.click(screen.getByText("sponsor_reports_page.view_orders"));
expect(onChange).not.toHaveBeenCalled();
});
+
+ it("renders the By Item option and reports it on click", () => {
+ const onChange = jest.fn();
+ render();
+ fireEvent.click(
+ screen.getByRole("button", { name: "sponsor_reports_page.view_by_item" })
+ );
+ expect(onChange).toHaveBeenCalledWith("byitem");
+ });
});
diff --git a/src/i18n/en.json b/src/i18n/en.json
index f775dfaa7..d816a7f64 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -4359,6 +4359,7 @@
"view_toggle": "View",
"view_orders": "Orders",
"view_line_items": "Line Items",
+ "view_by_item": "By Item",
"lines_count": "{count} lines",
"destination_booth_fallback": "Booth",
"col_checkout_time": "Checkout Time",
@@ -4381,6 +4382,13 @@
"col_invoice_sub_status": "Invoice Status",
"col_invoice_due_date": "Invoice Due",
"col_line_total": "Line Total",
+ "byitem_col_orders": "Orders",
+ "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:",
"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 956486c80..5d6ffd4c5 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
@@ -77,6 +77,12 @@ jest.mock("../../../../../actions/sponsor-reports-actions", () => ({
exportPurchaseDetailsLinesCsv: jest.fn(() => ({
type: "EXPORT_PD_LINES_CSV"
})),
+ getPurchaseDetailsByItemRows: jest.fn(() => ({
+ type: "REQUEST_PURCHASE_DETAILS_BY_ITEM"
+ })),
+ setPurchaseDetailsByItemPaging: jest.fn(() => ({
+ type: "SET_PURCHASE_DETAILS_BY_ITEM_PAGING"
+ })),
PURCHASE_DETAILS_VALIDATION_CLEAR: "PURCHASE_DETAILS_VALIDATION_CLEAR",
PURCHASE_DETAILS_READ_ERROR: "PURCHASE_DETAILS_READ_ERROR"
}));
@@ -85,7 +91,9 @@ jest.mock("../../../../../actions/sponsor-reports-actions", () => ({
const {
getPurchaseDetailsReport,
getPurchaseDetailsLinesReport,
- exportPurchaseDetailsLinesCsv
+ exportPurchaseDetailsLinesCsv,
+ getPurchaseDetailsByItemRows,
+ setPurchaseDetailsByItemPaging
} = require("../../../../../actions/sponsor-reports-actions");
// ────────────────────────────────────────────────────────────────────────────
@@ -130,7 +138,14 @@ const PAGE_URL = "/app/summits/42/sponsors/reports/purchase-details";
function buildState(
summaryOverrides = {},
- { total = 1, ordersFilters = {}, linesFilters = {} } = {}
+ {
+ total = 1,
+ ordersFilters = {},
+ linesFilters = {},
+ byItemFilters = {},
+ byItemData = [],
+ byItemReadError = null
+ } = {}
) {
return {
sponsorReportsPurchaseDetailsState: {
@@ -179,6 +194,14 @@ function buildState(
perPage: 50,
filters: linesFilters,
readError: null
+ },
+ sponsorReportsPurchaseDetailsByItemState: {
+ data: byItemData,
+ summary: null,
+ currentPage: 1,
+ perPage: 10,
+ filters: byItemFilters,
+ readError: byItemReadError
}
};
}
@@ -370,6 +393,171 @@ describe("PurchaseDetailsReportPage", () => {
});
});
+ it("switching to By Item fetches the whole line set with the carried Orders filters", async () => {
+ const history = createMemoryHistory({ initialEntries: [PAGE_URL] });
+ renderWithRedux(
+
+
+ ,
+ {
+ initialState: buildState(
+ {},
+ { ordersFilters: { dateFrom: "2026-01-01" } }
+ )
+ }
+ );
+ await act(async () => {});
+ await act(async () => {
+ fireEvent.click(screen.getByText("sponsor_reports_page.view_by_item"));
+ });
+ // Whole-set fetch with the Orders filters carried over — not {} and not
+ // the byitem slice's own (empty) filters.
+ expect(getPurchaseDetailsByItemRows).toHaveBeenCalledWith({
+ dateFrom: "2026-01-01"
+ });
+ });
+
+ it("does NOT re-fetch the By Item whole-set on view toggle when rows are already loaded and filters are unchanged", async () => {
+ const history = createMemoryHistory({ initialEntries: [PAGE_URL] });
+ renderWithRedux(
+
+
+ ,
+ // Rows already loaded; Orders and By Item filters both empty (unchanged).
+ { initialState: buildState({}, { byItemData: [SAMPLE_LINE] }) }
+ );
+ await act(async () => {});
+ // orders → byitem → orders → byitem: the expensive whole-set burst must not
+ // re-fire when nothing changed and the set is already loaded.
+ await act(async () => {
+ fireEvent.click(screen.getByText("sponsor_reports_page.view_by_item"));
+ });
+ await act(async () => {
+ fireEvent.click(screen.getByText("sponsor_reports_page.view_orders"));
+ });
+ await act(async () => {
+ fireEvent.click(screen.getByText("sponsor_reports_page.view_by_item"));
+ });
+ expect(getPurchaseDetailsByItemRows).not.toHaveBeenCalled();
+ });
+
+ it("DOES re-fetch the By Item whole-set on entry when rows are loaded but the carried filters differ", async () => {
+ const history = createMemoryHistory({ initialEntries: [PAGE_URL] });
+ renderWithRedux(
+
+
+ ,
+ // Rows already loaded, but Orders filters differ from the byitem slice's:
+ // the cache gate must NOT hit — the loaded set is for different filters.
+ {
+ initialState: buildState(
+ {},
+ {
+ byItemData: [SAMPLE_LINE],
+ ordersFilters: { dateFrom: "2026-01-01" },
+ byItemFilters: {}
+ }
+ )
+ }
+ );
+ await act(async () => {});
+ await act(async () => {
+ fireEvent.click(screen.getByText("sponsor_reports_page.view_by_item"));
+ });
+ // Carried (Orders) filters win and drive a fresh whole-set fetch.
+ expect(getPurchaseDetailsByItemRows).toHaveBeenCalledWith({
+ dateFrom: "2026-01-01"
+ });
+ });
+
+ it("DOES re-fetch the By Item whole-set on re-entry when the prior fetch errored (stale rows must not serve as a cache hit)", async () => {
+ const history = createMemoryHistory({ initialEntries: [PAGE_URL] });
+ renderWithRedux(
+
+
+ ,
+ // Nonempty (stale) rows + unchanged filters, but the last fetch errored —
+ // re-entry must retry rather than skip.
+ {
+ initialState: buildState(
+ {},
+ { byItemData: [SAMPLE_LINE], byItemReadError: { message: "boom" } }
+ )
+ }
+ );
+ await act(async () => {});
+ await act(async () => {
+ fireEvent.click(screen.getByText("sponsor_reports_page.view_by_item"));
+ });
+ expect(getPurchaseDetailsByItemRows).toHaveBeenCalledWith({});
+ });
+
+ it("Export CSV in the By Item view dispatches the LINES csv export with the byitem slice filters", async () => {
+ const history = createMemoryHistory({ initialEntries: [PAGE_URL] });
+ renderWithRedux(
+
+
+ ,
+ { initialState: buildState({}, { byItemFilters: { status: "Paid" } }) }
+ );
+ await act(async () => {});
+ await act(async () => {
+ fireEvent.click(screen.getByText("sponsor_reports_page.view_by_item"));
+ });
+ // Guard: switching views must not trigger an export on its own.
+ expect(exportPurchaseDetailsLinesCsv).not.toHaveBeenCalled();
+ await act(async () => {
+ fireEvent.click(screen.getByText("sponsor_reports_page.export_csv"));
+ });
+ expect(exportPurchaseDetailsLinesCsv).toHaveBeenCalledWith({
+ status: "Paid"
+ });
+ });
+
+ it("changing rows-per-page in the By Item view dispatches SET paging reset to page 1", async () => {
+ const history = createMemoryHistory({ initialEntries: [PAGE_URL] });
+ renderWithRedux(
+
+
+ ,
+ { initialState: buildState() }
+ );
+ await act(async () => {});
+ await act(async () => {
+ fireEvent.click(screen.getByText("sponsor_reports_page.view_by_item"));
+ });
+ // MUI 6 Select trigger is role="combobox"; options are role="option".
+ // FilterBar renders its own comboboxes in the same tree, so scope to the
+ // last one on the page — the pagination footer's rows-per-page select.
+ await act(async () => {
+ fireEvent.mouseDown(screen.getAllByRole("combobox").at(-1));
+ });
+ await act(async () => {
+ fireEvent.click(screen.getByRole("option", { name: "20" }));
+ });
+ expect(setPurchaseDetailsByItemPaging).toHaveBeenCalledWith({
+ currentPage: 1,
+ perPage: 20
+ });
+ });
+
+ it("hides the Payment Method filter in the By Item view (lines filter set omits it)", async () => {
+ const history = createMemoryHistory({ initialEntries: [PAGE_URL] });
+ renderWithRedux(
+
+
+ ,
+ { initialState: buildState() }
+ );
+ await act(async () => {});
+ await act(async () => {
+ fireEvent.click(screen.getByText("sponsor_reports_page.view_by_item"));
+ });
+ expect(
+ document.querySelector("#pd-filter-payment-method")
+ ).not.toBeInTheDocument();
+ });
+
describe("validation error — snackbar hook", () => {
it("calls errorMessage with the validationError message when validationError is set", async () => {
renderPageWithValidationError({ message: "Too many filters" });
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 1b9e3a352..eafe21aac 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
@@ -11,7 +11,7 @@
* limitations under the License.
* */
-import React, { useEffect, useRef, useState } from "react";
+import React, { useEffect, useMemo, useRef, useState } from "react";
import { connect } from "react-redux";
import { withRouter } from "react-router-dom";
import moment from "moment-timezone";
@@ -30,6 +30,9 @@ import FilterBar from "../../../../components/sponsors/reports/FilterBar";
import OrdersTable from "../../../../components/sponsors/reports/OrdersTable";
import LinesManifestView from "../../../../components/sponsors/reports/LinesManifestView";
import ReportViewToggle from "../../../../components/sponsors/reports/ReportViewToggle";
+import ByItemView, {
+ groupLinesBySponsorItem
+} from "../../../../components/sponsors/reports/ByItemView";
import usePrint from "../../../../hooks/usePrint";
import {
getPurchaseDetailsReport,
@@ -37,7 +40,9 @@ import {
getPurchaseDetailsFilters,
clearPurchaseDetailsValidation,
exportPurchaseDetailsCsv,
- exportPurchaseDetailsLinesCsv
+ exportPurchaseDetailsLinesCsv,
+ getPurchaseDetailsByItemRows,
+ setPurchaseDetailsByItemPaging
} from "../../../../actions/sponsor-reports-actions";
import { DEFAULT_CURRENT_PAGE } from "../../../../utils/constants";
@@ -67,6 +72,12 @@ const toPickerValue = (ymd) =>
const sameFilters = (a, b) =>
JSON.stringify(a ?? {}) === JSON.stringify(b ?? {});
+// Pick a per-view value by the current toggle state, keeping the growing view
+// set out of nested ternaries. Keys map 1:1 to "orders" | "lines" | "byitem".
+// Pass plain values to select one, or thunks when the branches must stay lazy
+// (e.g. the export handler, so only the active view's exporter runs).
+const byView = (key, choices) => choices[key];
+
const PurchaseDetailsReportPage = ({
// Orders slice (spread via mapStateToProps) — pagination/sort/filter now live
// in the reducer (recorded on REQUEST) so they survive SPA navigation.
@@ -89,6 +100,15 @@ const PurchaseDetailsReportPage = ({
linesCurrentPage,
linesPerPage,
linesFilters,
+ // By Item slice (whole-set rows + client paging over derived sponsor groups)
+ byItemData,
+ byItemSummary,
+ byItemReadError,
+ byItemCurrentPage,
+ byItemPerPage,
+ byItemFilters,
+ getPurchaseDetailsByItemRows: fetchByItemRows,
+ setPurchaseDetailsByItemPaging: setByItemPaging,
// From mapDispatchToProps (object form — bound action creators)
getPurchaseDetailsReport: fetchReport,
getPurchaseDetailsLinesReport: fetchLinesReport,
@@ -100,8 +120,8 @@ const PurchaseDetailsReportPage = ({
const print = usePrint();
const { errorMessage } = useSnackbarMessage();
- // "orders" | "lines" — a transient UI toggle (NOT server state), so it stays
- // local. Everything else is sourced from the reducer slices above.
+ // "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");
const prevViewRef = useRef(view);
@@ -124,30 +144,57 @@ const PurchaseDetailsReportPage = ({
}, []); // mount-only
// Fetch the active view. Runs on mount (initial load) and on every view switch.
- // A single FilterBar is shared across both views, so the filters applied in the
+ // A single FilterBar is shared across all views, so the filters applied in the
// view we're leaving are carried into the view we're entering; the entering view
// keeps its own page when those filters are unchanged, else snaps back to page 1.
useEffect(() => {
const prevView = prevViewRef.current;
prevViewRef.current = view;
- const carried = prevView === "orders" ? filters : linesFilters;
+ const carried = byView(prevView, {
+ orders: filters,
+ lines: linesFilters,
+ byitem: byItemFilters
+ });
if (view === "orders") {
const page = sameFilters(carried, filters)
? currentPage
: DEFAULT_CURRENT_PAGE;
fetchReport(carried, { page, perPage, order, orderDir });
- } else {
+ } else if (view === "lines") {
const page = sameFilters(carried, linesFilters)
? linesCurrentPage
: DEFAULT_CURRENT_PAGE;
fetchLinesReport(carried, { page, perPage: linesPerPage });
+ } else {
+ // Whole-set fetch (no server page). Keep the user's client page when the
+ // carried filters match; else snap back to page 1 (result set changed).
+ const filtersUnchanged = sameFilters(carried, byItemFilters);
+ if (!filtersUnchanged) {
+ setByItemPaging({
+ currentPage: DEFAULT_CURRENT_PAGE,
+ perPage: byItemPerPage
+ });
+ }
+ // Unlike the Orders/Lines single-page fetches above, this drives a
+ // multi-page whole-set burst. Skip it when re-entering By Item with
+ // unchanged filters and rows already loaded (a plain view toggle).
+ // Guard on !byItemReadError so a failed filtered fetch (which leaves stale
+ // rows + the new filters in the slice) still retries on re-entry instead
+ // of serving the stale set as a false cache hit.
+ if (!(filtersUnchanged && byItemData.length > 0 && !byItemReadError)) {
+ fetchByItemRows(carried);
+ }
}
}, [view]);
// ── Summary tiles ───────────────────────────────────────────────────────────
// D9: Total Refunded tile renders ONLY when total_refunded != null — a defensive
// presence check (the field is optional in the summary payload).
- const activeSummary = view === "orders" ? summary : linesSummary;
+ const activeSummary = byView(view, {
+ orders: summary,
+ lines: linesSummary,
+ byitem: byItemSummary
+ });
// money: format integer CENTS via uicore; guard unexpected nulls with em dash.
const money = (cents) =>
cents == null ? "—" : currencyAmountFromCents(cents);
@@ -200,11 +247,18 @@ const PurchaseDetailsReportPage = ({
order,
orderDir
});
- } else {
+ } else if (view === "lines") {
fetchLinesReport(next, {
page: DEFAULT_CURRENT_PAGE,
perPage: linesPerPage
});
+ } else {
+ // New result set → client page resets to 1.
+ setByItemPaging({
+ currentPage: DEFAULT_CURRENT_PAGE,
+ perPage: byItemPerPage
+ });
+ fetchByItemRows(next);
}
};
const handleClear = () => handleApply({});
@@ -347,7 +401,23 @@ const PurchaseDetailsReportPage = ({
>
);
- const activeFilters = view === "orders" ? filters : linesFilters;
+ const activeFilters = byView(view, {
+ orders: filters,
+ lines: linesFilters,
+ byitem: byItemFilters
+ });
+
+ const activeReadError = byView(view, {
+ orders: readError,
+ lines: linesReadError,
+ byitem: byItemReadError
+ });
+
+ // Memoized: regroups only when the whole-set rows change, not on every render.
+ const byItemGroups = useMemo(
+ () => groupLinesBySponsorItem(byItemData),
+ [byItemData]
+ );
return (
}
variant="outlined"
onClick={() =>
- view === "orders"
- ? exportOrdersCsv(filters, order, orderDir)
- : exportLinesCsv(linesFilters)
+ byView(view, {
+ orders: () => exportOrdersCsv(filters, order, orderDir),
+ lines: () => exportLinesCsv(linesFilters),
+ byitem: () => exportLinesCsv(byItemFilters)
+ })()
}
>
{T.translate("sponsor_reports_page.export_csv")}
@@ -387,32 +459,53 @@ const PurchaseDetailsReportPage = ({
}
>
- {(view === "orders" ? readError : linesReadError) ? (
+ {activeReadError ? (
- {(view === "orders" ? readError : linesReadError)?.message ||
+ {activeReadError?.message ||
T.translate("sponsor_reports_page.read_error")}
- ) : view === "orders" ? (
-
) : (
-
+ byView(view, {
+ orders: (
+
+ ),
+ lines: (
+
+ ),
+ byitem: (
+
+ setByItemPaging({ currentPage: page, perPage: byItemPerPage })
+ }
+ onPerPageChange={(newPerPage) =>
+ setByItemPaging({
+ currentPage: DEFAULT_CURRENT_PAGE,
+ perPage: newPerPage
+ })
+ }
+ />
+ )
+ })
)}
);
@@ -420,7 +513,8 @@ const PurchaseDetailsReportPage = ({
const mapStateToProps = ({
sponsorReportsPurchaseDetailsState,
- sponsorReportsPurchaseDetailsLinesState
+ sponsorReportsPurchaseDetailsLinesState,
+ sponsorReportsPurchaseDetailsByItemState
}) => ({
...sponsorReportsPurchaseDetailsState,
linesData: sponsorReportsPurchaseDetailsLinesState.data,
@@ -429,7 +523,13 @@ const mapStateToProps = ({
linesReadError: sponsorReportsPurchaseDetailsLinesState.readError,
linesCurrentPage: sponsorReportsPurchaseDetailsLinesState.currentPage,
linesPerPage: sponsorReportsPurchaseDetailsLinesState.perPage,
- linesFilters: sponsorReportsPurchaseDetailsLinesState.filters
+ linesFilters: sponsorReportsPurchaseDetailsLinesState.filters,
+ byItemData: sponsorReportsPurchaseDetailsByItemState.data,
+ byItemSummary: sponsorReportsPurchaseDetailsByItemState.summary,
+ byItemReadError: sponsorReportsPurchaseDetailsByItemState.readError,
+ byItemCurrentPage: sponsorReportsPurchaseDetailsByItemState.currentPage,
+ byItemPerPage: sponsorReportsPurchaseDetailsByItemState.perPage,
+ byItemFilters: sponsorReportsPurchaseDetailsByItemState.filters
});
const mapDispatchToProps = {
@@ -438,7 +538,9 @@ const mapDispatchToProps = {
getPurchaseDetailsFilters,
clearPurchaseDetailsValidation,
exportPurchaseDetailsCsv,
- exportPurchaseDetailsLinesCsv
+ exportPurchaseDetailsLinesCsv,
+ getPurchaseDetailsByItemRows,
+ setPurchaseDetailsByItemPaging
};
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
new file mode 100644
index 000000000..79bf30252
--- /dev/null
+++ b/src/reducers/sponsors/__tests__/sponsor-reports-purchase-details-by-item-reducer.test.js
@@ -0,0 +1,59 @@
+import { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions";
+import reducer, {
+ DEFAULT_STATE
+} from "../sponsor-reports-purchase-details-by-item-reducer";
+import { SET_CURRENT_SUMMIT } from "../../../actions/summit-actions";
+import {
+ REQUEST_PURCHASE_DETAILS_BY_ITEM,
+ RECEIVE_PURCHASE_DETAILS_BY_ITEM_ROWS
+} from "../../../actions/sponsor-reports-actions";
+
+describe("sponsor-reports-purchase-details-by-item-reducer", () => {
+ it("REQUEST records the active filters and clears readError", () => {
+ const prev = { ...DEFAULT_STATE, readError: { status: 403 } };
+ const state = reducer(prev, {
+ type: REQUEST_PURCHASE_DETAILS_BY_ITEM,
+ payload: { filters: { sponsorIds: [17] } }
+ });
+ expect(state.filters).toEqual({ sponsorIds: [17] });
+ expect(state.readError).toBeNull();
+ });
+
+ it("RECEIVE_ROWS commits data + summary atomically and clears readError", () => {
+ const rows = [{ item_code: "A1" }, { item_code: "B1" }];
+ const summary = { total_orders: 11 };
+ const state = reducer(
+ { ...DEFAULT_STATE, readError: { status: 500 } },
+ {
+ type: RECEIVE_PURCHASE_DETAILS_BY_ITEM_ROWS,
+ payload: { response: { data: rows, summary } }
+ }
+ );
+ expect(state.data).toEqual(rows);
+ expect(state.summary).toEqual(summary);
+ expect(state.readError).toBeNull();
+ });
+
+ it("RECEIVE_ROWS falls back to a null summary when the envelope omits it", () => {
+ const state = reducer(
+ { ...DEFAULT_STATE, summary: { total_orders: 11 } },
+ {
+ type: RECEIVE_PURCHASE_DETAILS_BY_ITEM_ROWS,
+ payload: { response: { data: [] } }
+ }
+ );
+ expect(state.summary).toBeNull();
+ });
+
+ it.each([[SET_CURRENT_SUMMIT], [LOGOUT_USER]])(
+ "%s resets to DEFAULT_STATE",
+ (type) => {
+ const dirty = {
+ ...DEFAULT_STATE,
+ data: [{ item_code: "A1" }],
+ currentPage: 4
+ };
+ expect(reducer(dirty, { type, payload: {} })).toEqual(DEFAULT_STATE);
+ }
+ );
+});
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
new file mode 100644
index 000000000..b7aa833ef
--- /dev/null
+++ b/src/reducers/sponsors/sponsor-reports-purchase-details-by-item-reducer.js
@@ -0,0 +1,67 @@
+/**
+ * Copyright 2026 OpenStack Foundation
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * */
+
+import { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions";
+import { DEFAULT_CURRENT_PAGE, 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
+} from "../../actions/sponsor-reports-actions";
+
+export const DEFAULT_STATE = {
+ data: [], // ALL filtered line rows (whole-set fetch; client-side rollup)
+ summary: null,
+ // Client-side paging over the DERIVED sponsor groups — no server page exists.
+ // Recorded via SET_PURCHASE_DETAILS_BY_ITEM_PAGING (not REQUEST) so the user's
+ // place survives the Orders ↔ By Item toggle and SPA navigation; filters are
+ // recorded on REQUEST like the sibling slices.
+ currentPage: DEFAULT_CURRENT_PAGE,
+ perPage: DEFAULT_PER_PAGE,
+ filters: {},
+ readError: null
+};
+
+const reducer = (state = DEFAULT_STATE, action) => {
+ const { type, payload } = action;
+ switch (type) {
+ case LOGOUT_USER:
+ case SET_CURRENT_SUMMIT:
+ return DEFAULT_STATE;
+ case REQUEST_PURCHASE_DETAILS_BY_ITEM: {
+ const { filters } = payload;
+ return { ...state, filters, readError: null };
+ }
+ case RECEIVE_PURCHASE_DETAILS_BY_ITEM_ROWS: {
+ const env = payload.response;
+ return {
+ ...state,
+ data: env.data,
+ summary: env.summary ?? null,
+ readError: null
+ };
+ }
+ case SET_PURCHASE_DETAILS_BY_ITEM_PAGING: {
+ const { currentPage, perPage } = payload;
+ return { ...state, currentPage, perPage };
+ }
+ case PURCHASE_DETAILS_BY_ITEM_READ_ERROR:
+ return { ...state, readError: payload };
+ default:
+ return state;
+ }
+};
+
+export default reducer;
diff --git a/src/store.js b/src/store.js
index 7ea67fde2..67d5f79b1 100644
--- a/src/store.js
+++ b/src/store.js
@@ -178,6 +178,7 @@ import sponsorReportsPurchaseDetailsReducer from "./reducers/sponsors/sponsor-re
import sponsorReportsPurchaseDetailsLinesReducer from "./reducers/sponsors/sponsor-reports-purchase-details-lines-reducer";
import sponsorReportsSponsorAssetReducer from "./reducers/sponsors/sponsor-reports-sponsor-asset-reducer";
import sponsorReportsDrilldownReducer from "./reducers/sponsors/sponsor-reports-drilldown-reducer";
+import sponsorReportsPurchaseDetailsByItemReducer from "./reducers/sponsors/sponsor-reports-purchase-details-by-item-reducer";
import addOnTypeReducer from "./reducers/sponsors_inventory/add-on-type-reducer.js";
import addOnTypesListReducer from "./reducers/sponsors_inventory/add-on-types-list-reducer.js";
@@ -190,6 +191,7 @@ const config = {
"dropboxSyncState",
"sponsorReportsPurchaseDetailsState",
"sponsorReportsPurchaseDetailsLinesState",
+ "sponsorReportsPurchaseDetailsByItemState",
"sponsorReportsSponsorAssetState",
"sponsorReportsDrilldownState"
]
@@ -358,7 +360,9 @@ const reducers = persistCombineReducers(config, {
dropboxSyncState: dropboxSyncReducer,
sponsorReportsPurchaseDetailsState: sponsorReportsPurchaseDetailsReducer,
sponsorReportsPurchaseDetailsLinesState:
- sponsorReportsPurchaseDetailsLinesReducer,
+ sponsorReportsPurchaseDetailsLinesReducer,
+ sponsorReportsPurchaseDetailsByItemState:
+ sponsorReportsPurchaseDetailsByItemReducer,
sponsorReportsSponsorAssetState: sponsorReportsSponsorAssetReducer,
sponsorReportsDrilldownState: sponsorReportsDrilldownReducer,
currentAddOnTypesListState: addOnTypesListReducer,