diff --git a/src/actions/__tests__/sponsor-reports-actions.test.js b/src/actions/__tests__/sponsor-reports-actions.test.js index 0b5493eba..bb0678ccd 100644 --- a/src/actions/__tests__/sponsor-reports-actions.test.js +++ b/src/actions/__tests__/sponsor-reports-actions.test.js @@ -1233,7 +1233,7 @@ describe("sponsor-reports-actions", () => { 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 () => { + it("records the active filters on REQUEST and hits the lines endpoint with paymentMethod", async () => { const store = mockStore(MOCK_STATE); const filters = { sponsorIds: [17], paymentMethod: "Card" }; await store.dispatch(getPurchaseDetailsByItemRows(filters)); @@ -1250,11 +1250,12 @@ describe("sponsor-reports-actions", () => { expect(capturedUrl).toContain( "/summits/42/reports/purchase-details/lines" ); - // buildPurchaseLinesQuery drops paymentMethod (order-level attribute). + // buildPurchaseLinesQuery now carries paymentMethod through (declared on + // the lines filter set via the parent hop). const filterClauses = capturedParams["filter[]"] || []; expect( filterClauses.some((c) => String(c).includes("payment_method")) - ).toBe(false); + ).toBe(true); }); it("bulk-loads all pages (page 1, then the rest in parallel) into one atomic RECEIVE_PURCHASE_DETAILS_BY_ITEM_ROWS", async () => { diff --git a/src/actions/__tests__/sponsor-reports-query.test.js b/src/actions/__tests__/sponsor-reports-query.test.js index 0831d5af7..678dae15d 100644 --- a/src/actions/__tests__/sponsor-reports-query.test.js +++ b/src/actions/__tests__/sponsor-reports-query.test.js @@ -51,6 +51,42 @@ describe("buildReportQuery", () => { }); }); + it("sets include_cancelled from showCanceled with no status clause", () => { + // The point of the separate axis: `status==Canceled` resolves to the parent + // order's status at line grain, so deriving include_cancelled from the status + // value alone can never surface a canceled line inside a Paid order. The + // checkbox must open the flag WITHOUT emitting a status filter. + expect(buildReportQuery({ showCanceled: true })).toStrictEqual({ + include_cancelled: "true" + }); + }); + + it("keeps the status coupling when only the status is Canceled", () => { + // Guard against "fixing" this by replacing the coupling with the checkbox: + // the orders endpoint excludes Canceled by default, so a Canceled selection + // without the flag returns zero rows and the dropdown option goes dead. + expect(buildReportQuery({ status: "Canceled" }).include_cancelled).toBe( + "true" + ); + }); + + it("emits include_cancelled once when both axes are on", () => { + expect( + buildReportQuery({ status: "Canceled", showCanceled: true }) + ).toStrictEqual({ + "filter[]": ["status==Canceled"], + include_cancelled: "true" + }); + }); + + it("omits include_cancelled when showCanceled is false", () => { + expect( + buildReportQuery({ status: "Paid", showCanceled: false }) + ).toStrictEqual({ + "filter[]": ["status==Paid"] + }); + }); + it("passes through search/order/pagination", () => { expect( buildReportQuery({ @@ -132,13 +168,19 @@ describe("buildPurchaseLinesQuery", () => { expect(q).not.toHaveProperty("order"); }); - it("drops payment_method — the lines filter set does not support it", () => { - const q = buildPurchaseLinesQuery( - { status: "Paid", paymentMethod: "Invoice" }, - { page: 1, perPage: 10 } - ); - expect(q["filter[]"]).toEqual(["status==Paid"]); - expect(q["filter[]"]).not.toContain("payment_method==Invoice"); + it("carries paymentMethod through to the lines query", () => { + const query = buildPurchaseLinesQuery({ paymentMethod: "invoice" }); + expect(query["filter[]"]).toContain("payment_method==invoice"); + }); + + it("builds the same payment_method clause at both grains", () => { + // the regression: applying a filter and switching grain must not change the set + const orders = buildPurchaseQuery({ paymentMethod: "invoice" })["filter[]"]; + const lines = buildPurchaseLinesQuery({ paymentMethod: "invoice" })[ + "filter[]" + ]; + const clause = (f) => f.find((c) => c.startsWith("payment_method")); + expect(clause(lines)).toEqual(clause(orders)); }); }); diff --git a/src/actions/sponsor-reports-actions.js b/src/actions/sponsor-reports-actions.js index 3fa35df8d..757365e05 100644 --- a/src/actions/sponsor-reports-actions.js +++ b/src/actions/sponsor-reports-actions.js @@ -180,6 +180,7 @@ export const buildReportQuery = (filters = {}) => { mediaRequestType, dateFrom, dateTo, + showCanceled, search, order, page, @@ -222,8 +223,14 @@ export const buildReportQuery = (filters = {}) => { if (order) query.order = order; if (page != null) query.page = page; if (perPage != null) query.per_page = perPage; - // Canceled is excluded server-side by default. - if (status === "Canceled") query.include_cancelled = "true"; + // Canceled is excluded server-side by default, on TWO independent axes: the + // order's status, and a line's own canceled_at (a soft-canceled line leaves its + // parent order Paid). Selecting the Canceled status must keep implying this, or + // that option would return nothing at order grain -- but it can't be the only + // way in, because `status==Canceled` resolves to `purchase__status` at line + // grain, which excludes the soft-canceled-line rows it is meant to reveal. + // Hence the OR: the checkbox opens the second axis without closing the first. + if (status === "Canceled" || showCanceled) query.include_cancelled = "true"; return query; }; @@ -259,13 +266,11 @@ export const buildPurchaseQuery = ( // Lines grain: same date expansion, NO order (manifest relies on backend default // ordering). Used by the on-screen lines fetch AND exportPurchaseDetailsLinesCsv. -// The lines endpoint's filter set omits payment_method (it's an order-level -// attribute), so drop it here rather than emit a clause BaseFilter silently -// ignores. The UI also hides the Payment Method control in the Line Items view. -export const buildPurchaseLinesQuery = ( - { paymentMethod: _paymentMethod, ...filters } = {}, - { page, perPage } = {} -) => buildReportQuery({ ...expandDates(filters), page, perPage }); +// payment_method IS supported at line grain (declared on PurchaseLineDetailsFilter +// via the parent hop). It used to be dropped here, which is why applying it and +// switching grain silently widened the result set instead of narrowing it. +export const buildPurchaseLinesQuery = (filters = {}, { page, perPage } = {}) => + buildReportQuery({ ...expandDates(filters), page, perPage }); export const getPurchaseDetailsReport = (filters = {}, pagination = {}) => @@ -516,7 +521,7 @@ export const getPurchaseDetailsByItemRows = guardedDispatch( createAction(REQUEST_PURCHASE_DETAILS_BY_ITEM)({ filters }) ); - // Lines-grain query (drops paymentMethod); one arg → no page/per_page emitted. + // Lines-grain query (carries paymentMethod through); one arg → no page/per_page emitted. const baseQuery = buildPurchaseLinesQuery(filters); const url = `${base(currentSummit.id)}/purchase-details/lines`; const fetchPage = (page) => diff --git a/src/components/sponsors/reports/ByItemView.js b/src/components/sponsors/reports/ByItemView.js index 1f644ef72..910901f3d 100644 --- a/src/components/sponsors/reports/ByItemView.js +++ b/src/components/sponsors/reports/ByItemView.js @@ -116,10 +116,14 @@ const accumulateRow = (itemMap, row) => { sponsorBooth: row.sponsor_booth ?? null, checkoutAt: row.purchase?.checkout_at ?? null, rateName: row.rate_name ?? "", - status: row.purchase?.status ?? "", + // the line's own state: a soft-canceled line leaves its parent order Paid + status: row.is_canceled ? "Canceled" : row.purchase?.status ?? "", qty: row.quantity ?? 0, lineTotalCents: row.line_total ?? null, - isCanceled: Boolean(row.is_canceled) + isCanceled: Boolean(row.is_canceled), + // line-grain freshness (decision 1): the contributor row IS a line + syncedAt: row.synced_at ?? null, + sourceUpdatedAt: row.source_updated_at ?? null }); }; @@ -250,7 +254,9 @@ const CONTRIB_HEADERS = [ { key: "col_used_rate" }, { key: "col_status" }, { key: "col_quantity", align: "right" }, - { key: "col_line_total", align: "right" } + { key: "col_line_total", align: "right" }, + { key: "col_synced_at" }, + { key: "col_source_updated" } ]; // One expansion key per (sponsor, item) so the same item code under two @@ -468,6 +474,12 @@ const ItemTable = ({ ? "—" : currencyAmountFromCents(c.lineTotalCents)} + + {formatCheckoutTime(c.syncedAt)} + + + {formatCheckoutTime(c.sourceUpdatedAt)} + ))} diff --git a/src/components/sponsors/reports/LinesManifestView.js b/src/components/sponsors/reports/LinesManifestView.js index 04a875e34..a4d061a6e 100644 --- a/src/components/sponsors/reports/LinesManifestView.js +++ b/src/components/sponsors/reports/LinesManifestView.js @@ -83,10 +83,16 @@ const bucketLinesBySponsor = (rows = []) => { groups.push({ sponsorId: id, sponsorName: row.sponsor?.name ?? "", - lines: [] + lines: [], + // Canceled lines still RENDER (struck through) but must not be counted — + // the chip means live lines, matching the By Item units chip on the same + // screen, which already excludes them. + liveLineCount: 0 }); } - groups[indexByKey.get(key)].lines.push(row); + const group = groups[indexByKey.get(key)]; + group.lines.push(row); + if (!row.is_canceled) group.liveLineCount += 1; }); return groups; }; @@ -102,7 +108,9 @@ const HEADERS = [ { key: "col_quantity", align: "right" }, { key: "col_used_rate" }, { key: "col_status" }, - { key: "col_line_total", align: "right" } + { key: "col_line_total", align: "right" }, + { key: "col_synced_at" }, + { key: "col_source_updated" } ]; const LinesManifestView = ({ @@ -126,7 +134,7 @@ const LinesManifestView = ({ size="small" sx={{ ml: 1.5 }} label={T.translate("sponsor_reports_page.lines_count", { - count: group.lines.length + count: group.liveLineCount })} /> @@ -178,16 +186,35 @@ const LinesManifestView = ({ {line.quantity} {line.rate_name} - + {/* The LINE's state, not the parent order's. A soft-canceled + line leaves its order Paid, so rendering purchase.status + printed "Paid" on a dead row — and the strikethrough that + was the only other signal does not survive CSV export. */} + {line.is_canceled ? ( + + ) : ( + + )} {line.line_total == null ? "—" : currencyAmountFromCents(line.line_total)} + + {formatCheckoutTime(line.synced_at)} + + + {formatCheckoutTime(line.source_updated_at)} + ))} diff --git a/src/components/sponsors/reports/OrdersTable.js b/src/components/sponsors/reports/OrdersTable.js index bb0d6b08b..6cacd9ded 100644 --- a/src/components/sponsors/reports/OrdersTable.js +++ b/src/components/sponsors/reports/OrdersTable.js @@ -135,6 +135,30 @@ const columns = [ header: T.translate("sponsor_reports_page.col_sponsor_note"), sortable: false // not a backend ordering field // No render — MuiTable fallback reads row["sponsor_note"] directly. + }, + { + columnKey: "refunded_amount", + header: T.translate("sponsor_reports_page.col_refunded"), + sortable: false, // not a backend ordering field + align: "right", + // Refunds never touch purchase status — a fully refunded order reads Paid + // forever, so this column is the only on-screen signal. The CSV already had it. + render: (row) => + row.refunded_amount == null + ? "—" + : currencyAmountFromCents(row.refunded_amount) + }, + { + columnKey: "synced_at", + header: T.translate("sponsor_reports_page.col_synced_at"), + sortable: false, // not a backend ordering field + render: (row) => formatCheckoutTime(row.synced_at) + }, + { + columnKey: "source_updated_at", + header: T.translate("sponsor_reports_page.col_source_updated"), + sortable: false, // not a backend ordering field + render: (row) => formatCheckoutTime(row.source_updated_at) } ]; diff --git a/src/components/sponsors/reports/__tests__/ByItemView.test.js b/src/components/sponsors/reports/__tests__/ByItemView.test.js index 666df5c5b..3a1dc2ffe 100644 --- a/src/components/sponsors/reports/__tests__/ByItemView.test.js +++ b/src/components/sponsors/reports/__tests__/ByItemView.test.js @@ -1,5 +1,6 @@ import "@testing-library/jest-dom"; import React from "react"; +import moment from "moment-timezone"; import { render, screen, fireEvent, within } from "@testing-library/react"; import ByItemView, { groupLinesByItem, @@ -146,6 +147,8 @@ describe("groupLinesBySponsorItem", () => { }); it("passes canceled lines through as contributors with isCanceled", () => { + // status carries the LINE's own state, resolved here rather than in the + // render: the fixture's parent order is Paid while this line is canceled. const rows = [line({ is_canceled: true })]; const [group] = groupLinesBySponsorItem(rows); const [contrib] = group.items[0].contributors; @@ -157,13 +160,24 @@ describe("groupLinesBySponsorItem", () => { sponsorBooth: null, checkoutAt: 1735000000, rateName: "Early", - status: "Paid", + status: "Canceled", qty: 2, lineTotalCents: 100000, - isCanceled: true + isCanceled: true, + syncedAt: null, + sourceUpdatedAt: null }); }); + it("carries line-grain freshness into the contributor", () => { + const [g] = groupLinesBySponsorItem([ + line({ synced_at: 1755561600, source_updated_at: 1755558000 }) + ]); + const [c] = g.items[0].contributors; + expect(c.syncedAt).toBe(1755561600); + expect(c.sourceUpdatedAt).toBe(1755558000); + }); + it("EXCLUDES canceled lines from qty/money/purchasedCount/Σqty but keeps them as contributors", () => { const rows = [ line({ @@ -415,6 +429,52 @@ describe("ByItemView", () => { expect(screen.queryByText("OCP-1")).not.toBeInTheDocument(); }); + it("renders the contributor status it is given", () => { + // Distinct epochs (not the same value for both fields) so a swap or a + // missing cell can't hide behind a shared string. + const synced = 1755561600; // 2025-08-19 + const sourceUpdated = 1755648000; // 2025-08-20 + renderView({ + groups: [ + group({ + items: [ + item({ + contributors: [ + { + sponsorName: "FNTECH", + number: "OCP-1", + formCode: "AV", + addOnName: null, + checkoutAt: null, + rateName: "Early", + // groupLinesByItem resolves the line's own state before this + // point (covered above), so the cell renders what it is handed. + status: "Canceled", + qty: 1, + lineTotalCents: 1000, + isCanceled: true, + syncedAt: synced, + sourceUpdatedAt: sourceUpdated + } + ] + }) + ] + }) + ] + }); + fireEvent.click(screen.getByText("AV1")); // expand the item + expect(screen.getByText("Canceled")).toBeInTheDocument(); + const syncedText = moment.unix(synced).utc().format("YYYY-MM-DD h:mm A"); + const sourceUpdatedText = moment + .unix(sourceUpdated) + .utc() + .format("YYYY-MM-DD h:mm A"); + const row = screen.getByText("OCP-1").closest("tr"); + const cells = within(row).getAllByRole("cell"); + expect(cells[cells.length - 2]).toHaveTextContent(syncedText); + expect(cells[cells.length - 1]).toHaveTextContent(sourceUpdatedText); + }); + it("expand button toggles the drill-down and reflects aria-expanded", () => { renderView(); const toggle = screen.getByRole("button", { @@ -484,6 +544,23 @@ describe("ByItemView", () => { }); }); +describe("byitem_sponsor_items_chip copy", () => { + it("does not describe canceled orders as purchased", () => { + // The chip read "13 of 13 items purchased" over a set of deliberately-filtered + // CANCELED orders. Under Jest the chip renders as its i18n key, so the + // wording is only checkable in the catalog — and only by exact equality: + // the template's own {purchased} interpolation key contains the substring + // "purchased", so a substring/regex guard either self-matches the + // placeholder or is too loose to pin the actual sentence (e.g. it would + // let "{purchased} of {items} purchased" or "...items shipped" through). + // eslint-disable-next-line global-require + const en = require("../../../../i18n/en.json"); + expect(en.sponsor_reports_page.byitem_sponsor_items_chip).toBe( + "{purchased} of {items} items with purchases" + ); + }); +}); + describe("sortItems", () => { const row = (over) => item(over); @@ -842,6 +919,31 @@ describe("ByItemView all-sponsors layout", () => { ); expect(onLayoutChange).not.toHaveBeenCalled(); }); + + // A positional last-two-cells check (as used elsewhere for the freshness + // columns) only proves a cell isn't missing — it says nothing about the + // HEADER row, which is a separate array (CONTRIB_HEADERS, optionally + // prepended with col_sponsor). If the two desync by one, every column right + // of the break silently misaligns and stays green. Assert exact + // header/cell cardinality on the NESTED contributor table specifically — + // the outer item table has its own separate ITEM_HEADERS and expansion + // colSpan that must not be counted here. + it.each([ + ["by-sponsor", 10, () => renderView()], + ["all-sponsors", 11, () => renderAll()] + ])( + "the %s drill-down has %i headers matching that many cells per row", + (_name, count, mount) => { + mount(); + fireEvent.click(screen.getByText("AV1")); + const contribTable = screen.getAllByRole("table").at(-1); + expect(within(contribTable).getAllByRole("columnheader")).toHaveLength( + count + ); + const row = within(contribTable).getByText("OCP-1").closest("tr"); + expect(within(row).getAllByRole("cell")).toHaveLength(count); + } + ); }); describe("Destination booth fallback (By Item drill-down)", () => { diff --git a/src/components/sponsors/reports/__tests__/LinesManifestView.test.js b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js index 81ded0b53..4eb0e2569 100644 --- a/src/components/sponsors/reports/__tests__/LinesManifestView.test.js +++ b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js @@ -1,5 +1,6 @@ import "@testing-library/jest-dom"; import React from "react"; +import moment from "moment-timezone"; import { render, screen, within } from "@testing-library/react"; import LinesManifestView from "../LinesManifestView"; @@ -56,6 +57,67 @@ describe("LinesManifestView", () => { expect(row).toHaveAttribute("data-canceled", "true"); }); + it("renders the LINE's own state, not the parent order's status", () => { + // line() defaults to a Paid parent: the exact trap. A soft-canceled line leaves + // its order Paid, so rendering purchase.status printed "Paid" on a dead row. + renderView({ rows: [line({ is_canceled: true })] }); + expect( + screen.getByText("sponsor_reports_page.status_canceled") + ).toBeInTheDocument(); + expect(screen.queryByText("Paid")).not.toBeInTheDocument(); + }); + + it("renders both freshness timestamps, in the row's final two cells", () => { + // formatCheckoutTime is moment.unix(v).utc().format("YYYY-MM-DD h:mm A"). + // Distinct values (not the same epoch for both fields) so a swap or a + // missing cell can't hide behind a shared string. + const synced = 1755561600; // 2025-08-19 + const sourceUpdated = 1755648000; // 2025-08-20 + renderView({ + rows: [line({ synced_at: synced, source_updated_at: sourceUpdated })] + }); + const syncedText = moment.unix(synced).utc().format("YYYY-MM-DD h:mm A"); + const sourceUpdatedText = moment + .unix(sourceUpdated) + .utc() + .format("YYYY-MM-DD h:mm A"); + const row = screen.getByText("AV1").closest("tr"); + const cells = within(row).getAllByRole("cell"); + expect(cells[cells.length - 2]).toHaveTextContent(syncedText); + expect(cells[cells.length - 1]).toHaveTextContent(sourceUpdatedText); + }); + + it("counts only live lines per sponsor group", () => { + // Canceled lines still RENDER (struck through); the chip means LIVE lines, + // matching the By Item units chip on the same screen. The module's local + // i18n mock interpolates {count}, so the chip text carries the number. + renderView({ + rows: [ + line({ is_canceled: false }), + line({ is_canceled: true, item_code: "AV2" }) + ] + }); + // header row + the 2 line rows, exactly — proves neither line was dropped. + expect(screen.getAllByRole("row")).toHaveLength(3); + const canceledRow = screen.getByText("AV2").closest("tr"); + expect(canceledRow).toHaveAttribute("data-canceled", "true"); + expect( + screen.getByText("sponsor_reports_page.lines_count:1") + ).toBeInTheDocument(); + }); + + // A positional last-two-cells check (as used above for the freshness + // columns) only proves a cell isn't missing — it says nothing about the + // HEADER row, which is a separate array (HEADERS). If the two desync by + // one, every column right of the break silently misaligns and stays + // green. Assert exact header/cell cardinality directly. + it("has exactly 13 column headers matching 13 cells per row", () => { + renderView(); + expect(screen.getAllByRole("columnheader")).toHaveLength(13); + const row = screen.getByText("AV1").closest("tr"); + expect(within(row).getAllByRole("cell")).toHaveLength(13); + }); + // Sponsor bucketing (formerly bucketLinesBySponsor, now a private helper). describe("sponsor bucketing", () => { it("groups by sponsor.id preserving first-seen order", () => { @@ -138,3 +200,17 @@ describe("Destination booth fallback", () => { ).toBeInTheDocument(); }); }); + +describe("lines_count copy", () => { + it("says the count is of LIVE lines, not all rendered lines", () => { + // The chip is fed liveLineCount, but canceled lines still RENDER, so a group + // showing two rows reports one. The copy has to say which number it is. + // This module's i18n mock renders the chip from the KEY and COUNT only, so + // the count assertion above is value-independent: the English could regress + // to "{count} lines" and every DOM test would still pass. Pin it in the + // catalog by exact equality, mirroring the By Item chip's copy test. + // eslint-disable-next-line global-require + const en = require("../../../../i18n/en.json"); + expect(en.sponsor_reports_page.lines_count).toBe("{count} live lines"); + }); +}); diff --git a/src/components/sponsors/reports/__tests__/OrdersTable.test.js b/src/components/sponsors/reports/__tests__/OrdersTable.test.js index 42851715d..94c089dcc 100644 --- a/src/components/sponsors/reports/__tests__/OrdersTable.test.js +++ b/src/components/sponsors/reports/__tests__/OrdersTable.test.js @@ -1,7 +1,8 @@ // src/components/sponsors/reports/__tests__/OrdersTable.test.js import "@testing-library/jest-dom"; import React from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; +import moment from "moment-timezone"; +import { render, screen, fireEvent, within } from "@testing-library/react"; import OrdersTable from "../OrdersTable"; // MuiTable uses i18n-react internally (no-items message, pagination labels). @@ -70,12 +71,67 @@ describe("OrdersTable finance columns", () => { payment_method: null, invoice_reference: null, invoice_sub_status: null, - invoice_due_date: null + invoice_due_date: null, + refunded_amount: null }; renderTable([nullRow]); - // invoice_total (25000) still renders $250.00; the four finance cells render —. - // Exactly four em-dash cells appear (one per null finance column). - expect(screen.getAllByText("—")).toHaveLength(4); + // invoice_total (25000) still renders $250.00; the five finance cells render —. + // Exactly five em-dash cells appear (one per null finance column, now + // including refunded_amount added in Task 10). + expect(screen.getAllByText("—")).toHaveLength(5); + }); +}); + +describe("OrdersTable refund and freshness columns", () => { + it("surfaces refunded_amount, which never changes purchase status", () => { + // a refund leaves the order Paid forever; refunded_amount is the only signal + renderTable([{ ...sampleRow, refunded_amount: 100 }]); + expect(screen.getByText("$1.00")).toBeInTheDocument(); + }); + + it("renders both freshness timestamps, in the row's final two cells", () => { + // formatCheckoutTime is moment.unix(v).utc().format("YYYY-MM-DD h:mm A"). + // Distinct values (not the same epoch for both fields) so a swap or a + // missing cell can't hide behind a shared string. + const synced = 1755561600; // 2025-08-19 + const sourceUpdated = 1755648000; // 2025-08-20 + renderTable([ + { ...sampleRow, synced_at: synced, source_updated_at: sourceUpdated } + ]); + const syncedText = moment.unix(synced).utc().format("YYYY-MM-DD h:mm A"); + const sourceUpdatedText = moment + .unix(sourceUpdated) + .utc() + .format("YYYY-MM-DD h:mm A"); + const row = screen.getByText(sampleRow.purchase_number).closest("tr"); + const cells = within(row).getAllByRole("cell"); + expect(cells[cells.length - 2]).toHaveTextContent(syncedText); + expect(cells[cells.length - 1]).toHaveTextContent(sourceUpdatedText); + }); + + it("appends the new columns without disturbing the existing header order", () => { + renderTable(); + const headers = screen + .getAllByRole("columnheader") + .map((h) => h.textContent); + // Full 14-key sequence, not just the appended tail — a reorder or swap + // among the original 11 (e.g. col_order/col_sponsor) must fail this too. + expect(headers).toEqual([ + "sponsor_reports_page.col_order", + "sponsor_reports_page.col_sponsor", + "sponsor_reports_page.col_checkout_time", + "sponsor_reports_page.col_type", + "sponsor_reports_page.col_status", + "sponsor_reports_page.col_invoice_total", + "sponsor_reports_page.col_payment_method", + "sponsor_reports_page.col_invoice_reference", + "sponsor_reports_page.col_invoice_sub_status", + "sponsor_reports_page.col_invoice_due_date", + "sponsor_reports_page.col_sponsor_note", + "sponsor_reports_page.col_refunded", + "sponsor_reports_page.col_synced_at", + "sponsor_reports_page.col_source_updated" + ]); }); }); diff --git a/src/i18n/en.json b/src/i18n/en.json index 58eb3a2c0..23606a892 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -4336,10 +4336,13 @@ "download_csv": "Download CSV", "total_orders": "Total Sales", "total_items": "Total Items", - "total_paid": "Total Paid", + "total_paid": "Total Paid (before refunds)", "total_pending": "Total Pending", "total_refunded": "Total Refunded", "filter_status": "Purchase Status", + "filter_status_info": "Canceled orders are excluded unless you select Canceled or check Show canceled", + "filter_status_info_lines": "Canceled orders and individually canceled line items are excluded unless you select Canceled or check Show canceled", + "filter_show_canceled": "Show canceled", "filter_form": "Type", "filter_payment_method": "Payment Method", "any": "Any", @@ -4360,6 +4363,7 @@ "status_completed": "Completed", "status_in_progress": "In Progress", "status_pending": "Pending", + "status_canceled": "Canceled", "filter_asset_status": "Status", "group_by": "Group by", "report_filters": "Report Filters", @@ -4372,7 +4376,7 @@ "view_orders": "Orders", "view_line_items": "Line Items", "view_by_item": "By Item", - "lines_count": "{count} lines", + "lines_count": "{count} live lines", "destination_booth_fallback": "Booth", "col_checkout_time": "Checkout Time", "col_invoice_total": "Invoice Total", @@ -4394,9 +4398,12 @@ "col_invoice_sub_status": "Invoice Status", "col_invoice_due_date": "Invoice Due", "col_line_total": "Line Total", + "col_refunded": "Refunded", + "col_synced_at": "Synced", + "col_source_updated": "Source Updated", "byitem_col_orders": "Orders", "byitem_col_total": "Total", - "byitem_sponsor_items_chip": "{purchased} of {items} items purchased", + "byitem_sponsor_items_chip": "{purchased} of {items} items with purchases", "byitem_sum_qty": "{qty} units", "byitem_contributing_orders": "Contributing orders", "byitem_sponsors_per_page": "Sponsors per page:", 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 4a7b0f662..8690bc635 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 @@ -373,6 +373,17 @@ describe("PurchaseDetailsReportPage", () => { ); }); + it("keeps the Payment Method control visible on the line views", async () => { + renderPage(); + await act(async () => { + fireEvent.click(screen.getByText("sponsor_reports_page.view_line_items")); + }); + // aria-label is T.translate(...), which renders as the raw KEY under Jest + expect( + screen.getByLabelText("sponsor_reports_page.filter_payment_method") + ).toBeInTheDocument(); + }); + it("Line Items CSV export passes the lines slice filters to exportPurchaseDetailsLinesCsv", async () => { // Export reads the applied filters from the lines slice (recorded on REQUEST // in production); seed them directly since the mock store is inert. @@ -575,7 +586,7 @@ describe("PurchaseDetailsReportPage", () => { }); }); - it("hides the Payment Method filter in the By Item view (lines filter set omits it)", async () => { + it("keeps the Payment Method filter visible in the By Item view (lines endpoint honors it via the parent hop)", async () => { const history = createMemoryHistory({ initialEntries: [PAGE_URL] }); renderWithRedux( @@ -587,9 +598,10 @@ describe("PurchaseDetailsReportPage", () => { await act(async () => { fireEvent.click(screen.getByText("sponsor_reports_page.view_by_item")); }); + // aria-label is T.translate(...), which renders as the raw KEY under Jest expect( - document.querySelector("#pd-filter-payment-method") - ).not.toBeInTheDocument(); + screen.getByLabelText("sponsor_reports_page.filter_payment_method") + ).toBeInTheDocument(); }); describe("validation error — snackbar hook", () => { @@ -599,4 +611,112 @@ describe("PurchaseDetailsReportPage", () => { expect(mockErrorMessage).toHaveBeenCalledWith("Too many filters"); }); }); + + describe("self-describing chrome — gross Total Paid label + canceled default note", () => { + it("labels Total Paid as gross", () => { + // Decision 7 keeps the figure gross; the label is the whole fix. Under Jest the + // tile renders "sponsor_reports_page.total_paid" (the i18n mock is the identity + // function), so the catalog value is the only place this copy is checkable — + // and exact equality (not a substring match) is the only assertion that can + // both catch a wrong replacement string and notice "Total Paid" going missing. + const en = require("../../../../../i18n/en.json"); + expect(en.sponsor_reports_page.total_paid).toBe( + "Total Paid (before refunds)" + ); + }); + + it("still renders the gross figure unchanged beside Total Refunded", () => { + // currencyAmountFromCents (openstack-uicore-foundation) has no thousands + // separator — verified directly against the installed lib, so this is + // "$13297.00", not "$13,297.00". Out of scope to change here; decision 7 + // only requires the gross figure itself stay untouched. + renderPage({ total_paid: 1329700, total_refunded: 100 }); + expect(screen.getByText("$13297.00")).toBeInTheDocument(); + expect(screen.getByText("$1.00")).toBeInTheDocument(); + }); + + it("states the silent canceled default on the status control's info icon", () => { + // Carried as the repo's hover-info idiom rather than helper text under the + // control: helper text made this filter twice the height of its siblings, + // and the center-aligned filter row then rendered it out of line with + // them. jsdom has no layout, so the alignment itself is not assertable — + // what IS assertable is that the note is no longer a block under the + // control, which is the thing that changed the height. + renderPage(); + const icon = document.querySelector("i.fa-info-circle"); + expect(icon).toBeInTheDocument(); + expect(icon).toHaveAttribute( + "title", + "sponsor_reports_page.filter_status_info" + ); + }); + + it("offers Show canceled as its own control, not derived from the status", () => { + // The status dropdown cannot reach the line-level axis: `status==Canceled` + // resolves to the PARENT order's status server-side, so it can never + // surface a canceled line inside a Paid order. The checkbox is the only + // way to ask for those rows. + renderPage(); + expect( + screen.getByLabelText("sponsor_reports_page.filter_show_canceled") + ).toBeInTheDocument(); + }); + + it("applies showCanceled with no status selected", async () => { + renderPage(); + await act(async () => {}); + getPurchaseDetailsReport.mockClear(); + + await act(async () => { + fireEvent.click( + screen.getByLabelText("sponsor_reports_page.filter_show_canceled") + ); + }); + await act(async () => { + fireEvent.click(screen.getByText("sponsor_reports_page.apply")); + }); + + const [[calledFilters]] = getPurchaseDetailsReport.mock.calls; + expect(calledFilters).toMatchObject({ showCanceled: true }); + // No status clause rides along — that is the whole point of the axis. + expect(calledFilters.status).toBeUndefined(); + }); + + it("describes the extra line-level axis on the line views only", async () => { + // The order grain hides canceled ORDERS; the line grains additionally hide + // individually canceled LINES. One string for both is wrong on one of them. + renderPage(); + await act(async () => {}); + expect(document.querySelector("i.fa-info-circle")).toHaveAttribute( + "title", + "sponsor_reports_page.filter_status_info" + ); + + await act(async () => { + fireEvent.click( + screen.getByText("sponsor_reports_page.view_line_items") + ); + }); + expect(document.querySelector("i.fa-info-circle")).toHaveAttribute( + "title", + "sponsor_reports_page.filter_status_info_lines" + ); + }); + + it("associates that note with the status control for screen readers", () => { + // Rendering the note NEAR the control is not the same as announcing it + // WITH the control. The note is a sibling, not a FormControl child, so + // MUI cannot wire this up itself — assert the aria-describedby actually + // resolves to the note's id rather than trusting visual adjacency. + renderPage(); + const control = screen.getByLabelText( + "sponsor_reports_page.filter_status" + ); + const noteId = control.getAttribute("aria-describedby"); + expect(noteId).toBeTruthy(); + expect(document.getElementById(noteId)).toHaveTextContent( + "sponsor_reports_page.filter_status_info" + ); + }); + }); }); 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 aa6df674c..c173561bc 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 @@ -16,7 +16,8 @@ import { connect } from "react-redux"; import { withRouter } from "react-router-dom"; import moment from "moment-timezone"; import T from "i18n-react/dist/i18n-react"; -import { Alert, Box, Button } from "@mui/material"; +import { Alert, Box, Button, Checkbox, FormControlLabel } from "@mui/material"; +import { visuallyHidden } from "@mui/utils"; import PrintIcon from "@mui/icons-material/Print"; import DownloadIcon from "@mui/icons-material/Download"; import ShoppingCartOutlinedIcon from "@mui/icons-material/ShoppingCartOutlined"; @@ -56,6 +57,17 @@ import { DEFAULT_CURRENT_PAGE } from "../../../../utils/constants"; // backend query contract is untouched. const REPORT_DATE_TZ = "UTC"; const REPORT_DATE_FORMAT = "YYYY-MM-DD"; +// Shared by the visually-hidden copy of the Purchase Status note and the +// aria-describedby that points at it, so the two cannot drift apart. +const STATUS_NOTE_ID = "pd-filter-status-note"; +// The canceled-default note is grain-aware: the order grain hides canceled +// ORDERS, while the line grains additionally hide individually canceled LINES +// (a separate axis — a soft-canceled line sits inside a Paid order). One string +// for both would be wrong on one of them. +const statusNoteKey = (view) => + view === "orders" + ? "sponsor_reports_page.filter_status_info" + : "sponsor_reports_page.filter_status_info_lines"; // The uicore picker emits moment(0) (epoch) on a CLEAR, not null — treat that // sentinel as "no date" so clearing removes the filter instead of sending // date>=1970-01-01. This matches the house filter convention (`.unix() || null` @@ -340,7 +352,9 @@ const PurchaseDetailsReportPage = ({ const extraControls = (draft, update) => ( <> - + {/* Icon is aria-hidden, so the same copy lives in a visually-hidden span + the Select points at via aria-describedby. */} + update({ status: e.target.value || undefined })} + aria-describedby={STATUS_NOTE_ID} + /> +