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,