NWP-101: add export options - #165
Open
aatumdesai-create wants to merge 3 commits into
Open
Conversation
npm install rewrote the stale "template-planner" name left over from the Tremor template the console was built on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sortPayments compared amounts with String(a.amount).localeCompare(...), which orders them lexicographically: 900 ranked above 1000. Amounts are integer minor units, so they subtract. The export reuses this sort, so an amount-sorted CSV was wrong in the same way the table was. Adds queries.test.ts to cover it. Also exports STATUSES, so the payments page can stop keeping a second copy of the status allowlist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ops can now pick the columns and the scope before downloading the payments
CSV, instead of always getting all ten columns for the current filter.
- parseExportColumns validates the columns parameter against EXPORT_COLUMNS
server-side, keeps the requested order, and drops unknown names
- the card last four is out of the default set and has to be opted into
- scope=all runs filterPayments({}) so "all payments" still goes through the
one query builder
- the filename carries the scope: payments-disputed-2026-08-13.csv
- an empty column selection returns 400 rather than a headerless file
- the Export button opens a Drawer (Radix Dialog) with the column checkboxes,
the scope radios and a row count for each
toCsv keeps its existing signature and its all-columns default, so the tests
pinning the current contract still pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ticket
Closes NWP-101
What changed
Ops can now choose what goes into a payments export instead of always getting all ten columns for whatever filter happens to be on screen. The Export button on
/paymentsopens a drawer with a checkbox per column, a scope choice between the current filter and all payments with the row count next to each, and a Download button that stays disabled until at least one column is ticked. The card last four is unticked by default, so a file can go to a merchant without being cleaned up by hand first, and the filename now says what is in it —payments-disputed-2026-09-10.csvrather thanpayments-2026-09-10.csv.Server side,
parseExportColumnsinsrc/lib/csv.tsvalidates thecolumnsparameter againstEXPORT_COLUMNS, keeps the order requested, drops names that are not columns, and returns an empty list for an empty selection so the route can answer 400 instead of writing a file with no columns in it.scope=allrunsfilterPayments({}), so "all payments" still goes through the one query builder rather than reaching for the store.toCsvis unchanged — its signature and its all-columns default both stay, which is why the tests pinning the existing contract still pass.How I verified it
npm test— 44 passed across 4 files (was 28 across 3):npx tsc --noEmitclean,npx next lintreports no warnings or errors.Against the dev server on :3000, by URL:
/api/payments/exportlast4?columns=amount,id?columns=id,shoe_size,amount?columns=and?columns=nope{"message":"Select at least one column to export."}?status=disputedpayments-disputed-2026-09-10.csv?scope=all&status=disputedpayments-all-2026-09-10.csv, 1658 rows?merchantId=mch_05payments-filtered-2026-09-10.csvpayments-2026-09-10.csv?sort=amount&direction=asc$7.96first; descending starts£475.55Row counts were compared against
GET /api/paymentstotals across five filter combinations — none,status=disputed,merchantId=mch_05,from=2026-08-01,search=order&status=captured— and the page total, the API total and the exported row count agree in every case.npm testpassesThe browser pass was not done, so the drawer itself is unexercised. Everything above was verified over HTTP and by unit test. What that leaves unconfirmed: that the drawer opens and renders, that
last4appears unticked, that unticking every column visibly disables Download, that the two row counts display correctly, and that Escape closes the drawer and returns focus to the Export button. The underlying route contract behind each of those is tested; the rendering is not.Acceptance criteria
parseExportColumnsunit tests cover the default set, the opt-in, ordering, unknown names, duplicates and whitespace; confirmed over HTTP. Checkbox rendering not visually confirmed.Content-Dispositionfor all four cases.formatMoneyis still the only formatter and the existingtoCsvtests pass untouched.Bugs fixed along the way
src/data/queries.ts—sortPaymentsordered amounts as text. It compared withString(a.amount).localeCompare(String(b.amount)), so 900 ranked above 1000. Amounts are integer minor units, so they subtract. The export reuses this sort, so an amount-sorted CSV was wrong in the same way the table was. Covered by a regression test in the newsrc/data/queries.test.ts.src/app/payments/page.tsx— a second validation path. The page hand-built itsPaymentFilterswith its own copy of the status allowlist and read onlystatus,merchantId,searchandpage, ignoringfrom,to,sortanddirection, all of which the export route'sparseFiltershonours. That was harmless until this ticket put a row count on screen: at?from=2026-08-01the page reported 1,658 payments while the export wrote 167. The page now callsparseFilters, andSTATUSESis exported fromqueries.tsas the single list. Side effect worth knowing:from,to,sortanddirectionnow actually filter and order the table.Notes for the reviewer
The drawer has no automated test. Vitest here runs
environment: "node"with ansrc/**/*.test.tsinclude glob — no jsdom, no testing-library, and.tsxis not matched. Rather than add those for one ticket, the testable logic went into pure functions incsv.tsand the server-side 400 is the enforcement behind the disabled button.disabledon an anchor does nothing, which nearly cost the last acceptance criterion.<Button asChild>forwardsdisabledonto the<a>, where it is not a valid attribute, and the variant'sdisabled:pointer-events-nonekeys off a pseudo-class an anchor never matches — the link stays clickable and looks normal. The footer swaps the element instead, rendering a real<button disabled>when nothing is selected, which is the pattern the pagination controls twelve lines below the Export button already use.Reused rather than added. There is no
Dialogcomponent insrc/components—.claude/rules/components.mdsays there is, and that is wrong.Drawer.tsxwraps@radix-ui/react-dialog, so it is the dialog primitive, and it brings the focus trap, Escape handling and close button already wired. No Checkbox, Radio or Label component exists and none of those Radix packages are installed, so the controls are native inputs with real<label htmlFor>, which keepspackage.jsonuntouched.Behavior change on an existing URL. A bookmarked
/api/payments/export?status=disputedreturned ten columns includinglast4before this change and returns nine without it now. That is the intent of the ticket, but it changes what an existing link does.Found and deliberately not fixed:
filterPaymentscompares full ISO timestamps againstfrom/towith string<and>, so a date-onlyto=2026-08-13excludes all of 13 August. Not reachable from the filter bar, which has no date inputs, so it is outside this ticket. Separately, sorting by amount orders across mixed currencies by raw minor units, which is pre-existing and arguably meaningless, but changing it is a product decision rather than a bug fix.Commits are split three ways — the lockfile name correction, the sort fix, then the feature — so the bug fix can be read on its own.
🤖 Generated with Claude Code