Skip to content

feat(sponsor-reports): port Sponsor Reports FE into Summit Admin (Sponsors → Reports) - #997

Merged
smarcet merged 93 commits into
masterfrom
feature/sponsor-reports-port
Jul 6, 2026
Merged

feat(sponsor-reports): port Sponsor Reports FE into Summit Admin (Sponsors → Reports)#997
smarcet merged 93 commits into
masterfrom
feature/sponsor-reports-port

Conversation

@caseylocker

@caseylocker caseylocker commented Jun 25, 2026

Copy link
Copy Markdown

ref: https://app.clickup.com/t/86bak754x

Ports the two sponsor reports (Purchase Details + Sponsor Assets, incl. the per-sponsor asset drill-down) out of sponsor-services and into summit-admin as an admin-only "Reports" sub-item under the Sponsors menu, reading the in-context summit. Backend (sponsor-reports-api) is unchanged. This is Phase 1 — parity port; the JP-pivot logistics/finance features are Phase 2 follow-ons.

What's included

  • Placement & access: Sponsors → Reports sub-item; layout wrapped Restrict(withRouter(SponsorReportsLayout), "admin-sponsors") — an explicit admin-only gate that reuses the existing admin-sponsors key and closes the direct-URL gap for the reports area. Summit is in-context (summit picker + /reports/summits dropped).
  • Re-implementation on summit-admin's stack (not a copy of the source): react-redux 5 connect() (no hooks), react-router 4 withRouter (no v6 hooks), i18n-react T.translate (no react-i18next), uicore MuiTable (no @mui/x-data-grid), CSV via uicore getCSV.
  • Purchase Details: OrdersTable rebuilt on MuiTable; formatCheckoutTime handles both ISO and epoch; Total Refunded tile renders only when summary.total_refunded != null (auto-appears once that field ships — no FE change needed).
  • Sponsor Assets: group-by sponsor/component views + per-sponsor drill-down; hard-coded drill-down links rewired to /app/summits/:summitId/sponsors/reports/sponsor-assets/sponsors/:sponsorId.
  • Landing with two report cards; breadcrumb trail Sponsors → Reports → <page>.
  • Env: SPONSOR_REPORTS_API_URL injected in app.js + .env/.env.example.

Tests / quality

  • yarn jest: 915 passing, 0 regressions vs master. yarn lint: 0 new errors (only the repo's pre-existing prop-types warnings).
  • Reviewed per-task (opus) + whole-branch (opus) + an independent Codex pass — all clean (0 Critical / 0 Important).

Deploy note (not blocking this PR)

Backend auth wiring (D5) is done on dev — repointed the deployed sponsor-reports-api scope env to reports/all + added the admin groups; verified both reports load real data on dev. Staging/prod need the same env at launch (tracked in the ClickUp ticket). No IDP change was needed (summit-admin already carries reports/all).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a new Sponsor Reports area in the app, including a landing page, Purchase Details (Orders + Line Items), Sponsor Assets, and sponsor drill-down pages.
    • Introduced filtering, grouping, pagination, status rollups, and CSV export with print-friendly reporting layout.
  • Bug Fixes
    • Improved handling of loading, empty, validation, and read-error states, including clearer behavior when CSV export is disabled.
    • Added more robust display formatting for dates, currency, and rich-text content.

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds sponsor reports configuration, Redux wiring, shared report UI, routes, and two sponsor report pages with supporting utilities and tests.

Changes

Sponsor reports feature

Layer / File(s) Summary
Helpers and query shaping
src/app.js, .env.example, src/utils/*, src/utils/__tests__/*, src/hooks/usePrint.js, src/hooks/__tests__/usePrint.test.js
Environment config, report error/query utilities, formatting helpers, manifest grouping, and hook support are added with tests.
Async actions
src/actions/sponsor-reports-actions.js, src/actions/__tests__/sponsor-reports-actions.test.js
Sponsor report thunks fetch purchase details, purchase-detail lines, sponsor assets, and drilldown data and route read, validation, export-disabled, and reauth outcomes.
Reducers and store
src/reducers/sponsors/*, src/reducers/sponsors/__tests__/*, src/store.js
New reducer slices track purchase-details, purchase-details lines, sponsor-asset, and drilldown state, and the store persists the corresponding keys.
Report controls and print support
src/components/sponsors/reports/{ReportShell,FilterBar,ExportCsvButton,GroupByToggle,ReportViewToggle}.js, src/components/sponsors/reports/report-print.css, src/components/sponsors/reports/__tests__/{ReportShell,FilterBar,ExportCsvButton,GroupByToggle,ReportViewToggle}.test.js
Reusable report shell, filters, CSV export, grouping toggles, view toggle, and print stylesheet are added with tests.
Status and display primitives
src/components/sponsors/reports/{statusTone,StatusPill,StatusRollupChips,SummaryPanel,TierBadge,SponsorAvatar}.js, src/components/sponsors/reports/__tests__/{StatusPill,StatusRollupChips,SummaryPanel,TierBadge,SponsorAvatar}.test.js
Status tone mapping, pills, rollup chips, summary tiles, tier badges, and sponsor avatars are added with tests.
Tables and line manifests
src/components/sponsors/reports/{OrdersTable,LinesManifestView}.js, src/components/sponsors/reports/__tests__/{OrdersTable,LinesManifestView}.test.js
Orders tables and line manifests render report data and add view-specific tests.
Grouped sponsor and component cards
src/components/sponsors/reports/{GroupBySponsorView,GroupByComponentView}.js, src/components/sponsors/reports/__tests__/{GroupBySponsorView,GroupByComponentView}.test.js
Sponsor and component grouped cards render report data and add view-specific tests.
Navigation and landing
src/components/menu/menu-definition.js, src/i18n/en.json, src/layouts/sponsor-layout.js, src/layouts/sponsor-reports-layout.js, src/layouts/__tests__/sponsor-reports-layout.test.js, src/pages/sponsors/sponsor-reports/reports-landing-page/*
The sponsor reports menu entry, translations, routes, breadcrumbs, landing cards, and layout tests are added.
Purchase details page
src/pages/sponsors/sponsor-reports/purchase-details-report-page/*
The purchase-details page builds report queries from filters, sort, and pagination, renders summary tiles and actions, and dispatches report and filter thunks with validation handling.
Sponsor asset pages
src/pages/sponsors/sponsor-reports/{sponsor-asset-report-page,sponsor-asset-drilldown-page}/*
The sponsor-asset report and drilldown pages load grouped data, render sponsor and component views, export CSV, and show per-module content states.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested reviewers

  • smarcet
  • martinquiroga-exo

Poem

🐰 I sniffed the carrots, crisp and bright,
and hopped through reports by day and night.
CSV trails and badges in sight,
sponsor cards gleam soft and light.
Hop-hop, these changes feel just right!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: porting Sponsor Reports into Summit Admin under Sponsors → Reports.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/sponsor-reports-port

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
src/pages/sponsors/sponsor-reports/__tests__/routing.test.js (1)

33-65: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Test replicates route ordering instead of asserting on the real layout.

Because renderSwitch hand-copies the <Switch> table rather than mounting sponsor-reports-layout.js, it validates React Router's behavior, not the production ordering. If the real layout's route order drifts, this test won't catch it. The drilldown-vs-landing ordering is already covered against the real component in sponsor-reports-layout.test.js, so this can either be removed or driven through the actual layout to guard against regressions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pages/sponsors/sponsor-reports/__tests__/routing.test.js` around lines 33
- 65, This test is duplicating the route table instead of exercising the real
sponsor-reports layout, so it won’t catch regressions in the production
ordering. Update the routing coverage to mount sponsor-reports-layout.js
directly (using the existing SponsorReportsLayout or equivalent wrapper) instead
of the local renderSwitch Switch copy, or remove this redundant spec if
sponsor-reports-layout.test.js already covers the drilldown-vs-list ordering.
src/components/sponsors/reports/FilterBar.js (1)

28-29: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

draft won't re-sync if the value prop changes after mount.

useState(value) only seeds the initial state; later changes to the value prop (e.g. an external filter reset) won't be reflected in draft. Current flows keep parent/child in sync manually (Apply commits the draft, Clear resets both to {}), so there's no active bug, but this coupling is fragile if value ever becomes externally driven. Consider syncing via an effect keyed on value if that scenario is anticipated.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/sponsors/reports/FilterBar.js` around lines 28 - 29, The local
draft state in FilterBar is only initialized from the value prop and will not
update if that prop changes later. If external changes to value are expected,
add synchronization in FilterBar using an effect keyed on value so setDraft
stays aligned; keep the update helper unchanged and make sure the draft state
refreshes whenever the incoming value object changes.
src/reducers/sponsors/__tests__/sponsor-reports-reducers.test.js (1)

110-124: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for PURCHASE_DETAILS_VALIDATION_CLEAR.

This file validates the set path for validationError, but not the clear action branch; add one test to prevent regressions in inline/toast reset flow.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/reducers/sponsors/__tests__/sponsor-reports-reducers.test.js` around
lines 110 - 124, Add a test for the PURCHASE_DETAILS_VALIDATION_CLEAR branch in
purchaseDetailsReducer to cover the reset path for validationError and loading
state. Use the existing PURCHASE_DETAILS_VALIDATION_ERROR test in
sponsor-reports-reducers.test.js as a guide, but assert that dispatching
PURCHASE_DETAILS_VALIDATION_CLEAR clears validationError without replacing the
current data/body so the inline/toast reset flow stays protected.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/actions/sponsor-reports-actions.js`:
- Around line 34-54: Add an early guard in getPurchaseDetailsReport and
getPurchaseDetailsFilters to check that currentSummit and currentSummit.id exist
before calling dispatch(startLoading()) or base(currentSummit.id). If the summit
is missing, exit the thunk immediately (optionally dispatch a validation/error
action) so the synchronous throw never happens and stopLoading() is not skipped.
Keep the fix localized in sponsor-reports-actions.js and use the existing thunk
names to update both purchase-details actions consistently.

In `@src/utils/reports-money.js`:
- Around line 10-13: Update formatUsd in reports-money.js so blank or
whitespace-only strings are treated as missing values instead of being converted
by Number(). Add an explicit check before parsing string input (for example in
the formatUsd path) to return "—" when the value is empty after trimming, while
keeping the existing USD.format behavior for valid numeric values.

---

Nitpick comments:
In `@src/components/sponsors/reports/FilterBar.js`:
- Around line 28-29: The local draft state in FilterBar is only initialized from
the value prop and will not update if that prop changes later. If external
changes to value are expected, add synchronization in FilterBar using an effect
keyed on value so setDraft stays aligned; keep the update helper unchanged and
make sure the draft state refreshes whenever the incoming value object changes.

In `@src/pages/sponsors/sponsor-reports/__tests__/routing.test.js`:
- Around line 33-65: This test is duplicating the route table instead of
exercising the real sponsor-reports layout, so it won’t catch regressions in the
production ordering. Update the routing coverage to mount
sponsor-reports-layout.js directly (using the existing SponsorReportsLayout or
equivalent wrapper) instead of the local renderSwitch Switch copy, or remove
this redundant spec if sponsor-reports-layout.test.js already covers the
drilldown-vs-list ordering.

In `@src/reducers/sponsors/__tests__/sponsor-reports-reducers.test.js`:
- Around line 110-124: Add a test for the PURCHASE_DETAILS_VALIDATION_CLEAR
branch in purchaseDetailsReducer to cover the reset path for validationError and
loading state. Use the existing PURCHASE_DETAILS_VALIDATION_ERROR test in
sponsor-reports-reducers.test.js as a guide, but assert that dispatching
PURCHASE_DETAILS_VALIDATION_CLEAR clears validationError without replacing the
current data/body so the inline/toast reset flow stays protected.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 878ce767-9e7a-43b8-97cf-abd302c54c9b

📥 Commits

Reviewing files that changed from the base of the PR and between d3f018b and 07c3261.

📒 Files selected for processing (57)
  • .env.example
  • src/actions/__tests__/sponsor-reports-actions.test.js
  • src/actions/sponsor-reports-actions.js
  • src/app.js
  • src/components/menu/menu-definition.js
  • src/components/sponsors/reports/ExportCsvButton.js
  • src/components/sponsors/reports/FilterBar.js
  • src/components/sponsors/reports/GroupByComponentView.js
  • src/components/sponsors/reports/GroupBySponsorView.js
  • src/components/sponsors/reports/GroupByToggle.js
  • src/components/sponsors/reports/OrdersTable.js
  • src/components/sponsors/reports/ReportShell.js
  • src/components/sponsors/reports/StatusPill.js
  • src/components/sponsors/reports/StatusRollupChips.js
  • src/components/sponsors/reports/SummaryPanel.js
  • src/components/sponsors/reports/TierBadge.js
  • src/components/sponsors/reports/__tests__/ExportCsvButton.test.js
  • src/components/sponsors/reports/__tests__/FilterBar.test.js
  • src/components/sponsors/reports/__tests__/GroupByComponentView.test.js
  • src/components/sponsors/reports/__tests__/GroupBySponsorView.test.js
  • src/components/sponsors/reports/__tests__/GroupByToggle.test.js
  • src/components/sponsors/reports/__tests__/OrdersTable.test.js
  • src/components/sponsors/reports/__tests__/ReportShell.test.js
  • src/components/sponsors/reports/__tests__/StatusPill.test.js
  • src/components/sponsors/reports/__tests__/StatusRollupChips.test.js
  • src/components/sponsors/reports/__tests__/SummaryPanel.test.js
  • src/components/sponsors/reports/__tests__/TierBadge.test.js
  • src/components/sponsors/reports/__tests__/statusTone.test.js
  • src/components/sponsors/reports/__tests__/usePrint.test.js
  • src/components/sponsors/reports/report-print.css
  • src/components/sponsors/reports/statusTone.js
  • src/components/sponsors/reports/usePrint.js
  • src/i18n/en.json
  • src/layouts/__tests__/sponsor-reports-layout.test.js
  • src/layouts/sponsor-layout.js
  • src/layouts/sponsor-reports-layout.js
  • src/pages/sponsors/sponsor-reports/__tests__/routing.test.js
  • src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js
  • src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js
  • src/pages/sponsors/sponsor-reports/reports-landing-page/__tests__/index.test.js
  • src/pages/sponsors/sponsor-reports/reports-landing-page/index.js
  • src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js
  • src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js
  • src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js
  • src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js
  • src/reducers/sponsors/__tests__/sponsor-reports-reducers.test.js
  • src/reducers/sponsors/sponsor-reports-drilldown-reducer.js
  • src/reducers/sponsors/sponsor-reports-purchase-details-reducer.js
  • src/reducers/sponsors/sponsor-reports-sponsor-asset-reducer.js
  • src/store.js
  • src/utils/constants.js
  • src/utils/report-errors.js
  • src/utils/report-query.js
  • src/utils/reports-api.js
  • src/utils/reports-money.js
  • src/utils/reports-text.js
  • src/utils/section-csv-query.js

Comment thread src/actions/sponsor-reports-actions.js
Comment thread src/pages/sponsors/sponsor-reports/reports-money.js Outdated
caseylocker added a commit that referenced this pull request Jun 25, 2026
- guard all report thunks against a missing currentSummit (prevents a stuck
  spinner from base(currentSummit.id) throwing after startLoading)
- formatUsd: treat blank/whitespace strings + non-finite numbers as missing
  (em dash) instead of $0.00; add reports-money tests
- FilterBar: re-sync draft when the committed value prop changes externally
- drop redundant routing.test.js (real-layout integration test in
  sponsor-reports-layout.test.js already covers route ordering)
- add PURCHASE_DETAILS_VALIDATION_CLEAR reducer test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@caseylocker caseylocker self-assigned this Jun 25, 2026
@caseylocker
caseylocker requested review from Copilot and smarcet June 25, 2026 03:43

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Ports the Sponsor Reports frontend (Purchase Details + Sponsor Assets, including per-sponsor drill-down) into summit-admin under Sponsors → Reports, using the existing admin-sponsors access gate and the in-context summit selection.

Changes:

  • Adds a new Sponsor Reports layout + routes (landing, purchase details, sponsor assets, drill-down) and wires it into the Sponsors menu.
  • Implements report UI building blocks (report shell, filters, grouping views, CSV export, print) plus supporting query/text/money utilities.
  • Introduces Redux actions/reducers for sponsor reports and adds extensive unit/integration test coverage + i18n strings.

Reviewed changes

Copilot reviewed 57 out of 57 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/utils/section-csv-query.js Builds section-scoped CSV export query while preserving unrelated query params.
src/utils/reports-text.js HTML-to-plain-text helper for report text fields.
src/utils/reports-money.js USD formatting helper for report summary/rows.
src/utils/reports-api.js Reports API base URL helper + positive-int route ID validator.
src/utils/report-query.js Translates report filter UI state into base-api-utils query params.
src/utils/report-errors.js Normalizes/classifies report API errors and provides a getRequest-shaped handler factory.
src/utils/constants.js Adds HTTP 503 constant used by report error classification.
src/utils/tests/reports-money.test.js Unit tests for USD formatting helper.
src/store.js Registers sponsor reports reducers and excludes their state from persistence.
src/reducers/sponsors/sponsor-reports-sponsor-asset-reducer.js Sponsor Assets grouped report reducer.
src/reducers/sponsors/sponsor-reports-purchase-details-reducer.js Purchase Details report reducer (incl. validation vs read-error split).
src/reducers/sponsors/sponsor-reports-drilldown-reducer.js Sponsor Assets drill-down reducer.
src/reducers/sponsors/tests/sponsor-reports-reducers.test.js Reducer unit tests covering all sponsor reports reducers.
src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js Sponsor Assets grouped report page (group-by, filters, pagination, CSV/print).
src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/tests/index.test.js Sponsor Assets report page tests (dispatching, toggles, states, CSV props).
src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js Per-sponsor drill-down page (section cards, content rendering, per-section CSV).
src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/tests/index.test.js Drill-down page tests including ContentCell behaviors.
src/pages/sponsors/sponsor-reports/reports-landing-page/index.js Reports landing page with two navigation cards.
src/pages/sponsors/sponsor-reports/reports-landing-page/tests/index.test.js Landing page tests for cards and links.
src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js Purchase Details report page (filters, sorting, pagination, CSV/print, tiles).
src/pages/sponsors/sponsor-reports/purchase-details-report-page/tests/index.test.js Purchase Details page tests including filter->query behavior and CSV dispatch.
src/layouts/sponsor-reports-layout.js Sponsor Reports sub-router + breadcrumb composition + admin gate.
src/layouts/sponsor-layout.js Adds /reports route under Sponsors layout.
src/layouts/tests/sponsor-reports-layout.test.js Layout routing + Restrict gate integration tests (landing, unauthorized, drilldown, crumbs).
src/i18n/en.json Adds menu label + Sponsor Reports page strings.
src/components/sponsors/reports/usePrint.js Hook wrapper around window.print() for reports.
src/components/sponsors/reports/TierBadge.js Tier badge chip with explicit tier color mapping.
src/components/sponsors/reports/SummaryPanel.js Shared summary tiles panel component.
src/components/sponsors/reports/statusTone.js Central status->chip-tone mapping helper.
src/components/sponsors/reports/StatusRollupChips.js Renders stable-order rollup chips for grouped Sponsor Assets cards.
src/components/sponsors/reports/StatusPill.js Single status chip component with shared tone mapping.
src/components/sponsors/reports/ReportShell.js Shared report page shell (header, action slot, filter slot, print CSS hook).
src/components/sponsors/reports/report-print.css Print-only CSS to print report body.
src/components/sponsors/reports/OrdersTable.js Purchase Details table built on uicore MuiTable + date/time formatting helpers.
src/components/sponsors/reports/GroupByToggle.js Toggle for sponsor/component grouping mode.
src/components/sponsors/reports/GroupBySponsorView.js Sponsor-grouped card list with drill-down links.
src/components/sponsors/reports/GroupByComponentView.js Component-grouped card list with per-sponsor links and content previews.
src/components/sponsors/reports/FilterBar.js Shared report filter bar (sponsor multiselect + optional search + extra controls).
src/components/sponsors/reports/ExportCsvButton.js CSV export button integrating uicore getCSV and access token retrieval.
src/components/sponsors/reports/tests/usePrint.test.js Unit test for print hook.
src/components/sponsors/reports/tests/TierBadge.test.js Unit tests for TierBadge rendering behavior.
src/components/sponsors/reports/tests/SummaryPanel.test.js Unit tests for SummaryPanel rendering and empty behavior.
src/components/sponsors/reports/tests/statusTone.test.js Unit tests for status tone mapping helper.
src/components/sponsors/reports/tests/StatusRollupChips.test.js Unit tests for rollup chips ordering and null rollup behavior.
src/components/sponsors/reports/tests/StatusPill.test.js Unit tests for StatusPill label behavior and tone mapping re-export.
src/components/sponsors/reports/tests/ReportShell.test.js Unit tests for ReportShell slots and icon rendering.
src/components/sponsors/reports/tests/OrdersTable.test.js Unit tests for table helpers and basic rendering/sort interactions.
src/components/sponsors/reports/tests/GroupByToggle.test.js Unit tests for group toggle behavior (ignore null toggle).
src/components/sponsors/reports/tests/GroupBySponsorView.test.js Unit tests for sponsor-grouped card rendering and link targets.
src/components/sponsors/reports/tests/GroupByComponentView.test.js Unit tests for component-grouped rendering, content hints, and links.
src/components/sponsors/reports/tests/FilterBar.test.js Unit tests for FilterBar sponsorIds emission and optional search box.
src/components/sponsors/reports/tests/ExportCsvButton.test.js Unit tests for CSV dispatch args, disabling, and in-flight click guard.
src/components/menu/menu-definition.js Adds Sponsors → Reports menu entry guarded by admin-sponsors.
src/app.js Injects SPONSOR_REPORTS_API_URL into window config.
src/actions/sponsor-reports-actions.js Thunks for reports endpoints (purchase details, sponsor assets, filters, drilldown).
src/actions/tests/sponsor-reports-actions.test.js Unit tests for sponsor reports thunks and read-error handler behavior.
.env.example Adds SPONSOR_REPORTS_API_URL example env var.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/utils/reports-text.js Outdated
Comment thread src/utils/report-errors.js Outdated
Comment thread src/utils/report-query.js Outdated
caseylocker added a commit that referenced this pull request Jun 25, 2026
- reports-text: decode uppercase/mixed-case HTML entities (the case-insensitive
  match was missing the lowercase ENTITIES map); add toPlainText tests
- report-errors: export-disabled now dispatches { kind, status, message } to
  match the other error branches
- report-query: coerce sponsorIds to positive integers and drop the rest so the
  filter never emits sponsor_id==NaN/==0; add buildReportQuery tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@smarcet
smarcet requested a review from santipalenque June 25, 2026 14:03

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.env.example:
- Line 26: The SCOPES aggregate still includes reports/all, which is broader
than needed and conflicts with the dedicated sponsor report scopes. Update the
SCOPES value in .env.example to remove reports/all while keeping the existing
scoped report permissions such as SPONSOR_REPORTS_SCOPES and the other named
scope variables intact.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 05a2e27c-7d13-497e-bd72-9787506508fa

📥 Commits

Reviewing files that changed from the base of the PR and between 07c3261 and d342f26.

📒 Files selected for processing (12)
  • .env.example
  • src/actions/__tests__/sponsor-reports-actions.test.js
  • src/actions/sponsor-reports-actions.js
  • src/components/sponsors/reports/FilterBar.js
  • src/reducers/sponsors/__tests__/sponsor-reports-reducers.test.js
  • src/utils/__tests__/report-query.test.js
  • src/utils/__tests__/reports-money.test.js
  • src/utils/__tests__/reports-text.test.js
  • src/utils/report-errors.js
  • src/utils/report-query.js
  • src/utils/reports-money.js
  • src/utils/reports-text.js
✅ Files skipped from review due to trivial changes (2)
  • src/utils/tests/reports-text.test.js
  • src/utils/tests/reports-money.test.js
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/utils/reports-money.js
  • src/utils/report-query.js
  • src/utils/report-errors.js
  • src/reducers/sponsors/tests/sponsor-reports-reducers.test.js
  • src/actions/tests/sponsor-reports-actions.test.js

Comment thread .env.example

@smarcet smarcet left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two low-severity findings from the deep review — both are code-quality nits with no functional impact on the feature. @caseylocker tagging you for awareness.

Comment thread src/utils/section-csv-query.js Outdated
Comment thread src/components/sponsors/reports/OrdersTable.js Outdated

@smarcet smarcet left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Convention audit against the established codebase patterns. 4 inline findings below — none are blockers, but two are 1-line fixes. @caseylocker

Comment thread src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js Outdated
Comment thread src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js Outdated
Comment thread src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js Outdated
Comment thread src/hooks/usePrint.js
Comment thread src/actions/sponsor-reports-query.js Outdated
@smarcet

smarcet commented Jun 25, 2026

Copy link
Copy Markdown

Addressing the two remaining false positives from CodeRabbit's nitpick summary (review #4567585391):


FilterBar.js:28-29 — draft won't re-sync if value prop changes after mount

The useEffect that re-syncs draft to the committed value prop is already there:

useEffect(() => {
  setDraft(value);
}, [value]);

The comment above it even explains the intent: "Re-sync the draft when the committed value prop changes externally (e.g. a parent-driven reset)." The finding describes exactly what the code already does.


sponsor-reports-reducers.test.js:110-124 — missing test for PURCHASE_DETAILS_VALIDATION_CLEAR

The test is present, starting at line 127:

describe("PURCHASE_DETAILS_VALIDATION_CLEAR", () => {
  it("clears validationError without replacing the body", () => {
    ...
    expect(result.validationError).toBeNull();
  });
});

CodeRabbit scanned lines 110–124 (the set path) and didn't look at the lines immediately following.

@smarcet smarcet left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@caseylocker please review

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js`:
- Around line 126-141: The three effects in
purchase-details-report-page/index.js are missing the in-context summit as a
dependency, so they won’t rerun when the summit changes after mount. Update the
useEffect hooks that call fetchFilters, fetchReport, and fetchLinesReport to
depend on currentSummit?.id (or the equivalent summit identifier from the
component scope) along with the existing inputs so the page refetches filters
and reports when the active summit is hydrated or switched. Keep the mount-only
behavior only if it still includes summit changes as a trigger for the
data-loading effects.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: aceb4674-64b2-4907-afd9-8c0ad10a05a7

📥 Commits

Reviewing files that changed from the base of the PR and between d342f26 and c1cc057.

📒 Files selected for processing (14)
  • src/actions/__tests__/sponsor-reports-actions.test.js
  • src/actions/sponsor-reports-actions.js
  • src/components/sponsors/reports/LinesManifestView.js
  • src/components/sponsors/reports/ReportViewToggle.js
  • src/components/sponsors/reports/__tests__/LinesManifestView.test.js
  • src/components/sponsors/reports/__tests__/ReportViewToggle.test.js
  • src/i18n/en.json
  • src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js
  • src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js
  • src/reducers/sponsors/__tests__/sponsor-reports-purchase-details-lines-reducer.test.js
  • src/reducers/sponsors/sponsor-reports-purchase-details-lines-reducer.js
  • src/store.js
  • src/utils/__tests__/manifest-grouping.test.js
  • src/utils/manifest-grouping.js
✅ Files skipped from review due to trivial changes (1)
  • src/utils/manifest-grouping.js
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/store.js
  • src/i18n/en.json
  • src/actions/tests/sponsor-reports-actions.test.js

Comment thread src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js Outdated
caseylocker added a commit that referenced this pull request Jun 25, 2026
- index.js: import DEFAULT_PER_PAGE from constants (drop local DEFAULT_PAGE_SIZE dup)
- index.js: mapDispatchToProps to object shorthand via new clearPurchaseDetailsValidation
  action creator (removes the raw dispatch prop)
- OrdersTable.js: remove dead getOrderRowId export + its test (page maps id inline)
- usePrint: move to src/hooks/ per the codebase custom-hook convention
- section-csv-query.js: align defense-in-depth comment with the sponsor_id/page_id guard

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js (1)

196-208: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: fold the duplicated CSV url/query derivation into a small helper.

linesCsvUrl/linesCsvQuery repeat the exact base-URL construction and page/per_page stripping already done for the orders export just above. A tiny helper (e.g. buildCsv(path, srcQuery) returning { url, query }) would remove the copy and keep both exports in sync if the strip rules change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js`
around lines 196 - 208, The CSV export setup for purchase details duplicates the
same URL construction and pagination-stripping logic already used for the orders
export. Extract that repeated logic into a small shared helper in this page (for
example, a function that builds the CSV url and query from a path and source
query), then use it for both exports so `linesCsvUrl` and `linesCsvQuery` stay
aligned with the existing order export behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js`:
- Around line 196-208: The CSV export setup for purchase details duplicates the
same URL construction and pagination-stripping logic already used for the orders
export. Extract that repeated logic into a small shared helper in this page (for
example, a function that builds the CSV url and query from a path and source
query), then use it for both exports so `linesCsvUrl` and `linesCsvQuery` stay
aligned with the existing order export behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a24726cb-2af3-4f33-a505-8cb87decc70c

📥 Commits

Reviewing files that changed from the base of the PR and between 645fe0c and 809356f.

📒 Files selected for processing (2)
  • src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js
  • src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js

@santipalenque santipalenque left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @caseylocker, this PR doesn't follow our codebase's coding patterns. For example, you added several files to the utils dir that are specific to these reports. No other page or section of the admin does this — if it did, utils would end up with dozens of one-off files, defeating the purpose of having a generic utils dir.

I left a few examples inline. Could you go through the PR and align the solution with summit-admin's coding patterns?

Comment thread src/utils/reports-api.js Outdated
Comment thread src/pages/sponsors/sponsor-reports/reports-money.js Outdated
Comment thread src/utils/reports-text.js Outdated
Comment thread src/utils/section-csv-query.js Outdated
Comment thread src/components/sponsors/reports/ExportCsvButton.js Outdated
caseylocker added a commit that referenced this pull request Jun 28, 2026
Backend now emits money fields as integer cents. Drop the bespoke
reports-money.js formatter (per santi PR #997 review) and use the
platform-wide currencyAmountFromCents from openstack-uicore-foundation.
Null guard via inline/local const rather than a new shared helper file.
Test fixtures updated to cents input; assertions to no-comma "$X.XX" output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@caseylocker

Copy link
Copy Markdown
Author

Hey @caseylocker, this PR doesn't follow our codebase's coding patterns. For example, you added several files to the utils dir that are specific to these reports. No other page or section of the admin does this — if it did, utils would end up with dozens of one-off files, defeating the purpose of having a generic utils dir.

I left a few examples inline. Could you go through the PR and align the solution with summit-admin's coding patterns?

@santipalenque Thank you very much for the excellent review. These are great callouts and will help me to align closer with code patterns in the project going forward. I believe that I've addressed all of your concerns with the latest push. Let me know if I missed something, especially with this one #997 (comment).

@santipalenque santipalenque left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @caseylocker, thanks for the fixes. It looks like only the inline-commented spots were addressed — could you go through the rest of the PR and check for similar coding pattern issues elsewhere, not just the ones I flagged?

Comment thread src/actions/sponsor-reports-actions.js Outdated
Comment thread src/components/sponsors/reports/report-print.css Outdated
Comment thread src/components/sponsors/reports/LinesManifestView.js Outdated
Comment thread src/components/sponsors/reports/OrdersTable.js Outdated
Comment thread src/components/sponsors/reports/OrdersTable.js
Comment thread src/components/sponsors/reports/OrdersTable.js Outdated
Comment thread src/components/sponsors/reports/StatusPill.js
Comment thread src/components/sponsors/reports/__tests__/statusTone.test.js Outdated
Comment thread src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js Outdated
caseylocker added a commit that referenced this pull request Jun 30, 2026
- guard all report thunks against a missing currentSummit (prevents a stuck
  spinner from base(currentSummit.id) throwing after startLoading)
- formatUsd: treat blank/whitespace strings + non-finite numbers as missing
  (em dash) instead of $0.00; add reports-money tests
- FilterBar: re-sync draft when the committed value prop changes externally
- drop redundant routing.test.js (real-layout integration test in
  sponsor-reports-layout.test.js already covers route ordering)
- add PURCHASE_DETAILS_VALIDATION_CLEAR reducer test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
caseylocker added a commit that referenced this pull request Jun 30, 2026
- reports-text: decode uppercase/mixed-case HTML entities (the case-insensitive
  match was missing the lowercase ENTITIES map); add toPlainText tests
- report-errors: export-disabled now dispatches { kind, status, message } to
  match the other error branches
- report-query: coerce sponsorIds to positive integers and drop the rest so the
  filter never emits sponsor_id==NaN/==0; add buildReportQuery tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
caseylocker added a commit that referenced this pull request Jun 30, 2026
- index.js: import DEFAULT_PER_PAGE from constants (drop local DEFAULT_PAGE_SIZE dup)
- index.js: mapDispatchToProps to object shorthand via new clearPurchaseDetailsValidation
  action creator (removes the raw dispatch prop)
- OrdersTable.js: remove dead getOrderRowId export + its test (page maps id inline)
- usePrint: move to src/hooks/ per the codebase custom-hook convention
- section-csv-query.js: align defense-in-depth comment with the sponsor_id/page_id guard

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
caseylocker added a commit that referenced this pull request Jun 30, 2026
Backend now emits money fields as integer cents. Drop the bespoke
reports-money.js formatter (per santi PR #997 review) and use the
platform-wide currencyAmountFromCents from openstack-uicore-foundation.
Null guard via inline/local const rather than a new shared helper file.
Test fixtures updated to cents input; assertions to no-comma "$X.XX" output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@caseylocker
caseylocker force-pushed the feature/sponsor-reports-port branch from fea1f2d to ac0a1f7 Compare June 30, 2026 19:55
Comment thread src/i18n/en.json Outdated
Comment thread src/actions/sponsor-reports-actions.js Outdated
caseylocker and others added 15 commits July 3, 2026 13:20
…setRows via guardedDispatch

Introduces a single `guardedDispatch` closure (checked against `mySeq` at
every call site) to replace the two existing ad-hoc terminal guards.

Hole 1 — stuck loading: REQUEST_SPONSOR_ASSET was dispatched after
`await getAccessTokenSafely()` without a seq-guard. A stale call that
resumed after a newer call had already committed RECEIVE_SPONSOR_ASSET_ROWS
would re-flip loading:true with no terminal to clear it. guardedDispatch
suppresses the REQUEST dispatch for any superseded invocation.

Hole 2 — persistent stale error: fetchPage passed raw dispatch to getRequest,
so a stale request's HTTP error handler fired SPONSOR_ASSET_READ_ERROR
unguarded — clobbering fresh success state with readError. Changing the
innermost `)(dispatch)` to `)(guardedDispatch)` threads the guard through
getRequest's error-handler invocation.

The RECEIVE and catch-block SPONSOR_ASSET_READ_ERROR terminals now also use
guardedDispatch, consolidating all four dispatch sites under one mechanism.

Tests added (getSponsorAssetRows):
• suppresses REQUEST_SPONSOR_ASSET from a stale call (prevents stuck-loading)
• suppresses SPONSOR_ASSET_READ_ERROR from a stale call's error handler

Full suite: 1089/1089 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…onsor Assets UI

The report is scoped to Media assets (server applies module_type==Media), whose
completion strategies only ever emit pending/in_progress/completed — never
not_applicable. The N/A summary tile and per-group rollup chip were therefore
always zero. Remove not_applicable from the tile keys, rollup chips, pivot-tree
accumulator, and the now-dead i18n key. Backend contract is untouched (it may
still send the key; the FE simply ignores it). StatusPill's generic
not_applicable->default tone mapping is left as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… request)

Single-select Status filter (Any / Completed / In Progress / Pending) in the
Sponsor Assets report's FilterBar extraControls slot. Pure FE: the backend
SponsorAssetFilter already allows status==, buildReportQuery already emits it,
and every row carries status — only the UI control was missing. Reuses
STATUS_TILE_KEYS so the options stay in lockstep with the summary tiles.
Applies server-side (refetch on Apply), so the summary tiles recompute on the
filtered set and CSV export inherits the filter. No backend change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… Purchase Details orders table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF
…report

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… review

Addresses the smarcet + santipalenque CHANGES_REQUESTED on the Sponsor Reports
FE port, plus a whole-PR convention sweep and correctness pass (each verified
with an independent review).

Error handling:
- Delete sponsor-reports-errors.js; fold a module-private reportReadErrorHandler
  into sponsor-reports-actions.js, matching the per-feature handler precedent in
  sponsor-cart-actions.js / summit-actions.js (src/actions holds only action files).
- Delegate 401 to uicore authErrorHandler so its isClearingSessionState guard
  dedupes re-login across the paginated getSponsorAssetRows fetch (was calling
  doLogin() raw per page).
- Drop the dead classifyReportError taxonomy (onExportDisabled was wired to the
  same action as onReadError everywhere; the kind zoo was unused). Keep only what
  pages consume: inline readError.message, kind:"not-found" for the drilldown 404,
  and the Purchase Details 412 validation toast. Reports keep inline error bodies
  (not the app snackbar) by design.
- Gate the getSponsorAssetRows catch to genuine non-HTTP exceptions (e instanceof
  Error): uicore rejects HTTP failures with a plain {err,res,...} object after
  invoking the handler, so the old unconditional catch flashed an inline error over
  the 401 reauth and clobbered the handler's server message on 403/500.

Correctness:
- Escape the five string filter[] operands (status, form_code, payment_method,
  module_type, media_request_type) with escapeFilterValue, per the FN filtering
  convention (cf. sponsor-cart-actions.js / event-rsvp-actions.js). Numeric ids,
  ISO dates, and the top-level search param stay literal.
- getSponsorAssetRows: bail a superseded invocation before it fires a request
  (after the token await and in the pagination loop). uicore getRequest aborts the
  in-flight same-URL request, so a stale call could otherwise cancel the current
  load. Complements the existing seq-guard on dispatches.
- Drilldown empty-state: guard on visiblePages.length (Media-filtered) instead of
  pages.length, so a sponsor whose pages hold only non-Media modules shows the
  no-submissions panel rather than an empty body.

Convention sweep:
- Fold sponsor-reports-query.js into sponsor-reports-actions.js (only non-*-actions
  file in src/actions, imported only there); builders stay exported for their tests.
- Move usePivot to src/hooks (canonical hook location, cf. usePrint).
- Remove 6 dead i18n keys under sponsor_reports_page (live pivot_unnamed_component
  untouched).
- Optional-chaining sweep for row.module to match PivotTree's defensive idiom:
  ContentCell (2) + sponsor-asset-drilldown-page (4).

Rewrites the handler's tests as thunk-level integration tests; adds live-401 /
live-403 asset-fetch and no-submissions coverage. Full suite green (131 suites /
1130 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF
…te, hook fold)

Three issues surfaced by a whole-PR pattern audit against summit-admin exemplars.

- Payment Method filter silently no-op'd on the Line Items view. The Purchase
  Details page shares one filters object across the orders/lines toggle, but the
  lines endpoint's filter set omits payment_method (order-level attribute) and
  BaseFilter silently skips unknown AND-filter fields — so selecting a payment
  method returned unfiltered lines. Gate the control to the orders view (mirrors
  search being view-specific) and strip paymentMethod in buildPurchaseLinesQuery
  so a value carried over from the orders view can't emit a dead clause. Orders
  view is unchanged (buildPurchaseQuery still emits payment_method).
- Reports layout had no catch-all route, so an unknown /reports/... path rendered
  only the breadcrumb shell. Add <Route component={NoMatchPage} /> last in the
  Switch, matching sponsor-layout/event-layout. Eager import (not lazy) to match
  this layout's own convention — it eager-imports its pages and has no Suspense.
- Fold usePivot into its only consumer (sponsor-asset-report-page) and delete the
  hook. A single-export helper with one consumer belongs in the consumer, not the
  shared src/hooks dir (react-frontend.md §2). usePrint stays in src/hooks — it's
  generic (window.print) and used by all three report pages.

Full suite green (131 suites / 1131 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF
Encodes the invariant behind the "mount-only effect never re-runs on summit
change → stuck empty" review concern, so a future refactor that breaks it fails
CI instead of silently reintroducing the bug.

- sponsor-reports-actions: mount fetches (getSponsorAssetFilters/Rows,
  getPurchaseDetailsFilters) no-op with no current summit, across null / {} /
  { id: 0 } (a weaker `!currentSummit` guard fails the {} and { id: 0 } cases).
- summit-id-layout: the gate renders nothing unless currentSummit.id &&
  summitId === currentSummit.id && !loading && hasLoaded, with each negative case
  isolating ONE clause so deleting that clause fails exactly its test.
- sponsor-asset-report-page: with the real thunks (only getRequest mocked), a
  fresh mount fetches the current summit and a remount fetches the NEW summit —
  the direct observation a SummitIdLayout-driven remount produces on a switch.

Tests only; no source change. Full suite green (137 suites / 1164 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF
…cet review)

Verified real (not a false positive) by smarcet: alt={row.module?.title} renders
an <img> with NO alt attribute when module/title is missing — worse than alt=""
for screen readers (they announce the filename/URL). module can be undefined on
this row shape (PivotTree's Leaf defends against exactly that).

- alt now falls back to row.module?.title || row.module?.component_name || ""
  (mirrors PivotTree's component_name || title idiom; "" marks it decorative).
- ContentCell.test.js: cover the previously-untested image branch with module
  absent — assert alt="" is present, not an omitted attribute.

Also renames a shadowing `thunk` it.each param (no-shadow) in the guard tests.

Full suite green (137 suites / 1165 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF
…ore dedup, paging, test prune)

Reducer-driven list state (#1):
- Move pagination/sort/filter out of component useState into the
  purchase-details, lines, and sponsor-asset reducers; thunks record them
  via getRequest's 5th-arg REQUEST payload and components read from props.
  Mirrors ticket-type-list-page.

Adopt uicore components, drop duplicates (#2-#4 + parent sweep):
- MuiDropdown for the status/form/payment selects, sponsor-asset status,
  and PivotSelector; uicore SearchInput in FilterBar; uicore DateTimePicker
  for the date filters (values kept as YYYY-MM-DD via a UTC boundary
  conversion, query contract unchanged).
- SummaryPanel -> uicore InlineCard; StatusRollupChips -> mui/chip-list.

Use the global loading overlay (#5):
- Remove per-slice `loading` from all four reports reducers; thunks bracket
  start/stopLoading so state.baseState.loading + the single AjaxLoader own
  it. Sponsor-asset keeps a local hasFetched flag for first-mount
  empty-state suppression.

Fold single-consumer helper (#6):
- Inline bucketLinesBySponsor into LinesManifestView; delete
  manifest-grouping.js.

Bulk-load sponsor-asset rows with bounded concurrency (#10 / smarcet):
- Replace the per_page=5000 fetch (removed ALL_ROWS_PER_PAGE) with
  page-1-then-parallel paging at per_page=100 via p-limit + Promise.all,
  matching ticket-actions. Re-check the sequence token inside the pool
  callback so a superseded invocation's queued jobs cannot fire an
  identical-page request that would abort the fresh invocation's (under
  getRequest's page-bearing abort key).

Follow-up correctness fixes (Codex review):
- toReportDate treats the picker's moment(0) clear-sentinel as "no date"
  (matches the .unix()||null filter convention) so clearing a date drops
  the filter instead of sending 1970-01-01.
- FilterBar search applies live (debounced) instead of feeding the
  Apply-gated draft, so a fast type+Apply can't submit a stale value.
- Generation-guard hasFetched so a superseded fetch can't flash the empty
  state.

Prune tests to meaningful coverage (parent + #7-#9):
- Drop tests that exercised third-party libraries or were low-value
  (summit-switch, ReportShell, reducer-loading, TierBadge neutral, the
  manifest-grouping unit test) and reduce sponsor-reports suites to the
  behavior we own. Add mutation-proof regression tests for the fixes above.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF
…efault, dead code)

Whole-branch review findings that survived verification:

- Restore the Line Items (manifest) default page size to 50. The reducer
  relocation had set the lines DEFAULT_STATE.perPage to DEFAULT_PER_PAGE (10);
  the page test seeded perPage:50 in its fixture and masked the wrong default,
  so the live first fetch pulled 10. Use FIFTY_PER_PAGE + add a reducer test
  asserting the default (the test that would have caught the regression).

- Default FilterBar `value` to a module-level stable {} (EMPTY_VALUE) instead
  of an inline {}, so omitting the prop no longer makes the value-resync effect
  loop on a fresh object each render.

- Remove dead `query: {}` from the purchase-details reducer DEFAULT_STATE
  (never read or written).

- Remove unused ERROR_CODE_503 constant (503 is handled generically).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF
…wser QA)

Two issues found while testing the filters in the browser:

- MuiDropdown label overlapped the value. The uicore component hardcodes
  displayEmpty and renders the placeholder (or "Select an option...") in the
  value area, while a `label` prop becomes a separate InputLabel that never
  shrinks — so the two overlap. Drive it by `placeholder` (the field name)
  instead, and restore the combobox's accessible name via
  SelectDisplayProps={{ "aria-label" }} (a bare aria-label on Select doesn't
  reach the role="combobox" element). Applies to the Purchase Status / Type /
  Payment Method selects and the Sponsor Assets status select.

- Type filter showed only "Any". The filters endpoint returns forms as
  { code, name } where name is the (often blank) form_type; the old code
  dropped every blank-name form, while the table still shows them via each
  row's prebuilt form.display ("{code} - {name}"). Fall back to the code as the
  label when the name is blank (dropping only entries with no code), so the
  Type filter lists the types and matches the table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF
The Sponsor Assets pivot tree and drill-down rendered the raw status token
("completed", "in_progress") as the StatusPill label, while the same report's
tiles and filter use the translated sponsor_reports_page.status_* labels.
Translate the label at both asset call sites, with a notFound fallback to the
raw token for any status without a key (Media-scoped report only yields the
three keyed statuses). Purchase-side pills (OrdersTable, Lines) keep the raw
status, which is already human-readable and has no status_* keys.

Scope the by_status tiles test to rows:[] so the tile labels don't collide
with a seeded row's now-translated pill under the key-echo i18n mock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF
@caseylocker
caseylocker force-pushed the feature/sponsor-reports-port branch from bd811fa to edef96d Compare July 3, 2026 18:21

@santipalenque santipalenque left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a few nits and another one that I think is out of scope, once nits are fixed I think we are good to go @smarcet . pre-approving - good job @caseylocker !

Comment thread src/actions/sponsor-reports-actions.js Outdated
// Each invocation captures the counter value at entry; only the invocation whose captured
// value still matches the current counter (i.e. no newer call was started) is allowed to
// commit its RECEIVE_SPONSOR_ASSET_ROWS dispatch.
let sponsorAssetRowsSeq = 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@smarcet I think this should be a broader solution since this an issue across all api calls

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@santipalenque i do agree i will open a ticket many thanks

Comment thread src/actions/sponsor-reports-actions.js Outdated
// Clears the Purchase Details validation toast (dispatched from the Snackbar
// onClose). A plain action creator lets the page bind it via the object form of
// mapDispatchToProps instead of receiving raw dispatch.
export const clearPurchaseDetailsValidation = () => ({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use createAction

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 68961e2. It's a thunk dispatching createAction(PURCHASE_DETAILS_VALIDATION_CLEAR)({}) now, same shape as clearCurrentSummit / resetTicketTypeForm.


// Stable empty-object reference so an omitted `value` prop doesn't create a new
// object every render (which would re-fire the resync effect and loop).
const EMPTY_VALUE = {};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't need this as a constant, you can just set the default value in the props

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@santipalenque, kept this one deliberately, an inline {} default creates a fresh object identity on every render, which re-fires the useEffect([value]) draft-resync > setDraft > re-render > new {}, and the loop self-sustains ("Maximum update depth exceeded"). The module-level constant gives the omitted-prop case a stable identity; there's now a test that exercises the omitted-value path and blows up if this ever regresses to an inline default. If you still want the change I'm happy to make it, just wanted to call out my reasoning first.

// not_applicable is omitted — the report is scoped to Media assets (server applies
// module_type==Media), which never yield an N/A status, so the tile was always zero.
const STATUS_TILE_KEYS = ["completed", "in_progress", "pending"];
const TILE_TONE = {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we use TONE_BY_STATUS in StatusPill ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep. Dropped the local TILE_TONE and the tiles now use statusTone() exported from StatusPill, so the tone map lives in one place. In 68961e2.


// not_applicable is omitted — the report is scoped to Media assets (server applies
// module_type==Media), which never yield an N/A status, so the tile was always zero.
const STATUS_TILE_KEYS = ["completed", "in_progress", "pending"];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This same list of keys is duplicated in build-pivot-tree.js and StatusRollupChips.js

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 68961e2. STATUS_KEYS is exported once from build-pivot-tree (it's what computes the rollup those keys describe) and StatusRollupChips + the report page import it. Also derived the rollup's zero-init from the same array, which was a fourth hidden copy.

@smarcet smarcet left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@caseylocker please review comments

… tone map, STATUS_KEYS)

Three of the five nits from the approving review (the seq-guard one is
@smarcet's platform-level call; the FilterBar EMPTY_VALUE one is pushed back
on — an inline {} default re-fires the value-resync effect every render):

- clearPurchaseDetailsValidation now uses createAction via a thunk, matching
  the house idiom (summit-actions clearCurrentSummit et al) instead of a
  hand-rolled plain action object.

- Sponsor Assets tiles derive their tone from StatusPill's exported
  statusTone() instead of a duplicated local TILE_TONE map.

- Hoist the displayed-status list to a single exported STATUS_KEYS in
  build-pivot-tree (which computes the rollup those keys describe); the
  rollup zero-init, StatusRollupChips, and the asset page's tiles + status
  filter all consume it instead of holding literal copies.

- FilterBar live-search test now OMITS `value`, exercising the module-level
  EMPTY_VALUE default; regressing it to an inline {} default loops the
  resync effect ("Maximum update depth exceeded") and blows the test up —
  mutation-verified against that exact change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF
@caseylocker

Copy link
Copy Markdown
Author

@caseylocker please review comments

@smarcet all of santi's comments are handled as of 68961e2: three fixed (createAction, tone map reuse, STATUS_KEYS dedup), and I replied on the FilterBar constant one with my reasoning for keeping it (inline {} default loops the resync effect, there's a test guarding it now). I see your response to the seq-guard one so didn't make any changes there. Replies are on each thread, ready for your re-review.

@smarcet
smarcet self-requested a review July 6, 2026 15:58
Comment thread src/actions/sponsor-reports-actions.js

@smarcet smarcet left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

…onses

Port the stale-response guard from getSponsorAssetRows to the sibling thunks
smarcet flagged (getPurchaseDetailsReport, getPurchaseDetailsLinesReport,
getSponsorAssetSponsor): concurrent invocations with different filters, page,
sort, or sponsorId carry different getRequest abort keys (only access_token is
stripped), so they never cancel each other and whichever response lands last
would win, even the older one. Reachable via the pre-startLoading token-refresh
window or an A-to-B drill-down navigation, where sponsor A's late response
could render under sponsor B's URL.

Extract the counter boilerplate into a shared sequenced() factory: each
invocation begins a sequence before the token await and gets isCurrent() plus
guardedDispatch(), which drops every dispatch (REQUEST, RECEIVE, error
handlers, start/stopLoading) once a newer invocation has begun. The newest
invocation is always current, so the overlay is always cleared by whichever
call finishes last. A stale call's 401 re-login is dropped too (uicore
authErrorHandler reaches doLogin by dispatching a thunk through the dispatch
it is given) — safe: the fresh call carries the same token, 401s too, and
drives the re-login. getSponsorAssetRows is refactored onto the same helper
(behavior unchanged; its three existing stale tests pass as-is).

Also guard the two filters thunks: safe for same-summit duplicates (identical
abort key, getRequest cancels), but a summit switch changes the URL, so the
old summit's late filters response would land after the SET_CURRENT_SUMMIT
reset and repopulate filterOptions with the wrong summit's options.

Add a stale-response test per newly guarded thunk (including the sponsor A
over B drill-down and the summit-switch filters case), each mutation-verified:
un-guarding the thunk makes its test fail with the stale response landing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

@smarcet smarcet left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@smarcet
smarcet merged commit 8f4158a into master Jul 6, 2026
9 checks passed
smarcet pushed a commit that referenced this pull request Jul 6, 2026
…nsors → Reports) (#997)

* feat(sponsor-reports): scaffold admin-gated Reports section under Sponsors

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): port actions/reducers/utils + store wiring

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): port shared report components (T.translate, getCSV)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): Purchase Details report (MuiTable, conditional refund tile)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(sponsor-reports): cover CSV+pagination/sort dispatch; drop vestigial SORT_FIELD_MAP

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): Sponsor Assets report + drill-down (rewired routes)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): Reports landing + integration

Replace placeholder with two MUI card links (Purchase Details,
Sponsor Assets); add breadcrumb; add landing_title i18n key;
update layout test for card assertions; 6 new tests, 912 total.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(sponsor-reports): final-review cleanup — drilldown errors, i18n, prop, route test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sponsor-reports): drop vestigial export-disabled; route to READ_ERROR

Remove *_EXPORT_DISABLED action constants (purchase-details, sponsor-asset, drilldown)
and their dead reducer arms. Route onExportDisabled → READ_ERROR in getPurchaseDetailsReport
and getSponsorAssetReport thunks (mirrors the already-fixed drilldown), so a 503
export-disabled on a read path clears loading via the same arm as not-found/unauthorized.
Remove exportDisabled and exportError from all three reducer DEFAULT_STATEs, drop
disabled={exportDisabled} from all three ExportCsvButton usages, and clean up all test
references to the deleted constants and unreachable state. Add 503 export-disabled coverage
for getPurchaseDetailsReport and getSponsorAssetReport matching the existing drilldown tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sponsor-reports): UI polish (fonts, breadcrumb trail, asset spacing)

- SummaryPanel tile labels overline -> subtitle1 (bigger; both reports)
- GroupByToggle small -> medium + larger button text
- SponsorReportsLayout renders a persistent Reports crumb + per-route page
  crumb so the trail reads .../Sponsors/Reports/<page> (was dropping to
  .../Sponsors on sub-pages); landing's own crumb removed (no dup)
- Sponsor Assets: gap between Report Filters and summary cards
- tests updated (landing breadcrumb moved to layout; layout trail test added)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sponsor-reports): address CodeRabbit review (PR #997)

- guard all report thunks against a missing currentSummit (prevents a stuck
  spinner from base(currentSummit.id) throwing after startLoading)
- formatUsd: treat blank/whitespace strings + non-finite numbers as missing
  (em dash) instead of $0.00; add reports-money tests
- FilterBar: re-sync draft when the committed value prop changes externally
- drop redundant routing.test.js (real-layout integration test in
  sponsor-reports-layout.test.js already covers route ordering)
- add PURCHASE_DETAILS_VALIDATION_CLEAR reducer test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sponsor-reports): address Copilot review (PR #997)

- reports-text: decode uppercase/mixed-case HTML entities (the case-insensitive
  match was missing the lowercase ENTITIES map); add toPlainText tests
- report-errors: export-disabled now dispatches { kind, status, message } to
  match the other error branches
- report-query: coerce sponsorIds to positive integers and drop the rest so the
  filter never emits sponsor_id==NaN/==0; add buildReportQuery tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(sponsor-reports): request dedicated sponsor-reports scopes (review)

Per Sebastian's PR review: summit-admin now requests the new microservice's own
scopes (sponsor-reports/read, sponsor-reports/export) instead of relying on the
broad reports/all. Adds SPONSOR_REPORTS_SCOPES and wires it into SCOPES
(reports/all kept for the legacy summit-reports-api). Requires the IDP to grant
these two scopes to the summit-admin client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): add purchase-details lines report action

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): add purchase-details lines reducer + store registration

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): add bucketLinesBySponsor grouping util

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): add LinesManifestView collapsible per-line manifest

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): add Orders/Line-Items report view toggle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): wire Line Items manifest view into Purchase Details page

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(sponsor-reports): strip trailing whitespace in LinesManifestView

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sponsor-reports): address PR #997 convention review (smarcet)

- index.js: import DEFAULT_PER_PAGE from constants (drop local DEFAULT_PAGE_SIZE dup)
- index.js: mapDispatchToProps to object shorthand via new clearPurchaseDetailsValidation
  action creator (removes the raw dispatch prop)
- OrdersTable.js: remove dead getOrderRowId export + its test (page maps id inline)
- usePrint: move to src/hooks/ per the codebase custom-hook convention
- section-csv-query.js: align defense-in-depth comment with the sponsor_id/page_id guard

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(sponsor-reports): add lines slice to layout test store (fix CI)

The Purchase Details page mapStateToProps reads sponsorReportsPurchaseDetailsLinesState.data;
the SponsorReportsLayout test mounts the real page, so its mock store must include that slice.
Missing it threw 'Cannot read properties of undefined (reading data)' under the full yarn test
run (CI), though the scoped report-suite runs passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sponsor-reports): wrap overflowing asset filenames in drilldown links

Hashed asset filenames (32-char hex prefix, no spaces) overflowed their card.
The link Typography had noWrap (white-space:nowrap). Switch the link to a flex
row with overflowWrap:anywhere + minWidth:0 on the filename so the unbroken hash
wraps within the card; icons get flexShrink:0 so they don't squash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sponsor-reports): readable toggle + summary-tile typography

The app sets html root font-size to 62.5% (10px), so rem-based sizes render
tiny: the toggle override read 8.75px and subtitle1 tile labels 10px. Pin px
instead — toggles to 14px/500 to match the Print/Export action buttons, and the
summary-tile labels to 14px.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): visible sponsor avatars + white header-icon theme

Add SponsorAvatar: logo-first with a colored-initials fallback so a no-logo
(or white-on-white) sponsor is never an invisible blank. Styled to mimic the
ReportShell title icon — single primary.light rounded square with white
foreground — and used in both group-by views and the drill-down header. Switch
the ReportShell title icon glyph to white to match. Also wrap the component-view
asset filename (was truncated by noWrap+maxWidth) so the full name is readable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): enable CSV export on the Line Items view

Render the Export CSV button on the Line Items (per-line) view of the
Purchase Details report, wired to the new backend
GET .../purchase-details/lines/csv endpoint. One ExportCsvButton switched
by view; lines query strips only pagination (preserves filters incl. the
derived include_cancelled). Orders export unchanged. Label stays 'Export CSV';
filename purchase-details-lines-summit-<id>.csv.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sponsor-reports): add isPositiveIntId + htmlToPlainText to methods.js

Generic helpers move to the shared methods.js home (convention). htmlToPlainText
preserves tag-boundary whitespace + decodes entities (htmlToString fuses words).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sponsor-reports): co-locate report-query + add shared purchase query builders

Move report-query under the feature folder (feature helper convention). Add
buildPurchaseQuery/buildPurchaseLinesQuery so the on-screen fetch and the upcoming
export thunks share one date/order normalization (no duplication, no regression).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sponsor-reports): use methods.htmlToPlainText, drop reports-text util

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sponsor-reports): purchase-details CSV export via action thunks

Move orders/lines CSV URL+params+filename building into exportPurchaseDetailsCsv/
exportPurchaseDetailsLinesCsv (cf. exportEventRsvpsCSV); page dispatches them from
a plain MUI Button. Orders CSV keeps the on-screen sort. base() now reads
window.SPONSOR_REPORTS_API_URL directly (drops getReportsApiBaseUrl import).
currentSummit removed from component props/mapStateToProps (no longer needed
after CSV URL building moved to the thunks).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sponsor-reports): sponsor-asset CSV export via action thunks; drop ExportCsvButton

Add exportSponsorAssetCsv/exportSponsorAssetSectionCsv (section logic folded in,
simplified); pages dispatch from plain buttons. Remove the ported ExportCsvButton
and section-csv-query util.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sponsor-reports): delete reports-api util; wire isPositiveIntId from methods

- sponsor-asset-report-page/index.js: repoint isPositiveIntId import to utils/methods
- sponsor-asset-drilldown-page/index.js: merge htmlToPlainText + isPositiveIntId into one
  import from utils/methods (fixes import/no-duplicates)
- sponsor-asset-report-page/__tests__: remove jest.mock("reports-api") — pure fn, no mock needed
- sponsor-asset-drilldown-page/__tests__: same
- layouts/__tests__/sponsor-reports-layout.test.js: remove obsolete reports-api mock + comment
- purchase-details-report-page/__tests__: rename stale "ExportCsvButton" test label to
  "export button" (authorized micro-cleanup; component deleted in Task 5)
- git rm src/utils/reports-api.js — no longer consumed by any source or test
- 115 suites / 966 tests passing; 0 new lint errors

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sponsor-reports): co-locate report-errors + reports-money out of shared utils

Both files moved into src/pages/sponsors/sponsor-reports/ per §2 (feature helpers
never in shared utils/); test for reports-money co-located alongside. Internal
./constants import in report-errors updated to ../../../utils/constants.
formatUsd logic unchanged — uicore/cents contract deferred to PR-B.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sponsor-reports): update constants import path in moved report-errors.js

./constants was not re-staged after the path update; this corrects it to
../../../utils/constants (resolves from the new feature folder location).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(reports): replace formatUsd with uicore currencyAmountFromCents

Backend now emits money fields as integer cents. Drop the bespoke
reports-money.js formatter (per santi PR #997 review) and use the
platform-wide currencyAmountFromCents from openstack-uicore-foundation.
Null guard via inline/local const rather than a new shared helper file.
Test fixtures updated to cents input; assertions to no-comma "$X.XX" output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(reports): use cents fixtures in purchase-details reducer test

Codex follow-up: the reducer passes summary payload through unchanged, so the
dollar-string fixtures were not a live bug, but they're stale under the cents
contract. Switch total_paid fixtures to integer cents to stay representative.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(reports): dedup per-page/ms constants via src/utils/constants

- OrdersTable: replace local MS_PER_SECOND (1000) with MILLISECONDS_IN_SECOND
  and local DEFAULT_PER_PAGE (10) with the shared constant; remove eslint-disable
- LinesManifestView: replace magic-number PER_PAGE_OPTIONS [10,25,50,100] and
  DEFAULT_PER_PAGE (50) with named constants [DEFAULT_PER_PAGE, TWENTY_PER_PAGE,
  FIFTY_PER_PAGE, MAX_PER_PAGE]; default perPage prop is now FIFTY_PER_PAGE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* fix(sponsor-reports): return Promise.resolve() on all thunk guard branches

Every thunk in sponsor-reports-actions.js that guards against a missing
summit returned `undefined` synchronously while the normal code-path
returned a Promise — inconsistent for callers using `.then()`/`await`.
Changed all ten guard-branch returns to `return Promise.resolve()` so
every thunk is uniformly thenable regardless of which branch is taken.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(sponsor-reports): fold statusTone into StatusPill

Moves TONE_BY_STATUS + statusTone() from the standalone statusTone.js
into StatusPill.js as a named export. Updates StatusRollupChips.js to
import from StatusPill. Deletes statusTone.js and its test file; all
branch-coverage assertions migrated into StatusPill.test.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(reports): replace hand-rolled formatCheckoutTime with moment

Swaps the manual regex + AM/PM math in formatCheckoutTime for
moment.unix().utc() / moment.utc() + .format("YYYY-MM-DD h:mm A").
Output is byte-identical for all tested inputs. Removes dead NOON
constant and unused MILLISECONDS_IN_SECOND import; keeps ISO_DATE_LENGTH
(still needed for the date-only fallback path, and the no-magic-numbers
rule requires it named).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(sponsor-reports): adopt useSnackbarMessage hook for 412 validation toast

Replaces the hand-rolled inline MUI <Snackbar>/<Alert> with the existing
useSnackbarMessage hook (openstack-uicore-foundation), consistent with
payment-view.js and mui-qr-badge-popup.js. Removes TOAST_AUTO_HIDE_MS and
the Snackbar import; Alert import kept (still used for readError). Redux
clearPurchaseDetailsValidation lifecycle preserved via useEffect.

Tests: mocks useSnackbarMessage, asserts errorMessage is called with the
correct message (and i18n fallback), clearValidation is dispatched, and
errorMessage is not called when validationError is null.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* test(sponsor-reports): mock useSnackbarMessage in layout test

The Purchase Details page now calls useSnackbarMessage() (T5); the layout
integration test renders that page without the global snackbar provider, so
the hook destructured undefined and threw on render. Mock it like the page's
own test does.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(T6): move manifest-grouping into reports feature dir

bucketLinesBySponsor had exactly one production caller
(LinesManifestView). Moving the helper and its test out of
src/utils/ and co-locating them with the feature removes the
one-off-file-in-shared-utils anti-pattern flagged in PR review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(sponsor-reports): T7 — dedup page-size constant + fix default-param-last

Edit A: replace local LINES_DEFAULT_PAGE_SIZE = 50 with imported FIFTY_PER_PAGE
from utils/constants; Edit B: remove `filters = {}` default on
exportPurchaseDetailsCsv — all callers pass an explicit object, buildPurchaseQuery
already defaults its own first arg.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(sponsor-reports): convert nextDayStartIso to moment-timezone

Replace hand-rolled new Date / setUTCDate with
moment.utc(ymd, "YYYY-MM-DD", true).add(1, "day") for consistency
with the rest of the date-formatting in this feature. Output is
byte-identical; ISO_DATE_LENGTH removed (now unused). Invalid-input
guard returns ymd unchanged rather than throwing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* test(OrdersTable): pin moment.utc offset+malformed contract (T9)

Add tests for non-Z offset inputs (-05:00, +05:00) and malformed ISO-
like strings to lock current moment.utc() behavior. Backend emits UTC Z
only; offset path is inert in production — assertions are documentation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(sponsor-reports): use repo-native isImageUrl + DEFAULT_CURRENT_PAGE

Replace local IMAGE_EXT regex in sponsor-asset-drilldown-page with the
repo-canonical isImageUrl from utils/methods (same extensions, also handles
query strings). Replace local FIRST_PAGE = 1 in sponsor-asset-report-page
with DEFAULT_CURRENT_PAGE from utils/constants (7 references). Replace
bare useState(1) / setPage(1) page-init/reset literals in purchase-details-
report-page with DEFAULT_CURRENT_PAGE (9 references); orderDir useState(1)
(sort direction) is intentionally unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(sponsor-reports): translate OrdersTable column headers via i18n

Replace 7 bare-string header values with T.translate calls; add 5 new
col_* keys to en.json; update header assertions in both test files to
match key output from the translate mock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(sponsor-reports): use DEFAULT_CURRENT_PAGE for page-number defaults

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(sponsor-reports): use Grid2 in asset drilldown page

Replace deprecated MUI v4 Grid (item xs={}) with Grid2 size={{}} API,
matching every other file in this PR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(sponsor-reports): use object-shorthand mapDispatchToProps in asset pages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(sponsor-reports): move query builders to actions layer, fold in toOrderParam

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(sponsor-reports): build report queries inside thunks, not components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(sponsor-reports): move report-errors helper to actions layer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* docs(sponsor-reports): fix stale toOrderParam comment in OrdersTable

toOrderParam moved to the query thunk in an earlier commit; the column
comment no longer describes a page-handler conversion.

* feat(sponsor-reports): add ContentTypeToggle (Collected/All)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* feat(sponsor-reports): Collected/All content filter on asset report page

Wire ContentTypeToggle (default: collected) into SponsorAssetReportPage —
injects moduleType:"Media" on fetch + CSV export when collected, omits it
for all; resets page on toggle. Tests: 12/12 (+3 new), full suite 995/995.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* feat(sponsor-reports): suppress download status + Collected/All filter in drilldown

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* fix(sponsor-reports): show empty page sections under All in drilldown

Drop-empty-section now applies only in Collected mode; All renders every
section as-is, including a page the sponsor never submitted to.

* refactor(sponsor-reports): hard-wire sponsor assets to collected (Media); remove toggle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* fix(sponsor-reports): scope section CSV to collected (Media); restore HTML-flatten test

- exportSponsorAssetSectionCsv now filters module_type==Media so the per-page
  Download CSV matches the collected-only view (was an escape path for
  Document/Info rows).
- Restore ContentCell HTML-flatten coverage as a Media text/input row (the
  prior test used an Info row removed with the toggle teardown).

* test(sponsor-reports): strengthen ContentCell flatten test to pin htmlToPlainText

Use an input with entities + adjacent tags (<p>Booth&nbsp;A</p><p>B &amp; C</p>
→ "Booth A B & C") so the test exercises entity-decode + whitespace-collapse +
tag-to-space — the behavior that distinguishes htmlToPlainText from stripTags.
A regression swapping the helper would now fail.

* refactor(sponsor-reports): reuse isPositiveIntId + TWENTY_PER_PAGE; drop port-framing comments

Convention-sweep fixes (Codex full-branch pass):
- query + section-CSV thunks reuse isPositiveIntId instead of hand-rolled
  Number.isInteger ID checks; section CSV bails on an invalid id rather than
  emitting a broadened (whole-sponsor) export
- replace local GROUP_PER_PAGE=25 (an arbitrary literal carried from the
  sponsor-services source, not spec'd) with the existing TWENTY_PER_PAGE
- rewrite OrdersGrid/sponsor-services port-framing comments to describe
  summit-admin behavior only

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(sponsor-reports): replace report-print.css with MUI GlobalStyles

Removes the last standalone stylesheet in the reports feature (legacy in
summit-admin per santipalenque). Whole-page print isolation is preserved
verbatim via an inline <GlobalStyles> block in ReportShell; the .report-body
className anchor is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* refactor(sponsor-reports): extract ContentCell into a shared component

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): add pivot axis + named-pivot definitions

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): pure recursive pivot-tree grouping + usePivot memo

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): fetch full flat asset set atomically with last_page paging

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): add PivotSelector

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): add recursive PivotTree renderer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): multi-level pivot view replaces single-level grouping

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(sponsor-reports): cover getSponsorAssetRows multi-page paging loop

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sponsor-reports): remove orphaned grouped-fetch path + dead exports; fix empty-state

Retire getSponsorAssetReport (no callers after the pivot rewire), its dead reducer
fields/case and the group_by query branch, drop unused axisKeyOf/axisLabelOf, and
default the reducer loading flag true to avoid a no-results flash before first fetch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sponsor-reports): drop stale getSponsorAssetRows completions (request token)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* feat(sponsor-reports): route pivot + unknown-bucket labels through i18n

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* fix(sponsor-reports): trim component pivot label (key/label parity)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* fix(sponsor-reports): close both stale-dispatch holes in getSponsorAssetRows via guardedDispatch

Introduces a single `guardedDispatch` closure (checked against `mySeq` at
every call site) to replace the two existing ad-hoc terminal guards.

Hole 1 — stuck loading: REQUEST_SPONSOR_ASSET was dispatched after
`await getAccessTokenSafely()` without a seq-guard. A stale call that
resumed after a newer call had already committed RECEIVE_SPONSOR_ASSET_ROWS
would re-flip loading:true with no terminal to clear it. guardedDispatch
suppresses the REQUEST dispatch for any superseded invocation.

Hole 2 — persistent stale error: fetchPage passed raw dispatch to getRequest,
so a stale request's HTTP error handler fired SPONSOR_ASSET_READ_ERROR
unguarded — clobbering fresh success state with readError. Changing the
innermost `)(dispatch)` to `)(guardedDispatch)` threads the guard through
getRequest's error-handler invocation.

The RECEIVE and catch-block SPONSOR_ASSET_READ_ERROR terminals now also use
guardedDispatch, consolidating all four dispatch sites under one mechanism.

Tests added (getSponsorAssetRows):
• suppresses REQUEST_SPONSOR_ASSET from a stale call (prevents stuck-loading)
• suppresses SPONSOR_ASSET_READ_ERROR from a stale call's error handler

Full suite: 1089/1089 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): drop the always-zero N/A status bucket from Sponsor Assets UI

The report is scoped to Media assets (server applies module_type==Media), whose
completion strategies only ever emit pending/in_progress/completed — never
not_applicable. The N/A summary tile and per-group rollup chip were therefore
always zero. Remove not_applicable from the tile keys, rollup chips, pivot-tree
accumulator, and the now-dead i18n key. Backend contract is untouched (it may
still send the key; the FE simply ignores it). StatusPill's generic
not_applicable->default tone mapping is left as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): add Status filter to Sponsor Assets report (JP request)

Single-select Status filter (Any / Completed / In Progress / Pending) in the
Sponsor Assets report's FilterBar extraControls slot. Pure FE: the backend
SponsorAssetFilter already allows status==, buildReportQuery already emits it,
and every row carries status — only the UI control was missing. Reuses
STATUS_TILE_KEYS so the options stay in lockstep with the summary tiles.
Applies server-side (refetch on Apply), so the summary tiles recompute on the
filtered set and CSV export inherits the filter. No backend change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): map Payment Method filter to payment_method== query bracket

* feat(sponsor-reports): add payment method + invoice detail columns to orders table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* feat(sponsor-reports): add Payment Method filter to Purchase Details report

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(sponsor-reports): tighten finance-column assertions (all 4 non-sortable, exact em-dash count)

* refactor(sponsor-reports): rework read-error handling + address PR #997 review

Addresses the smarcet + santipalenque CHANGES_REQUESTED on the Sponsor Reports
FE port, plus a whole-PR convention sweep and correctness pass (each verified
with an independent review).

Error handling:
- Delete sponsor-reports-errors.js; fold a module-private reportReadErrorHandler
  into sponsor-reports-actions.js, matching the per-feature handler precedent in
  sponsor-cart-actions.js / summit-actions.js (src/actions holds only action files).
- Delegate 401 to uicore authErrorHandler so its isClearingSessionState guard
  dedupes re-login across the paginated getSponsorAssetRows fetch (was calling
  doLogin() raw per page).
- Drop the dead classifyReportError taxonomy (onExportDisabled was wired to the
  same action as onReadError everywhere; the kind zoo was unused). Keep only what
  pages consume: inline readError.message, kind:"not-found" for the drilldown 404,
  and the Purchase Details 412 validation toast. Reports keep inline error bodies
  (not the app snackbar) by design.
- Gate the getSponsorAssetRows catch to genuine non-HTTP exceptions (e instanceof
  Error): uicore rejects HTTP failures with a plain {err,res,...} object after
  invoking the handler, so the old unconditional catch flashed an inline error over
  the 401 reauth and clobbered the handler's server message on 403/500.

Correctness:
- Escape the five string filter[] operands (status, form_code, payment_method,
  module_type, media_request_type) with escapeFilterValue, per the FN filtering
  convention (cf. sponsor-cart-actions.js / event-rsvp-actions.js). Numeric ids,
  ISO dates, and the top-level search param stay literal.
- getSponsorAssetRows: bail a superseded invocation before it fires a request
  (after the token await and in the pagination loop). uicore getRequest aborts the
  in-flight same-URL request, so a stale call could otherwise cancel the current
  load. Complements the existing seq-guard on dispatches.
- Drilldown empty-state: guard on visiblePages.length (Media-filtered) instead of
  pages.length, so a sponsor whose pages hold only non-Media modules shows the
  no-submissions panel rather than an empty body.

Convention sweep:
- Fold sponsor-reports-query.js into sponsor-reports-actions.js (only non-*-actions
  file in src/actions, imported only there); builders stay exported for their tests.
- Move usePivot to src/hooks (canonical hook location, cf. usePrint).
- Remove 6 dead i18n keys under sponsor_reports_page (live pivot_unnamed_component
  untouched).
- Optional-chaining sweep for row.module to match PivotTree's defensive idiom:
  ContentCell (2) + sponsor-asset-drilldown-page (4).

Rewrites the handler's tests as thunk-level integration tests; adds live-401 /
live-403 asset-fetch and no-submissions coverage. Full suite green (131 suites /
1130 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* fix(sponsor-reports): pattern-audit follow-ups (filter scope, 404 route, hook fold)

Three issues surfaced by a whole-PR pattern audit against summit-admin exemplars.

- Payment Method filter silently no-op'd on the Line Items view. The Purchase
  Details page shares one filters object across the orders/lines toggle, but the
  lines endpoint's filter set omits payment_method (order-level attribute) and
  BaseFilter silently skips unknown AND-filter fields — so selecting a payment
  method returned unfiltered lines. Gate the control to the orders view (mirrors
  search being view-specific) and strip paymentMethod in buildPurchaseLinesQuery
  so a value carried over from the orders view can't emit a dead clause. Orders
  view is unchanged (buildPurchaseQuery still emits payment_method).
- Reports layout had no catch-all route, so an unknown /reports/... path rendered
  only the breadcrumb shell. Add <Route component={NoMatchPage} /> last in the
  Switch, matching sponsor-layout/event-layout. Eager import (not lazy) to match
  this layout's own convention — it eager-imports its pages and has no Suspense.
- Fold usePivot into its only consumer (sponsor-asset-report-page) and delete the
  hook. A single-export helper with one consumer belongs in the consumer, not the
  shared src/hooks dir (react-frontend.md §2). usePrint stays in src/hooks — it's
  generic (window.print) and used by all three report pages.

Full suite green (131 suites / 1131 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* test(sponsor-reports): regression tests for the summit-gate rebuttal

Encodes the invariant behind the "mount-only effect never re-runs on summit
change → stuck empty" review concern, so a future refactor that breaks it fails
CI instead of silently reintroducing the bug.

- sponsor-reports-actions: mount fetches (getSponsorAssetFilters/Rows,
  getPurchaseDetailsFilters) no-op with no current summit, across null / {} /
  { id: 0 } (a weaker `!currentSummit` guard fails the {} and { id: 0 } cases).
- summit-id-layout: the gate renders nothing unless currentSummit.id &&
  summitId === currentSummit.id && !loading && hasLoaded, with each negative case
  isolating ONE clause so deleting that clause fails exactly its test.
- sponsor-asset-report-page: with the real thunks (only getRequest mocked), a
  fresh mount fetches the current summit and a remount fetches the NEW summit —
  the direct observation a SummitIdLayout-driven remount produces on a switch.

Tests only; no source change. Full suite green (137 suites / 1164 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* fix(sponsor-reports): guarantee ContentCell img alt is a string (smarcet review)

Verified real (not a false positive) by smarcet: alt={row.module?.title} renders
an <img> with NO alt attribute when module/title is missing — worse than alt=""
for screen readers (they announce the filename/URL). module can be undefined on
this row shape (PivotTree's Leaf defends against exactly that).

- alt now falls back to row.module?.title || row.module?.component_name || ""
  (mirrors PivotTree's component_name || title idiom; "" marks it decorative).
- ContentCell.test.js: cover the previously-untested image branch with module
  absent — assert alt="" is present, not an omitted attribute.

Also renames a shadowing `thunk` it.each param (no-shadow) in the guard tests.

Full suite green (137 suites / 1165 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* refactor(sponsor-reports): address PR #997 review (reducer state, uicore dedup, paging)

Reducer-driven list state (#1):
- Move pagination/sort/filter out of component useState into the
  purchase-details, lines, and sponsor-asset reducers; thunks record them
  via getRequest's 5th-arg REQUEST payload and components read from props.
  Mirrors ticket-type-list-page.

Adopt uicore components, drop duplicates (#2-#4 + parent sweep):
- MuiDropdown for the status/form/payment selects, sponsor-asset status,
  and PivotSelector; uicore SearchInput in FilterBar; uicore DateTimePicker
  for the date filters (values kept as YYYY-MM-DD via a UTC boundary
  conversion, query contract unchanged).
- SummaryPanel -> uicore InlineCard; StatusRollupChips -> mui/chip-list.

Use the global loading overlay (#5):
- Remove per-slice `loading` from all four reports reducers; thunks bracket
  start/stopLoading so state.baseState.loading + the single AjaxLoader own
  it. Sponsor-asset keeps a local hasFetched flag for first-mount
  empty-state suppression.

Fold single-consumer helper (#6):
- Inline bucketLinesBySponsor into LinesManifestView; delete
  manifest-grouping.js.

Bulk-load sponsor-asset rows with bounded concurrency (#10 / smarcet):
- Replace the per_page=5000 fetch (removed ALL_ROWS_PER_PAGE) with
  page-1-then-parallel paging at per_page=100 via p-limit + Promise.all,
  matching ticket-actions. Re-check the sequence token inside the pool
  callback so a superseded invocation's queued jobs cannot fire an
  identical-page request that would abort the fresh invocation's (under
  getRequest's page-bearing abort key).

Follow-up correctness fixes (Codex review):
- toReportDate treats the picker's moment(0) clear-sentinel as "no date"
  (matches the .unix()||null filter convention) so clearing a date drops
  the filter instead of sending 1970-01-01.
- FilterBar search applies live (debounced) instead of feeding the
  Apply-gated draft, so a fast type+Apply can't submit a stale value.
- Generation-guard hasFetched so a superseded fetch can't flash the empty
  state.

Prune tests to meaningful coverage (parent + #7-#9):
- Drop tests that exercised third-party libraries or were low-value
  (summit-switch, ReportShell, reducer-loading, TierBadge neutral, the
  manifest-grouping unit test) and reduce sponsor-reports suites to the
  behavior we own. Add mutation-proof regression tests for the fixes above.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* fix(sponsor-reports): review follow-ups (lines page size, FilterBar default, dead code)

Whole-branch review findings that survived verification:

- Restore the Line Items (manifest) default page size to 50. The reducer
  relocation had set the lines DEFAULT_STATE.perPage to DEFAULT_PER_PAGE (10);
  the page test seeded perPage:50 in its fixture and masked the wrong default,
  so the live first fetch pulled 10. Use FIFTY_PER_PAGE + add a reducer test
  asserting the default (the test that would have caught the regression).

- Default FilterBar `value` to a module-level stable {} (EMPTY_VALUE) instead
  of an inline {}, so omitting the prop no longer makes the value-resync effect
  loop on a fresh object each render.

- Remove dead `query: {}` from the purchase-details reducer DEFAULT_STATE
  (never read or written).

- Remove unused ERROR_CODE_503 constant (503 is handled generically).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* fix(sponsor-reports): dropdown label overlap + empty Type filter (browser QA)

Two issues found while testing the filters in the browser:

- MuiDropdown label overlapped the value. The uicore component hardcodes
  displayEmpty and renders the placeholder (or "Select an option...") in the
  value area, while a `label` prop becomes a separate InputLabel that never
  shrinks — so the two overlap. Drive it by `placeholder` (the field name)
  instead, and restore the combobox's accessible name via
  SelectDisplayProps={{ "aria-label" }} (a bare aria-label on Select doesn't
  reach the role="combobox" element). Applies to the Purchase Status / Type /
  Payment Method selects and the Sponsor Assets status select.

- Type filter showed only "Any". The filters endpoint returns forms as
  { code, name } where name is the (often blank) form_type; the old code
  dropped every blank-name form, while the table still shows them via each
  row's prebuilt form.display ("{code} - {name}"). Fall back to the code as the
  label when the name is blank (dropping only entries with no code), so the
  Type filter lists the types and matches the table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* fix(sponsor-reports): translate asset status pills (Copilot review)

The Sponsor Assets pivot tree and drill-down rendered the raw status token
("completed", "in_progress") as the StatusPill label, while the same report's
tiles and filter use the translated sponsor_reports_page.status_* labels.
Translate the label at both asset call sites, with a notFound fallback to the
raw token for any status without a key (Media-scoped report only yields the
three keyed statuses). Purchase-side pills (OrdersTable, Lines) keep the raw
status, which is already human-readable and has no status_* keys.

Scope the by_status tiles test to rows:[] so the tile labels don't collide
with a seeded row's now-translated pill under the key-echo i18n mock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* refactor(sponsor-reports): address santi approval nits (createAction, tone map, STATUS_KEYS)

Three of the five nits from the approving review (the seq-guard one is
@smarcet's platform-level call; the FilterBar EMPTY_VALUE one is pushed back
on — an inline {} default re-fires the value-resync effect every render):

- clearPurchaseDetailsValidation now uses createAction via a thunk, matching
  the house idiom (summit-actions clearCurrentSummit et al) instead of a
  hand-rolled plain action object.

- Sponsor Assets tiles derive their tone from StatusPill's exported
  statusTone() instead of a duplicated local TILE_TONE map.

- Hoist the displayed-status list to a single exported STATUS_KEYS in
  build-pivot-tree (which computes the rollup those keys describe); the
  rollup zero-init, StatusRollupChips, and the asset page's tiles + status
  filter all consume it instead of holding literal copies.

- FilterBar live-search test now OMITS `value`, exercising the module-level
  EMPTY_VALUE default; regressing it to an inline {} default loops the
  resync effect ("Maximum update depth exceeded") and blows the test up —
  mutation-verified against that exact change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* fix(sponsor-reports): sequence-guard report thunks against stale responses

Port the stale-response guard from getSponsorAssetRows to the sibling thunks
smarcet flagged (getPurchaseDetailsReport, getPurchaseDetailsLinesReport,
getSponsorAssetSponsor): concurrent invocations with different filters, page,
sort, or sponsorId carry different getRequest abort keys (only access_token is
stripped), so they never cancel each other and whichever response lands last
would win, even the older one. Reachable via the pre-startLoading token-refresh
window or an A-to-B drill-down navigation, where sponsor A's late response
could render under sponsor B's URL.

Extract the counter boilerplate into a shared sequenced() factory: each
invocation begins a sequence before the token await and gets isCurrent() plus
guardedDispatch(), which drops every dispatch (REQUEST, RECEIVE, error
handlers, start/stopLoading) once a newer invocation has begun. The newest
invocation is always current, so the overlay is always cleared by whichever
call finishes last. A stale call's 401 re-login is dropped too (uicore
authErrorHandler reaches doLogin by dispatching a thunk through the dispatch
it is given) — safe: the fresh call carries the same token, 401s too, and
drives the re-login. getSponsorAssetRows is refactored onto the same helper
(behavior unchanged; its three existing stale tests pass as-is).

Also guard the two filters thunks: safe for same-summit duplicates (identical
abort key, getRequest cancels), but a summit switch changes the URL, so the
old summit's late filters response would land after the SET_CURRENT_SUMMIT
reset and repopulate filterOptions with the wrong summit's options.

Add a stale-response test per newly guarded thunk (including the sponsor A
over B drill-down and the summit-switch filters case), each mutation-verified:
un-guarding the thunk makes its test fail with the stale response landing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
smarcet pushed a commit that referenced this pull request Jul 16, 2026
…nsors → Reports) (#997)

* feat(sponsor-reports): scaffold admin-gated Reports section under Sponsors

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): port actions/reducers/utils + store wiring

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): port shared report components (T.translate, getCSV)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): Purchase Details report (MuiTable, conditional refund tile)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(sponsor-reports): cover CSV+pagination/sort dispatch; drop vestigial SORT_FIELD_MAP

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): Sponsor Assets report + drill-down (rewired routes)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): Reports landing + integration

Replace placeholder with two MUI card links (Purchase Details,
Sponsor Assets); add breadcrumb; add landing_title i18n key;
update layout test for card assertions; 6 new tests, 912 total.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(sponsor-reports): final-review cleanup — drilldown errors, i18n, prop, route test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sponsor-reports): drop vestigial export-disabled; route to READ_ERROR

Remove *_EXPORT_DISABLED action constants (purchase-details, sponsor-asset, drilldown)
and their dead reducer arms. Route onExportDisabled → READ_ERROR in getPurchaseDetailsReport
and getSponsorAssetReport thunks (mirrors the already-fixed drilldown), so a 503
export-disabled on a read path clears loading via the same arm as not-found/unauthorized.
Remove exportDisabled and exportError from all three reducer DEFAULT_STATEs, drop
disabled={exportDisabled} from all three ExportCsvButton usages, and clean up all test
references to the deleted constants and unreachable state. Add 503 export-disabled coverage
for getPurchaseDetailsReport and getSponsorAssetReport matching the existing drilldown tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sponsor-reports): UI polish (fonts, breadcrumb trail, asset spacing)

- SummaryPanel tile labels overline -> subtitle1 (bigger; both reports)
- GroupByToggle small -> medium + larger button text
- SponsorReportsLayout renders a persistent Reports crumb + per-route page
  crumb so the trail reads .../Sponsors/Reports/<page> (was dropping to
  .../Sponsors on sub-pages); landing's own crumb removed (no dup)
- Sponsor Assets: gap between Report Filters and summary cards
- tests updated (landing breadcrumb moved to layout; layout trail test added)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sponsor-reports): address CodeRabbit review (PR #997)

- guard all report thunks against a missing currentSummit (prevents a stuck
  spinner from base(currentSummit.id) throwing after startLoading)
- formatUsd: treat blank/whitespace strings + non-finite numbers as missing
  (em dash) instead of $0.00; add reports-money tests
- FilterBar: re-sync draft when the committed value prop changes externally
- drop redundant routing.test.js (real-layout integration test in
  sponsor-reports-layout.test.js already covers route ordering)
- add PURCHASE_DETAILS_VALIDATION_CLEAR reducer test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sponsor-reports): address Copilot review (PR #997)

- reports-text: decode uppercase/mixed-case HTML entities (the case-insensitive
  match was missing the lowercase ENTITIES map); add toPlainText tests
- report-errors: export-disabled now dispatches { kind, status, message } to
  match the other error branches
- report-query: coerce sponsorIds to positive integers and drop the rest so the
  filter never emits sponsor_id==NaN/==0; add buildReportQuery tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(sponsor-reports): request dedicated sponsor-reports scopes (review)

Per Sebastian's PR review: summit-admin now requests the new microservice's own
scopes (sponsor-reports/read, sponsor-reports/export) instead of relying on the
broad reports/all. Adds SPONSOR_REPORTS_SCOPES and wires it into SCOPES
(reports/all kept for the legacy summit-reports-api). Requires the IDP to grant
these two scopes to the summit-admin client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): add purchase-details lines report action

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): add purchase-details lines reducer + store registration

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): add bucketLinesBySponsor grouping util

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): add LinesManifestView collapsible per-line manifest

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): add Orders/Line-Items report view toggle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): wire Line Items manifest view into Purchase Details page

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(sponsor-reports): strip trailing whitespace in LinesManifestView

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sponsor-reports): address PR #997 convention review (smarcet)

- index.js: import DEFAULT_PER_PAGE from constants (drop local DEFAULT_PAGE_SIZE dup)
- index.js: mapDispatchToProps to object shorthand via new clearPurchaseDetailsValidation
  action creator (removes the raw dispatch prop)
- OrdersTable.js: remove dead getOrderRowId export + its test (page maps id inline)
- usePrint: move to src/hooks/ per the codebase custom-hook convention
- section-csv-query.js: align defense-in-depth comment with the sponsor_id/page_id guard

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(sponsor-reports): add lines slice to layout test store (fix CI)

The Purchase Details page mapStateToProps reads sponsorReportsPurchaseDetailsLinesState.data;
the SponsorReportsLayout test mounts the real page, so its mock store must include that slice.
Missing it threw 'Cannot read properties of undefined (reading data)' under the full yarn test
run (CI), though the scoped report-suite runs passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sponsor-reports): wrap overflowing asset filenames in drilldown links

Hashed asset filenames (32-char hex prefix, no spaces) overflowed their card.
The link Typography had noWrap (white-space:nowrap). Switch the link to a flex
row with overflowWrap:anywhere + minWidth:0 on the filename so the unbroken hash
wraps within the card; icons get flexShrink:0 so they don't squash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sponsor-reports): readable toggle + summary-tile typography

The app sets html root font-size to 62.5% (10px), so rem-based sizes render
tiny: the toggle override read 8.75px and subtitle1 tile labels 10px. Pin px
instead — toggles to 14px/500 to match the Print/Export action buttons, and the
summary-tile labels to 14px.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): visible sponsor avatars + white header-icon theme

Add SponsorAvatar: logo-first with a colored-initials fallback so a no-logo
(or white-on-white) sponsor is never an invisible blank. Styled to mimic the
ReportShell title icon — single primary.light rounded square with white
foreground — and used in both group-by views and the drill-down header. Switch
the ReportShell title icon glyph to white to match. Also wrap the component-view
asset filename (was truncated by noWrap+maxWidth) so the full name is readable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): enable CSV export on the Line Items view

Render the Export CSV button on the Line Items (per-line) view of the
Purchase Details report, wired to the new backend
GET .../purchase-details/lines/csv endpoint. One ExportCsvButton switched
by view; lines query strips only pagination (preserves filters incl. the
derived include_cancelled). Orders export unchanged. Label stays 'Export CSV';
filename purchase-details-lines-summit-<id>.csv.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sponsor-reports): add isPositiveIntId + htmlToPlainText to methods.js

Generic helpers move to the shared methods.js home (convention). htmlToPlainText
preserves tag-boundary whitespace + decodes entities (htmlToString fuses words).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sponsor-reports): co-locate report-query + add shared purchase query builders

Move report-query under the feature folder (feature helper convention). Add
buildPurchaseQuery/buildPurchaseLinesQuery so the on-screen fetch and the upcoming
export thunks share one date/order normalization (no duplication, no regression).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sponsor-reports): use methods.htmlToPlainText, drop reports-text util

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sponsor-reports): purchase-details CSV export via action thunks

Move orders/lines CSV URL+params+filename building into exportPurchaseDetailsCsv/
exportPurchaseDetailsLinesCsv (cf. exportEventRsvpsCSV); page dispatches them from
a plain MUI Button. Orders CSV keeps the on-screen sort. base() now reads
window.SPONSOR_REPORTS_API_URL directly (drops getReportsApiBaseUrl import).
currentSummit removed from component props/mapStateToProps (no longer needed
after CSV URL building moved to the thunks).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sponsor-reports): sponsor-asset CSV export via action thunks; drop ExportCsvButton

Add exportSponsorAssetCsv/exportSponsorAssetSectionCsv (section logic folded in,
simplified); pages dispatch from plain buttons. Remove the ported ExportCsvButton
and section-csv-query util.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sponsor-reports): delete reports-api util; wire isPositiveIntId from methods

- sponsor-asset-report-page/index.js: repoint isPositiveIntId import to utils/methods
- sponsor-asset-drilldown-page/index.js: merge htmlToPlainText + isPositiveIntId into one
  import from utils/methods (fixes import/no-duplicates)
- sponsor-asset-report-page/__tests__: remove jest.mock("reports-api") — pure fn, no mock needed
- sponsor-asset-drilldown-page/__tests__: same
- layouts/__tests__/sponsor-reports-layout.test.js: remove obsolete reports-api mock + comment
- purchase-details-report-page/__tests__: rename stale "ExportCsvButton" test label to
  "export button" (authorized micro-cleanup; component deleted in Task 5)
- git rm src/utils/reports-api.js — no longer consumed by any source or test
- 115 suites / 966 tests passing; 0 new lint errors

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sponsor-reports): co-locate report-errors + reports-money out of shared utils

Both files moved into src/pages/sponsors/sponsor-reports/ per §2 (feature helpers
never in shared utils/); test for reports-money co-located alongside. Internal
./constants import in report-errors updated to ../../../utils/constants.
formatUsd logic unchanged — uicore/cents contract deferred to PR-B.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sponsor-reports): update constants import path in moved report-errors.js

./constants was not re-staged after the path update; this corrects it to
../../../utils/constants (resolves from the new feature folder location).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(reports): replace formatUsd with uicore currencyAmountFromCents

Backend now emits money fields as integer cents. Drop the bespoke
reports-money.js formatter (per santi PR #997 review) and use the
platform-wide currencyAmountFromCents from openstack-uicore-foundation.
Null guard via inline/local const rather than a new shared helper file.
Test fixtures updated to cents input; assertions to no-comma "$X.XX" output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(reports): use cents fixtures in purchase-details reducer test

Codex follow-up: the reducer passes summary payload through unchanged, so the
dollar-string fixtures were not a live bug, but they're stale under the cents
contract. Switch total_paid fixtures to integer cents to stay representative.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(reports): dedup per-page/ms constants via src/utils/constants

- OrdersTable: replace local MS_PER_SECOND (1000) with MILLISECONDS_IN_SECOND
  and local DEFAULT_PER_PAGE (10) with the shared constant; remove eslint-disable
- LinesManifestView: replace magic-number PER_PAGE_OPTIONS [10,25,50,100] and
  DEFAULT_PER_PAGE (50) with named constants [DEFAULT_PER_PAGE, TWENTY_PER_PAGE,
  FIFTY_PER_PAGE, MAX_PER_PAGE]; default perPage prop is now FIFTY_PER_PAGE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* fix(sponsor-reports): return Promise.resolve() on all thunk guard branches

Every thunk in sponsor-reports-actions.js that guards against a missing
summit returned `undefined` synchronously while the normal code-path
returned a Promise — inconsistent for callers using `.then()`/`await`.
Changed all ten guard-branch returns to `return Promise.resolve()` so
every thunk is uniformly thenable regardless of which branch is taken.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(sponsor-reports): fold statusTone into StatusPill

Moves TONE_BY_STATUS + statusTone() from the standalone statusTone.js
into StatusPill.js as a named export. Updates StatusRollupChips.js to
import from StatusPill. Deletes statusTone.js and its test file; all
branch-coverage assertions migrated into StatusPill.test.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(reports): replace hand-rolled formatCheckoutTime with moment

Swaps the manual regex + AM/PM math in formatCheckoutTime for
moment.unix().utc() / moment.utc() + .format("YYYY-MM-DD h:mm A").
Output is byte-identical for all tested inputs. Removes dead NOON
constant and unused MILLISECONDS_IN_SECOND import; keeps ISO_DATE_LENGTH
(still needed for the date-only fallback path, and the no-magic-numbers
rule requires it named).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(sponsor-reports): adopt useSnackbarMessage hook for 412 validation toast

Replaces the hand-rolled inline MUI <Snackbar>/<Alert> with the existing
useSnackbarMessage hook (openstack-uicore-foundation), consistent with
payment-view.js and mui-qr-badge-popup.js. Removes TOAST_AUTO_HIDE_MS and
the Snackbar import; Alert import kept (still used for readError). Redux
clearPurchaseDetailsValidation lifecycle preserved via useEffect.

Tests: mocks useSnackbarMessage, asserts errorMessage is called with the
correct message (and i18n fallback), clearValidation is dispatched, and
errorMessage is not called when validationError is null.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* test(sponsor-reports): mock useSnackbarMessage in layout test

The Purchase Details page now calls useSnackbarMessage() (T5); the layout
integration test renders that page without the global snackbar provider, so
the hook destructured undefined and threw on render. Mock it like the page's
own test does.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(T6): move manifest-grouping into reports feature dir

bucketLinesBySponsor had exactly one production caller
(LinesManifestView). Moving the helper and its test out of
src/utils/ and co-locating them with the feature removes the
one-off-file-in-shared-utils anti-pattern flagged in PR review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(sponsor-reports): T7 — dedup page-size constant + fix default-param-last

Edit A: replace local LINES_DEFAULT_PAGE_SIZE = 50 with imported FIFTY_PER_PAGE
from utils/constants; Edit B: remove `filters = {}` default on
exportPurchaseDetailsCsv — all callers pass an explicit object, buildPurchaseQuery
already defaults its own first arg.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(sponsor-reports): convert nextDayStartIso to moment-timezone

Replace hand-rolled new Date / setUTCDate with
moment.utc(ymd, "YYYY-MM-DD", true).add(1, "day") for consistency
with the rest of the date-formatting in this feature. Output is
byte-identical; ISO_DATE_LENGTH removed (now unused). Invalid-input
guard returns ymd unchanged rather than throwing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* test(OrdersTable): pin moment.utc offset+malformed contract (T9)

Add tests for non-Z offset inputs (-05:00, +05:00) and malformed ISO-
like strings to lock current moment.utc() behavior. Backend emits UTC Z
only; offset path is inert in production — assertions are documentation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(sponsor-reports): use repo-native isImageUrl + DEFAULT_CURRENT_PAGE

Replace local IMAGE_EXT regex in sponsor-asset-drilldown-page with the
repo-canonical isImageUrl from utils/methods (same extensions, also handles
query strings). Replace local FIRST_PAGE = 1 in sponsor-asset-report-page
with DEFAULT_CURRENT_PAGE from utils/constants (7 references). Replace
bare useState(1) / setPage(1) page-init/reset literals in purchase-details-
report-page with DEFAULT_CURRENT_PAGE (9 references); orderDir useState(1)
(sort direction) is intentionally unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(sponsor-reports): translate OrdersTable column headers via i18n

Replace 7 bare-string header values with T.translate calls; add 5 new
col_* keys to en.json; update header assertions in both test files to
match key output from the translate mock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(sponsor-reports): use DEFAULT_CURRENT_PAGE for page-number defaults

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(sponsor-reports): use Grid2 in asset drilldown page

Replace deprecated MUI v4 Grid (item xs={}) with Grid2 size={{}} API,
matching every other file in this PR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(sponsor-reports): use object-shorthand mapDispatchToProps in asset pages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(sponsor-reports): move query builders to actions layer, fold in toOrderParam

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(sponsor-reports): build report queries inside thunks, not components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(sponsor-reports): move report-errors helper to actions layer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* docs(sponsor-reports): fix stale toOrderParam comment in OrdersTable

toOrderParam moved to the query thunk in an earlier commit; the column
comment no longer describes a page-handler conversion.

* feat(sponsor-reports): add ContentTypeToggle (Collected/All)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* feat(sponsor-reports): Collected/All content filter on asset report page

Wire ContentTypeToggle (default: collected) into SponsorAssetReportPage —
injects moduleType:"Media" on fetch + CSV export when collected, omits it
for all; resets page on toggle. Tests: 12/12 (+3 new), full suite 995/995.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* feat(sponsor-reports): suppress download status + Collected/All filter in drilldown

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* fix(sponsor-reports): show empty page sections under All in drilldown

Drop-empty-section now applies only in Collected mode; All renders every
section as-is, including a page the sponsor never submitted to.

* refactor(sponsor-reports): hard-wire sponsor assets to collected (Media); remove toggle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* fix(sponsor-reports): scope section CSV to collected (Media); restore HTML-flatten test

- exportSponsorAssetSectionCsv now filters module_type==Media so the per-page
  Download CSV matches the collected-only view (was an escape path for
  Document/Info rows).
- Restore ContentCell HTML-flatten coverage as a Media text/input row (the
  prior test used an Info row removed with the toggle teardown).

* test(sponsor-reports): strengthen ContentCell flatten test to pin htmlToPlainText

Use an input with entities + adjacent tags (<p>Booth&nbsp;A</p><p>B &amp; C</p>
→ "Booth A B & C") so the test exercises entity-decode + whitespace-collapse +
tag-to-space — the behavior that distinguishes htmlToPlainText from stripTags.
A regression swapping the helper would now fail.

* refactor(sponsor-reports): reuse isPositiveIntId + TWENTY_PER_PAGE; drop port-framing comments

Convention-sweep fixes (Codex full-branch pass):
- query + section-CSV thunks reuse isPositiveIntId instead of hand-rolled
  Number.isInteger ID checks; section CSV bails on an invalid id rather than
  emitting a broadened (whole-sponsor) export
- replace local GROUP_PER_PAGE=25 (an arbitrary literal carried from the
  sponsor-services source, not spec'd) with the existing TWENTY_PER_PAGE
- rewrite OrdersGrid/sponsor-services port-framing comments to describe
  summit-admin behavior only

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV

* refactor(sponsor-reports): replace report-print.css with MUI GlobalStyles

Removes the last standalone stylesheet in the reports feature (legacy in
summit-admin per santipalenque). Whole-page print isolation is preserved
verbatim via an inline <GlobalStyles> block in ReportShell; the .report-body
className anchor is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* refactor(sponsor-reports): extract ContentCell into a shared component

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): add pivot axis + named-pivot definitions

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): pure recursive pivot-tree grouping + usePivot memo

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): fetch full flat asset set atomically with last_page paging

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): add PivotSelector

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): add recursive PivotTree renderer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): multi-level pivot view replaces single-level grouping

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(sponsor-reports): cover getSponsorAssetRows multi-page paging loop

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sponsor-reports): remove orphaned grouped-fetch path + dead exports; fix empty-state flash

Retire getSponsorAssetReport (no callers after the pivot rewire), its dead reducer
fields/case and the group_by query branch, drop unused axisKeyOf/axisLabelOf, and
default the reducer loading flag true to avoid a no-results flash before first fetch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sponsor-reports): drop stale getSponsorAssetRows completions (request token)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* feat(sponsor-reports): route pivot + unknown-bucket labels through i18n

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* fix(sponsor-reports): trim component pivot label (key/label parity)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* fix(sponsor-reports): close both stale-dispatch holes in getSponsorAssetRows via guardedDispatch

Introduces a single `guardedDispatch` closure (checked against `mySeq` at
every call site) to replace the two existing ad-hoc terminal guards.

Hole 1 — stuck loading: REQUEST_SPONSOR_ASSET was dispatched after
`await getAccessTokenSafely()` without a seq-guard. A stale call that
resumed after a newer call had already committed RECEIVE_SPONSOR_ASSET_ROWS
would re-flip loading:true with no terminal to clear it. guardedDispatch
suppresses the REQUEST dispatch for any superseded invocation.

Hole 2 — persistent stale error: fetchPage passed raw dispatch to getRequest,
so a stale request's HTTP error handler fired SPONSOR_ASSET_READ_ERROR
unguarded — clobbering fresh success state with readError. Changing the
innermost `)(dispatch)` to `)(guardedDispatch)` threads the guard through
getRequest's error-handler invocation.

The RECEIVE and catch-block SPONSOR_ASSET_READ_ERROR terminals now also use
guardedDispatch, consolidating all four dispatch sites under one mechanism.

Tests added (getSponsorAssetRows):
• suppresses REQUEST_SPONSOR_ASSET from a stale call (prevents stuck-loading)
• suppresses SPONSOR_ASSET_READ_ERROR from a stale call's error handler

Full suite: 1089/1089 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): drop the always-zero N/A status bucket from Sponsor Assets UI

The report is scoped to Media assets (server applies module_type==Media), whose
completion strategies only ever emit pending/in_progress/completed — never
not_applicable. The N/A summary tile and per-group rollup chip were therefore
always zero. Remove not_applicable from the tile keys, rollup chips, pivot-tree
accumulator, and the now-dead i18n key. Backend contract is untouched (it may
still send the key; the FE simply ignores it). StatusPill's generic
not_applicable->default tone mapping is left as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): add Status filter to Sponsor Assets report (JP request)

Single-select Status filter (Any / Completed / In Progress / Pending) in the
Sponsor Assets report's FilterBar extraControls slot. Pure FE: the backend
SponsorAssetFilter already allows status==, buildReportQuery already emits it,
and every row carries status — only the UI control was missing. Reuses
STATUS_TILE_KEYS so the options stay in lockstep with the summary tiles.
Applies server-side (refetch on Apply), so the summary tiles recompute on the
filtered set and CSV export inherits the filter. No backend change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sponsor-reports): map Payment Method filter to payment_method== query bracket

* feat(sponsor-reports): add payment method + invoice detail columns to Purchase Details orders table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* feat(sponsor-reports): add Payment Method filter to Purchase Details report

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(sponsor-reports): tighten finance-column assertions (all 4 non-sortable, exact em-dash count)

* refactor(sponsor-reports): rework read-error handling + address PR #997 review

Addresses the smarcet + santipalenque CHANGES_REQUESTED on the Sponsor Reports
FE port, plus a whole-PR convention sweep and correctness pass (each verified
with an independent review).

Error handling:
- Delete sponsor-reports-errors.js; fold a module-private reportReadErrorHandler
  into sponsor-reports-actions.js, matching the per-feature handler precedent in
  sponsor-cart-actions.js / summit-actions.js (src/actions holds only action files).
- Delegate 401 to uicore authErrorHandler so its isClearingSessionState guard
  dedupes re-login across the paginated getSponsorAssetRows fetch (was calling
  doLogin() raw per page).
- Drop the dead classifyReportError taxonomy (onExportDisabled was wired to the
  same action as onReadError everywhere; the kind zoo was unused). Keep only what
  pages consume: inline readError.message, kind:"not-found" for the drilldown 404,
  and the Purchase Details 412 validation toast. Reports keep inline error bodies
  (not the app snackbar) by design.
- Gate the getSponsorAssetRows catch to genuine non-HTTP exceptions (e instanceof
  Error): uicore rejects HTTP failures with a plain {err,res,...} object after
  invoking the handler, so the old unconditional catch flashed an inline error over
  the 401 reauth and clobbered the handler's server message on 403/500.

Correctness:
- Escape the five string filter[] operands (status, form_code, payment_method,
  module_type, media_request_type) with escapeFilterValue, per the FN filtering
  convention (cf. sponsor-cart-actions.js / event-rsvp-actions.js). Numeric ids,
  ISO dates, and the top-level search param stay literal.
- getSponsorAssetRows: bail a superseded invocation before it fires a request
  (after the token await and in the pagination loop). uicore getRequest aborts the
  in-flight same-URL request, so a stale call could otherwise cancel the current
  load. Complements the existing seq-guard on dispatches.
- Drilldown empty-state: guard on visiblePages.length (Media-filtered) instead of
  pages.length, so a sponsor whose pages hold only non-Media modules shows the
  no-submissions panel rather than an empty body.

Convention sweep:
- Fold sponsor-reports-query.js into sponsor-reports-actions.js (only non-*-actions
  file in src/actions, imported only there); builders stay exported for their tests.
- Move usePivot to src/hooks (canonical hook location, cf. usePrint).
- Remove 6 dead i18n keys under sponsor_reports_page (live pivot_unnamed_component
  untouched).
- Optional-chaining sweep for row.module to match PivotTree's defensive idiom:
  ContentCell (2) + sponsor-asset-drilldown-page (4).

Rewrites the handler's tests as thunk-level integration tests; adds live-401 /
live-403 asset-fetch and no-submissions coverage. Full suite green (131 suites /
1130 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* fix(sponsor-reports): pattern-audit follow-ups (filter scope, 404 route, hook fold)

Three issues surfaced by a whole-PR pattern audit against summit-admin exemplars.

- Payment Method filter silently no-op'd on the Line Items view. The Purchase
  Details page shares one filters object across the orders/lines toggle, but the
  lines endpoint's filter set omits payment_method (order-level attribute) and
  BaseFilter silently skips unknown AND-filter fields — so selecting a payment
  method returned unfiltered lines. Gate the control to the orders view (mirrors
  search being view-specific) and strip paymentMethod in buildPurchaseLinesQuery
  so a value carried over from the orders view can't emit a dead clause. Orders
  view is unchanged (buildPurchaseQuery still emits payment_method).
- Reports layout had no catch-all route, so an unknown /reports/... path rendered
  only the breadcrumb shell. Add <Route component={NoMatchPage} /> last in the
  Switch, matching sponsor-layout/event-layout. Eager import (not lazy) to match
  this layout's own convention — it eager-imports its pages and has no Suspense.
- Fold usePivot into its only consumer (sponsor-asset-report-page) and delete the
  hook. A single-export helper with one consumer belongs in the consumer, not the
  shared src/hooks dir (react-frontend.md §2). usePrint stays in src/hooks — it's
  generic (window.print) and used by all three report pages.

Full suite green (131 suites / 1131 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* test(sponsor-reports): regression tests for the summit-gate rebuttal

Encodes the invariant behind the "mount-only effect never re-runs on summit
change → stuck empty" review concern, so a future refactor that breaks it fails
CI instead of silently reintroducing the bug.

- sponsor-reports-actions: mount fetches (getSponsorAssetFilters/Rows,
  getPurchaseDetailsFilters) no-op with no current summit, across null / {} /
  { id: 0 } (a weaker `!currentSummit` guard fails the {} and { id: 0 } cases).
- summit-id-layout: the gate renders nothing unless currentSummit.id &&
  summitId === currentSummit.id && !loading && hasLoaded, with each negative case
  isolating ONE clause so deleting that clause fails exactly its test.
- sponsor-asset-report-page: with the real thunks (only getRequest mocked), a
  fresh mount fetches the current summit and a remount fetches the NEW summit —
  the direct observation a SummitIdLayout-driven remount produces on a switch.

Tests only; no source change. Full suite green (137 suites / 1164 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* fix(sponsor-reports): guarantee ContentCell img alt is a string (smarcet review)

Verified real (not a false positive) by smarcet: alt={row.module?.title} renders
an <img> with NO alt attribute when module/title is missing — worse than alt=""
for screen readers (they announce the filename/URL). module can be undefined on
this row shape (PivotTree's Leaf defends against exactly that).

- alt now falls back to row.module?.title || row.module?.component_name || ""
  (mirrors PivotTree's component_name || title idiom; "" marks it decorative).
- ContentCell.test.js: cover the previously-untested image branch with module
  absent — assert alt="" is present, not an omitted attribute.

Also renames a shadowing `thunk` it.each param (no-shadow) in the guard tests.

Full suite green (137 suites / 1165 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* refactor(sponsor-reports): address PR #997 review (reducer state, uicore dedup, paging, test prune)

Reducer-driven list state (#1):
- Move pagination/sort/filter out of component useState into the
  purchase-details, lines, and sponsor-asset reducers; thunks record them
  via getRequest's 5th-arg REQUEST payload and components read from props.
  Mirrors ticket-type-list-page.

Adopt uicore components, drop duplicates (#2-#4 + parent sweep):
- MuiDropdown for the status/form/payment selects, sponsor-asset status,
  and PivotSelector; uicore SearchInput in FilterBar; uicore DateTimePicker
  for the date filters (values kept as YYYY-MM-DD via a UTC boundary
  conversion, query contract unchanged).
- SummaryPanel -> uicore InlineCard; StatusRollupChips -> mui/chip-list.

Use the global loading overlay (#5):
- Remove per-slice `loading` from all four reports reducers; thunks bracket
  start/stopLoading so state.baseState.loading + the single AjaxLoader own
  it. Sponsor-asset keeps a local hasFetched flag for first-mount
  empty-state suppression.

Fold single-consumer helper (#6):
- Inline bucketLinesBySponsor into LinesManifestView; delete
  manifest-grouping.js.

Bulk-load sponsor-asset rows with bounded concurrency (#10 / smarcet):
- Replace the per_page=5000 fetch (removed ALL_ROWS_PER_PAGE) with
  page-1-then-parallel paging at per_page=100 via p-limit + Promise.all,
  matching ticket-actions. Re-check the sequence token inside the pool
  callback so a superseded invocation's queued jobs cannot fire an
  identical-page request that would abort the fresh invocation's (under
  getRequest's page-bearing abort key).

Follow-up correctness fixes (Codex review):
- toReportDate treats the picker's moment(0) clear-sentinel as "no date"
  (matches the .unix()||null filter convention) so clearing a date drops
  the filter instead of sending 1970-01-01.
- FilterBar search applies live (debounced) instead of feeding the
  Apply-gated draft, so a fast type+Apply can't submit a stale value.
- Generation-guard hasFetched so a superseded fetch can't flash the empty
  state.

Prune tests to meaningful coverage (parent + #7-#9):
- Drop tests that exercised third-party libraries or were low-value
  (summit-switch, ReportShell, reducer-loading, TierBadge neutral, the
  manifest-grouping unit test) and reduce sponsor-reports suites to the
  behavior we own. Add mutation-proof regression tests for the fixes above.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* fix(sponsor-reports): review follow-ups (lines page size, FilterBar default, dead code)

Whole-branch review findings that survived verification:

- Restore the Line Items (manifest) default page size to 50. The reducer
  relocation had set the lines DEFAULT_STATE.perPage to DEFAULT_PER_PAGE (10);
  the page test seeded perPage:50 in its fixture and masked the wrong default,
  so the live first fetch pulled 10. Use FIFTY_PER_PAGE + add a reducer test
  asserting the default (the test that would have caught the regression).

- Default FilterBar `value` to a module-level stable {} (EMPTY_VALUE) instead
  of an inline {}, so omitting the prop no longer makes the value-resync effect
  loop on a fresh object each render.

- Remove dead `query: {}` from the purchase-details reducer DEFAULT_STATE
  (never read or written).

- Remove unused ERROR_CODE_503 constant (503 is handled generically).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* fix(sponsor-reports): dropdown label overlap + empty Type filter (browser QA)

Two issues found while testing the filters in the browser:

- MuiDropdown label overlapped the value. The uicore component hardcodes
  displayEmpty and renders the placeholder (or "Select an option...") in the
  value area, while a `label` prop becomes a separate InputLabel that never
  shrinks — so the two overlap. Drive it by `placeholder` (the field name)
  instead, and restore the combobox's accessible name via
  SelectDisplayProps={{ "aria-label" }} (a bare aria-label on Select doesn't
  reach the role="combobox" element). Applies to the Purchase Status / Type /
  Payment Method selects and the Sponsor Assets status select.

- Type filter showed only "Any". The filters endpoint returns forms as
  { code, name } where name is the (often blank) form_type; the old code
  dropped every blank-name form, while the table still shows them via each
  row's prebuilt form.display ("{code} - {name}"). Fall back to the code as the
  label when the name is blank (dropping only entries with no code), so the
  Type filter lists the types and matches the table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* fix(sponsor-reports): translate asset status pills (Copilot review)

The Sponsor Assets pivot tree and drill-down rendered the raw status token
("completed", "in_progress") as the StatusPill label, while the same report's
tiles and filter use the translated sponsor_reports_page.status_* labels.
Translate the label at both asset call sites, with a notFound fallback to the
raw token for any status without a key (Media-scoped report only yields the
three keyed statuses). Purchase-side pills (OrdersTable, Lines) keep the raw
status, which is already human-readable and has no status_* keys.

Scope the by_status tiles test to rows:[] so the tile labels don't collide
with a seeded row's now-translated pill under the key-echo i18n mock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* refactor(sponsor-reports): address santi approval nits (createAction, tone map, STATUS_KEYS)

Three of the five nits from the approving review (the seq-guard one is
@smarcet's platform-level call; the FilterBar EMPTY_VALUE one is pushed back
on — an inline {} default re-fires the value-resync effect every render):

- clearPurchaseDetailsValidation now uses createAction via a thunk, matching
  the house idiom (summit-actions clearCurrentSummit et al) instead of a
  hand-rolled plain action object.

- Sponsor Assets tiles derive their tone from StatusPill's exported
  statusTone() instead of a duplicated local TILE_TONE map.

- Hoist the displayed-status list to a single exported STATUS_KEYS in
  build-pivot-tree (which computes the rollup those keys describe); the
  rollup zero-init, StatusRollupChips, and the asset page's tiles + status
  filter all consume it instead of holding literal copies.

- FilterBar live-search test now OMITS `value`, exercising the module-level
  EMPTY_VALUE default; regressing it to an inline {} default loops the
  resync effect ("Maximum update depth exceeded") and blows the test up —
  mutation-verified against that exact change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

* fix(sponsor-reports): sequence-guard report thunks against stale responses

Port the stale-response guard from getSponsorAssetRows to the sibling thunks
smarcet flagged (getPurchaseDetailsReport, getPurchaseDetailsLinesReport,
getSponsorAssetSponsor): concurrent invocations with different filters, page,
sort, or sponsorId carry different getRequest abort keys (only access_token is
stripped), so they never cancel each other and whichever response lands last
would win, even the older one. Reachable via the pre-startLoading token-refresh
window or an A-to-B drill-down navigation, where sponsor A's late response
could render under sponsor B's URL.

Extract the counter boilerplate into a shared sequenced() factory: each
invocation begins a sequence before the token await and gets isCurrent() plus
guardedDispatch(), which drops every dispatch (REQUEST, RECEIVE, error
handlers, start/stopLoading) once a newer invocation has begun. The newest
invocation is always current, so the overlay is always cleared by whichever
call finishes last. A stale call's 401 re-login is dropped too (uicore
authErrorHandler reaches doLogin by dispatching a thunk through the dispatch
it is given) — safe: the fresh call carries the same token, 401s too, and
drives the re-login. getSponsorAssetRows is refactored onto the same helper
(behavior unchanged; its three existing stale tests pass as-is).

Also guard the two filters thunks: safe for same-summit duplicates (identical
abort key, getRequest cancels), but a summit switch changes the URL, so the
old summit's late filters response would land after the SET_CURRENT_SUMMIT
reset and repopulate filterOptions with the wrong summit's options.

Add a stale-response test per newly guarded thunk (including the sponsor A
over B drill-down and the summit-switch filters case), each mutation-verified:
un-guarding the thunk makes its test fail with the stale response landing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbjUV2hmmvm8BWErNvJfdF

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants