Purchasing: PO create/list/detail, stock page (29/40 specs) - #65

Merged
corrin merged 14 commits into
mainfrom
purchasing-po
Aug 11, 2026
Merged

Purchasing: PO create/list/detail, stock page (29/40 specs)#65
corrin merged 14 commits into
mainfrom
purchasing-po

Conversation

@corrin

@corrincorrin commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Summary

Ports three of the five purchasing-cluster E2E specs (po-created-by, create-purchase-order, stock-search) with a full purchasing UI: PO list/create/detail pages, an editable PO-lines grid, and a stock page. supplier-alias-search and pickup-address (the two remaining, Google-Places-dependent specs) are next.

Backend was already ported in a prior slice; this is frontend-only.

Architecture work beyond the target specs

Three rounds of adversarial review found this slice was about to leave (or was leaving) real structural debt behind, and each was fixed before merge rather than deferred:

  • features/shared/DataTable.tsx — the one owner of the editable-grid E2E contract (DataTable-row-N, data-grid-*). The new PoLinesTable would otherwise have been a third hand-rolled copy alongside SmartTimesheetTable and CostLineGrid; all three now render through it.
  • features/shared/QueryState.tsx + features/shared/ListTable.tsx — the one owner of the pending/error gate every query-backed page or panel repeats, and the plain-rows-table shell layered over it. PoListPage/StockPage had copied CompaniesListPage's loading/error/retry block verbatim; an exhaustive sweep (three passes, each catching what the last missed) found and converted every genuine instance across the frontend — PoDetailPage, CostLineGrid, JobMovementReportPage, CompanyDetailPage, JobFinishTab, DailyOverviewPage, TimesheetEntryPage. A few sites stay deliberately unconverted (embedded card widgets with richer branching than binary success/fail; one guard-clause-shaped early return matching CLAUDE.md's own preference) — reasoning recorded in rewrite-status.md.
  • features/companyfeatures/shared/company — it had no route of its own and was already cross-imported by job; this slice's PoSummaryCard would have been a third cross-domain import of what was really a shared widget library sitting in a domain-shaped directory.
  • Bug fix: PoLinesTable's item-picker label had no description fallback, so a bound stock item with a null item_code (nullable, v1 parity) misread as unbound.

ADR 0039 was strengthened with the underlying principle: architectural unification is never deferred to a later slice, and shared concepts get shared homes.

Also (unrelated to purchasing, fixed in the same branch at the user's direction): docs/rewrite-status.md and docs/cutover-checklist.md corrected — the SSE/live-updates work (Slice 3) was misfiled as a post-cutover deferral and is actually MUST-before-cutover, and the release-gate section now states both go/no-go criteria (functional parity, architecture quality) rather than only the E2E proxy for the first.

Test plan

  • npm run type-check clean
  • npx vitest run src/features — 197/197 passing
  • Cheap + expensive pre-commit gate tiers green
  • ./scripts/ops/run_e2e.sh (full 88-case suite) green
  • Three rounds of adversarial subagent review, all findings resolved

🤖 Generated with Claude Code

https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L

Summary by CodeRabbit

  • New Features
    • Added purchasing pages for stock browsing and purchase-order creation, listing, and details.
    • Added supplier and job search, purchase-order line editing, autosave, status updates, and draft-line handling.
    • Added company and contact lookup, creation, selection, and editing workflows.
    • Added debounced stock and company searches with cached results when searches are cleared.
  • Bug Fixes
    • Improved loading, error, retry, and save-failure feedback across key screens.
    • Added safer purchase-order updates when concurrent changes occur.
  • Tests
    • Expanded automated coverage for purchasing, shared controls, search, accessibility, and end-to-end workflows.

corrinand others added 10 commits August 10, 2026 22:28
PoCreatePage (CompanyLookup + reference + save -> 201 redirect),
PoListPage, PoDetailPage with PoSummaryCard (created-by input,
reference autosave, status select), usePoLines over the single PATCH
endpoint, and the missing 'po' concurrency invalidator so 412/428
recovery actually refetches. Ports createTestPurchaseOrder +
waitForPoAutosave and the po-created-by spec.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
PoLinesTable as the third grid on the useReactTable + useDraftRows
pattern (phantom row, no add-line button, row-exit draft commit —
unit-cost stays the row's last focusable cell so the spec's Tab exits
the row). ItemSelect generalised for stock-only consumers (optional
jobId/line, label + wrapper overrides; labour-rates query gated on
jobId presence only, since textOnly labels need rate names). Inline
JobSelect over purchasing_all_jobs_retrieve — the unfiltered endpoint
v1's PO page uses, because fresh jobs are draft and the filtered
sibling excludes them. Spec ported with the autosave waiter armed
before the pick/status clicks (v2 saves immediately; v1's debounce is
what made arm-after work).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Spec-lean StockPage: full active-stock list on load, 300ms-debounced
server FTS from 3 characters, enabled-gated so clearing the box
renders the cached list with no /search/ request.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
…ports
The purchasing slice's PoLinesTable was about to become a third
hand-rolled grid emitting the DataTable-row-N/data-grid-* contract
inline. features/shared/DataTable.tsx is now the one owner of that
contract; SmartTimesheetTable, CostLineGrid and PoLinesTable all
render through it.
Auditing further found the same pathology in the plain list pages:
PoListPage and StockPage (both new this slice) had copied
CompaniesListPage's table shell and loading/error/retry block
verbatim, taking an existing duplicate (also in WipReportPage) from
2 instances to 4. features/shared/ListTable.tsx is the one owner of
that block instead -- deliberately separate from DataTable, since it
has no react-table dependency and forcing static lists through
column-def machinery would be indirection, not rigor. A hand-rolled
debounce-into-query-state pattern in CompaniesListPage and StockPage
is now features/shared/useDebouncedValue.ts (KanbanSearchInput keeps
its own URL-driven debounce -- a different concept).
ItemSelect, the decimal helpers and the Save-failed badge move to
features/shared/ because purchasing consumed them cross-domain -- a
domain feature is not a library.
ADR 0039 strengthened: unification is never deferred, shared concepts
get shared homes, the bar is reference quality.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Adversarial review on the branch found the ListTable audit had stopped
short: JobMovementReportPage and CompanyDetailPage hand-rolled the same
loading/error/retry block ListTable was built to own (6 real instances,
only 4 fixed). Split the block itself out as
features/shared/QueryState.tsx -- the pending/error gate alone, no
table -- so it fits pages that show something other than a table too.
ListTable now composes QueryState instead of duplicating it. PoDetailPage,
CostLineGrid, JobMovementReportPage and CompanyDetailPage all render
through it.
Fixed: PoLinesTable's item-picker label read `item_code ?? 'Select Item'`
with no description fallback, so a bound stock item with a null code
(nullable, v1 parity) misread as unbound. Now poLineItemLabel() in
lines.ts, unit-tested.
Moved features/company to features/shared/company: it was already
cross-imported by features/job (JobCreatePage, JobSettingsTab) before
this slice added a third importer (purchasing/PoSummaryCard) -- it never
had a route of its own, a shared widget library in a domain-shaped box.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Reversed 2026-08-11. The 2026-08-10 record said SSE ships with the
production-serving decision, filed under "Post-cutover -- decided,
deliberately NOT before 15 August." That was overturned: racing bad
architecture into production defeats the point of the rewrite
(non-negotiable #3 in the Cutover section), and the interim polling
shape plus the un-runnable apps/xero/sync_stream.py view are exactly
that. Slice 3 -- live updates done properly (serving model fix + SSE
ticker + discard the interim shortcuts) moves to its own MUST-tier
section with a milestone checkbox, and every stale "deferred
post-cutover" cross-reference in the file is corrected. The
purchasing-slice PR is unaffected -- it is unrelated and merges as
planned; this correction only fixes the durable record for the next
session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
QueryState grew optional loadingNode/errorNode override props so a
spinner-based caller keeps its visual shell instead of losing it to
the plain-text default. JobFinishTab, DailyOverviewPage, and both
gates in TimesheetEntryPage's EntryWorkspace now render through it.
XeroQuoteCard, JobInvoiceCard and JobSettingsTab's pay-item field stay
excluded -- richer branching than binary success/fail, not the
page-level gate QueryState owns. TimesheetEntryPage's own outer gate
stays as guard-clause `if` returns, matching CLAUDE.md's stated
preference rather than converting to a shape that would abandon it.
Also: rewrite-status.md's narration of "review round found X, we
fixed Y" replaced with what the outcomes actually are -- which shared
component owns which contract, which sites are deliberately excluded
and why, which constraints a future change must respect. And the
Cutover section now states the actual two-question go/no-go criteria
(functional parity, proxied by MUST-tier E2E; materially better
architecture, judged directly) rather than only the E2E proxy, with
the honest fallback (abort and stay on v1) stated explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
@coderabbitai

coderabbitaiBot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@corrin, you've reached your PR review limit, so we couldn't start this review.

Next review available in:12 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 756f5951-a815-47ee-9870-21c9be6081d2

📥 Commits

Reviewing files that changed from the base of the PR and between a3451f2 and ac028e2.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (14)
  • .gitignore
  • docs/rewrite-status.md
  • frontend/playwright.config.ts
  • frontend/src/features/crm/CompaniesListPage.tsx
  • frontend/src/features/job/JobFinishTab.tsx
  • frontend/src/features/purchasing/JobSelect.test.tsx
  • frontend/src/features/purchasing/JobSelect.tsx
  • frontend/src/features/purchasing/PoListPage.tsx
  • frontend/src/features/shared/ItemSelect.tsx
  • frontend/src/features/shared/SaveFailedBadge.tsx
  • frontend/src/features/shared/company/CompanyLookup.tsx
  • frontend/src/features/shared/company/CreateCompanyModal.tsx
  • frontend/src/features/shared/decimal.test.ts
  • frontend/src/features/shared/decimal.ts
📝 Walkthrough

Walkthrough

The PR adds authenticated purchasing pages for stock and purchase orders, including editable lines, autosave, optimistic updates, and E2E coverage. It also extracts shared query, table, company, decimal, and debounce components and updates architecture and cutover documentation.

Changes

Purchasing frontend

Layer / File(s)Summary
Purchasing contracts and persistence
frontend/src/api/..., frontend/src/features/purchasing/JobSelect.tsx, frontend/src/features/purchasing/lines.ts, frontend/src/features/purchasing/usePoLines.ts
Adds purchasing API exports, job filtering, PO draft utilities, optimistic mutations, concurrency invalidation, and refetch reconciliation.
Purchasing pages and routing
frontend/src/features/purchasing/..., frontend/src/routes/_authed/purchasing/..., frontend/src/routeTree.gen.ts
Adds PO list, create, detail, editable line, summary, and stock-search pages with authenticated routes.
Purchasing validation
frontend/tests/e2e/purchasing/..., frontend/tests/e2e/helpers.ts, frontend/src/features/purchasing/*.test.ts
Adds unit and E2E coverage for PO creation, autosave, status updates, created-by values, job filtering, draft serialization, and stock search.

Shared frontend consolidation

Layer / File(s)Summary
Shared query and grid infrastructure
frontend/src/features/shared/..., frontend/src/features/crm/..., frontend/src/features/job/..., frontend/src/features/reports/..., frontend/src/features/timesheet/...
Adds QueryState, ListTable, DataTable, SaveFailedBadge, decimal utilities, and useDebouncedValue, then adopts them across existing pages.
Shared company and person features
frontend/src/features/shared/company/..., frontend/src/features/job/JobCreatePage.tsx, frontend/src/features/job/JobSettingsTab.tsx
Adds shared company lookup, creation, Xero validation, person selection, and person management components, and updates consumers to use the shared module.

Architecture and cutover documentation

Layer / File(s)Summary
Architecture and release rules
docs/adr/0039-one-implementation-per-concept.md, docs/cutover-checklist.md, docs/rewrite-status.md, docs/code-quality.md
Documents immediate implementation unification, independent functionality and architecture gates, threaded serving requirements, purchasing progress, and updated suppression counts.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 54.24% 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 nameStatusExplanation
Title check✅ PassedThe title clearly identifies the purchasing pages and spec progress covered by the pull request.
Description check✅ PassedThe description explains the scope, architectural changes, remaining work, and validation results in sufficient detail.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch purchasing-po

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.

@coderabbitaicoderabbitaiBot 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: 10

🧹 Nitpick comments (11)
docs/cutover-checklist.md (1)

115-123: 🩺 Stability & Availability | 🔵 Trivial

Define the ASGI acceptance criteria.

or the ASGI equivalent is not an executable serving requirement. Define the minimum concurrency, timeout, keep-alive, database-connection, and ORM-safety requirements. Link this checkbox to the deployment configuration that enforces them.

🤖 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 `@docs/cutover-checklist.md` around lines 115 - 123, Update the serving-model
checklist item to define executable ASGI acceptance criteria: minimum
concurrency, request timeout, keep-alive, database-connection capacity, and
ORM-safety requirements. Link the checkbox to the deployment configuration or
command that enforces these settings, while retaining the existing WSGI option
and cutover context.
frontend/src/features/purchasing/StockPage.tsx (2)

25-31: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Send the trimmed query.

searchActive is computed from query.trim(), but the request sends the untrimmed query. For an input of " abc" the gate opens and q is " abc". Each whitespace variation also produces a distinct query key, which adds duplicate cache entries and duplicate requests for the same search term.

♻️ Proposed fix
- const searchActive = query.trim().length >= MIN_QUERY_LENGTH+ const trimmedQuery = query.trim()+ const searchActive = trimmedQuery.length >= MIN_QUERY_LENGTH
const list = useQuery(purchasingStockListOptions())
const search = useQuery({
- ...purchasingStockSearchRetrieveOptions({ query: { q: query } }),+ ...purchasingStockSearchRetrieveOptions({ query: { q: trimmedQuery } }),
enabled: searchActive,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 25 - 31, Update
the search query construction near searchActive in StockPage so the request and
its query key use the trimmed query value, while preserving the existing
minimum-length gating behavior.

27-33: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Preserve the previous search results while fetching a new query.

Each active search term creates a new query key. search.isPending then replaces the table with Loading stock items.... Use placeholderData: keepPreviousData to keep the current rows visible during the fetch.

♻️ Proposed refactor
-import { useQuery } from '`@tanstack/react-query`'+import { keepPreviousData, useQuery } from '`@tanstack/react-query`'
 const search = useQuery({
...purchasingStockSearchRetrieveOptions({ query: { q: query } }),
enabled: searchActive,
+ placeholderData: keepPreviousData,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 27 - 33, Update
the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
frontend/tests/e2e/helpers.ts (1)

444-446: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace Math.random with a collision-free identifier.

Math.floor(Math.random() * 100000) gives 100,000 possible values. Parallel Playwright workers and repeated local runs can produce the same supplier name. A duplicate [TEST] Supplier N creates a second live Xero contact push with the same name, which makes later lookups ambiguous.

Use crypto.randomUUID() or combine the timestamp with the worker index.

♻️ Proposed change
- const randomSuffix = Math.floor(Math.random() * 100000)- const supplierName = `[TEST] Supplier ${randomSuffix}`+ const randomSuffix = crypto.randomUUID().slice(0, 8)+ const supplierName = `[TEST] Supplier ${randomSuffix}`
🤖 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 `@frontend/tests/e2e/helpers.ts` around lines 444 - 446, Update
createTestPurchaseOrder so supplierName uses a collision-free identifier instead
of Math.random(), preferably crypto.randomUUID() or a timestamp combined with
the Playwright worker index, while preserving the existing “[TEST] Supplier”
naming format.
frontend/tests/e2e/purchasing/stock-search.spec.ts (1)

117-134: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The counter filter can miss a regression.

The listener ignores every /search/ response whose URL contains q=5mm. The exclusion exists to skip the first search, but it also hides the most likely regression: clearing the input re-fires the same q=5mm request. That request is a real post-clear search call, and the assertion at Line 134 still passes.

Gate on time instead of on the query value.

♻️ Proposed change
- // Track whether any further /search/ request fires when we clear the box.+ // Gate on the clear action, not on the query value: a re-fired `q=5mm`+ // request after clearing is still a post-clear search call.
let postClearSearchCalls = 0
+ let cleared = false
page.on('response', (response) => {
- if (- response.url().includes('/api/purchasing/stock/search/') &&- !response.url().includes('q=5mm')- ) {+ if (cleared && response.url().includes('/api/purchasing/stock/search/')) {
postClearSearchCalls += 1
}
})
+ cleared = true
await input.fill('')
🤖 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 `@frontend/tests/e2e/purchasing/stock-search.spec.ts` around lines 117 - 134,
Update the response listener in the stock-search test to count search responses
based on whether they occur after the input is cleared, rather than excluding
URLs containing q=5mm. Start tracking post-clear responses only immediately
before input.fill(''), so the initial search is ignored while any repeated q=5mm
request after clearing is counted and fails the existing assertion.
frontend/src/features/shared/ListTable.tsx (1)

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

Document that renderRow must supply the React key.

Line 66 calls rows.map(renderRow) and applies no key. Every current caller sets key on its own <tr>. That contract is not stated on the prop, so a future caller can omit the key and cause a React key warning plus incorrect row reconciliation.

♻️ Proposed doc addition
+ /** Must set a stable React `key` on the returned row element; ListTable+ maps rows directly and applies no key of its own. */
renderRow: (row: TRow) => ReactNode

Also applies to: 66-66

🤖 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 `@frontend/src/features/shared/ListTable.tsx` at line 21, Document the
`renderRow` prop contract in `ListTable`: implementations must return each row
with its own stable React `key`, since the `rows.map(renderRow)` call does not
apply one. Add this requirement to the prop’s existing documentation without
changing the rendering logic.
frontend/src/features/shared/company/PersonSelectionModal.test.tsx (1)

19-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering the create and update flows.

The single test covers accessibility only. handleCreate and handleUpdate hold the business rules: first person is always primary, blank optional fields are omitted from the request body, and an invalid email blocks submission. These rules are unit-testable with mocked mutations.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx` around
lines 19 - 48, Extend the PersonSelectionModal tests beyond accessibility to
cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
frontend/src/features/shared/company/PersonSelectionModal.tsx (3)

294-303: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The person card is a clickable div.

The card carries onClick but no role, tabIndex, or key handler. Keyboard users reach the person through the hover-revealed Select button, which group-focus-within exposes, so the flow is not blocked. Removing the card-level onClick and relying on the explicit Select button would remove the duplicated activation path and the event.stopPropagation() calls at Lines 341, 354, and 367.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
294 - 303, The person card in the person-selection rendering should no longer be
clickable via its container. Remove the card-level onClick handler from the div
around person.person_id, rely on the explicit Select button for activation, and
remove the associated event.stopPropagation() calls in the button handlers.

218-220: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse invalidatePeople here.

Lines 218-220 repeat the query-key invalidation that invalidatePeople defines at Lines 118-121. handleUpdate and handleConfirmDelete already call the helper. A future key change would need two edits.

♻️ Proposed fix
- await queryClient.invalidateQueries({- queryKey: companiesPeopleListQueryKey({ path: { company_id: companyId } }),- })+ await invalidatePeople()
As per coding guidelines: "Use one implementation per concept; search before implementing and extend a near-match instead of creating a parallel implementation."
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
218 - 220, Replace the direct companies-people query invalidation in the
relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.

Source: Coding guidelines


534-542: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Handle the returned promise on the submit button.

handleUpdate and handleCreate are async. Passing them directly to onClick returns a floating promise from the event handler. The delete button at Line 272 already wraps its call with void. Use the same form here for consistency and to satisfy @typescript-eslint/no-misused-promises if that rule is enabled.

♻️ Proposed fix
- onClick={editingPerson ? handleUpdate : handleCreate}+ onClick={() => {+ void (editingPerson ? handleUpdate() : handleCreate())+ }}
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
534 - 542, Update the submit button’s onClick handler to explicitly discard the
promise returned by handleUpdate or handleCreate, matching the existing
delete-button pattern. Preserve the editingPerson conditional selection and
button behavior.
frontend/src/features/shared/useDebouncedValue.test.tsx (1)

39-46: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the fake-timer queue on unmount.

clearTimeout is global, so another cleanup can satisfy toHaveBeenCalled(). Since this suite enables fake timers, assert that vi.getTimerCount() is greater than zero after renderHook, then zero after unmount().

🤖 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 `@frontend/src/features/shared/useDebouncedValue.test.tsx` around lines 39 -
46, Update the “clears a pending timer on unmount” test to assert the fake-timer
queue directly: verify vi.getTimerCount() is greater than zero after renderHook
creates the debounced timer, then verify it is zero after unmount(). Remove the
clearTimeout spy assertion.
🤖 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 `@docs/rewrite-status.md`:
- Around line 57-61: Update the frontend rebuild table in the rewrite-status
document to replace references to features/company/PersonSelectionModal.tsx and
features/company/CompanyLookup.tsx with their canonical features/shared/company/
paths, leaving other entries unchanged.
In `@frontend/src/features/crm/CompaniesListPage.tsx`:
- Around line 113-119: Apply the first-load-only error rule at both QueryState
call sites: in frontend/src/features/crm/CompaniesListPage.tsx lines 113-119,
change the ListTable isError value to companies.isError && companies.data ===
undefined; in frontend/src/features/job/JobFinishTab.tsx lines 209-213, use a
loadError derived per query from isError && data === undefined so cached summary
and checklist content remains visible during failed refetches.
In `@frontend/src/features/purchasing/JobSelect.tsx`:
- Around line 113-150: Update JobSelect to support keyboard navigation by
tracking an active option index, moving it with ArrowUp/ArrowDown, and selecting
the active job on Enter. Add role="listbox" to the dropdown and role="option"
with aria-selected to each mapped job row, making options keyboard-accessible
and reusing the shared picker pattern if available.
- Around line 91-111: Update the closing paths in JobSelect’s onBlur timeout and
Escape handler to reset editing and clear the stale search term when the picker
closes without selection, so value returns to the bound job and filtering does
not reuse old input. Store the 150ms blur timeout in a ref and add a useEffect
cleanup that clears it on unmount, while preserving the existing select
behavior.
In `@frontend/src/features/purchasing/PoListPage.tsx`:
- Around line 57-74: Update the purchase-order row in PoListPage’s renderRow
callback to be keyboard accessible: make the tr focusable, expose it as an
interactive control, and handle keyboard activation by navigating to the same
po/$poId destination as onClick. Preserve the existing mouse navigation
behavior.
In `@frontend/src/features/shared/company/CompanyLookup.tsx`:
- Around line 238-242: Update the suggestions empty-state rendering near
companiesSearchRetrieveOptions to check search.isError first and display an
error state when the request fails. Gate the existing “No companies found”
message on a successful, non-error search so rejected requests never appear as
empty results.
- Around line 88-99: Associate quickCreateCompany with an active request token
and call handleCompanyCreated only when the response still matches the current
lookup interaction; otherwise ignore the completion. In
frontend/src/features/shared/company/CompanyLookup.tsx lines 88-99, update
quickCreateCompany accordingly. In
frontend/src/features/shared/company/CreateCompanyModal.tsx lines 49-64, either
prevent dismissal while creation is pending or ignore completion after the modal
closes. Add regression tests covering abandoned quick-create and modal-creation
interactions.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 242-281: Update the form footer submit state in the component’s
submitDisabled logic to also disable submission whenever deleteTarget is
non-null or removeLink.isPending is true. Keep the existing confirmation overlay
behavior and ensure create/edit actions cannot start while deletion is pending
or awaiting confirmation.
In `@frontend/src/features/shared/decimal.ts`:
- Around line 10-16: Update parseDecimalInput to validate cleaned input against
an explicit fixed-point decimal syntax before numeric finiteness checks,
rejecting hex, octal, binary, and other non-decimal forms. Normalize accepted
values such as “.5”, “+5”, and “5.” into the backend-supported decimal
representation, and ensure trimDecimal cannot return invalid syntax verbatim.
In `@frontend/src/features/shared/ItemSelect.tsx`:
- Around line 19-43: Rewrite or remove the comments at
frontend/src/features/shared/ItemSelect.tsx:19-43, replacing implementation,
E2E, and history narration with the rejected API alternative and factual
constraint, or remove them. Apply the same constraint-based approach at
frontend/src/features/shared/SaveFailedBadge.tsx:1-4,
frontend/src/features/shared/decimal.test.ts:33-34 (including the rejected
formatting behavior and exponent constraint),
frontend/src/features/shared/company/CompanyLookup.test.tsx:154-155 (including
the Sonner rendering constraint if retained),
frontend/src/features/shared/company/CompanyLookup.tsx:88-90, and
frontend/src/features/shared/company/CreateCompanyModal.tsx:16-26 and :39
(document only rejected alternatives and current product/state constraints,
otherwise remove the comments).
---
Nitpick comments:
In `@docs/cutover-checklist.md`:
- Around line 115-123: Update the serving-model checklist item to define
executable ASGI acceptance criteria: minimum concurrency, request timeout,
keep-alive, database-connection capacity, and ORM-safety requirements. Link the
checkbox to the deployment configuration or command that enforces these
settings, while retaining the existing WSGI option and cutover context.
In `@frontend/src/features/purchasing/StockPage.tsx`:
- Around line 25-31: Update the search query construction near searchActive in
StockPage so the request and its query key use the trimmed query value, while
preserving the existing minimum-length gating behavior.
- Around line 27-33: Update the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
In `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx`:
- Around line 19-48: Extend the PersonSelectionModal tests beyond accessibility
to cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 294-303: The person card in the person-selection rendering should
no longer be clickable via its container. Remove the card-level onClick handler
from the div around person.person_id, rely on the explicit Select button for
activation, and remove the associated event.stopPropagation() calls in the
button handlers.
- Around line 218-220: Replace the direct companies-people query invalidation in
the relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.
- Around line 534-542: Update the submit button’s onClick handler to explicitly
discard the promise returned by handleUpdate or handleCreate, matching the
existing delete-button pattern. Preserve the editingPerson conditional selection
and button behavior.
In `@frontend/src/features/shared/ListTable.tsx`:
- Line 21: Document the `renderRow` prop contract in `ListTable`:
implementations must return each row with its own stable React `key`, since the
`rows.map(renderRow)` call does not apply one. Add this requirement to the
prop’s existing documentation without changing the rendering logic.
In `@frontend/src/features/shared/useDebouncedValue.test.tsx`:
- Around line 39-46: Update the “clears a pending timer on unmount” test to
assert the fake-timer queue directly: verify vi.getTimerCount() is greater than
zero after renderHook creates the debounced timer, then verify it is zero after
unmount(). Remove the clearTimeout spy assertion.
In `@frontend/tests/e2e/helpers.ts`:
- Around line 444-446: Update createTestPurchaseOrder so supplierName uses a
collision-free identifier instead of Math.random(), preferably
crypto.randomUUID() or a timestamp combined with the Playwright worker index,
while preserving the existing “[TEST] Supplier” naming format.
In `@frontend/tests/e2e/purchasing/stock-search.spec.ts`:
- Around line 117-134: Update the response listener in the stock-search test to
count search responses based on whether they occur after the input is cleared,
rather than excluding URLs containing q=5mm. Start tracking post-clear responses
only immediately before input.fill(''), so the initial search is ignored while
any repeated q=5mm request after clearing is counted and fails the existing
assertion.
🪄 Autofix

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 Plus

Run ID: 66ca2b61-202f-4ca9-a415-b773f6995a0a

📥 Commits

Reviewing files that changed from the base of the PR and between 3030b60 and a3451f2.

📒 Files selected for processing (60)
  • docs/adr/0039-one-implementation-per-concept.md
  • docs/code-quality.md
  • docs/cutover-checklist.md
  • docs/rewrite-status.md
  • frontend/src/api/index.ts
  • frontend/src/api/query-client.ts
  • frontend/src/features/crm/CompaniesListPage.tsx
  • frontend/src/features/crm/CompanyDetailPage.tsx
  • frontend/src/features/job/JobCreatePage.tsx
  • frontend/src/features/job/JobFinishTab.tsx
  • frontend/src/features/job/JobSettingsTab.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/purchasing/JobSelect.test.ts
  • frontend/src/features/purchasing/JobSelect.tsx
  • frontend/src/features/purchasing/PoCreatePage.tsx
  • frontend/src/features/purchasing/PoDetailPage.tsx
  • frontend/src/features/purchasing/PoLinesTable.tsx
  • frontend/src/features/purchasing/PoListPage.tsx
  • frontend/src/features/purchasing/PoSummaryCard.tsx
  • frontend/src/features/purchasing/StockPage.tsx
  • frontend/src/features/purchasing/index.ts
  • frontend/src/features/purchasing/lines.test.ts
  • frontend/src/features/purchasing/lines.ts
  • frontend/src/features/purchasing/usePoLines.ts
  • frontend/src/features/reports/JobMovementReportPage.tsx
  • frontend/src/features/reports/WipReportPage.tsx
  • frontend/src/features/shared/DataTable.tsx
  • frontend/src/features/shared/ItemSelect.tsx
  • frontend/src/features/shared/ListTable.test.tsx
  • frontend/src/features/shared/ListTable.tsx
  • frontend/src/features/shared/QueryState.test.tsx
  • frontend/src/features/shared/QueryState.tsx
  • frontend/src/features/shared/SaveFailedBadge.tsx
  • frontend/src/features/shared/company/CompanyLookup.test.tsx
  • frontend/src/features/shared/company/CompanyLookup.tsx
  • frontend/src/features/shared/company/CreateCompanyModal.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.test.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.tsx
  • frontend/src/features/shared/company/PersonSelector.tsx
  • frontend/src/features/shared/company/create-company.ts
  • frontend/src/features/shared/company/index.ts
  • frontend/src/features/shared/company/xero-contact.ts
  • frontend/src/features/shared/decimal.test.ts
  • frontend/src/features/shared/decimal.ts
  • frontend/src/features/shared/useDebouncedValue.test.tsx
  • frontend/src/features/shared/useDebouncedValue.ts
  • frontend/src/features/timesheet/DailyOverviewPage.tsx
  • frontend/src/features/timesheet/SmartTimesheetTable.tsx
  • frontend/src/features/timesheet/TimesheetEntryPage.tsx
  • frontend/src/routeTree.gen.ts
  • frontend/src/routes/_authed/purchasing/po/$poId.tsx
  • frontend/src/routes/_authed/purchasing/po/create.tsx
  • frontend/src/routes/_authed/purchasing/po/index.tsx
  • frontend/src/routes/_authed/purchasing/stock.tsx
  • frontend/tests/e2e/helpers.ts
  • frontend/tests/e2e/purchasing/create-purchase-order.spec.ts
  • frontend/tests/e2e/purchasing/po-created-by.spec.ts
  • frontend/tests/e2e/purchasing/stock-search.spec.ts
💤 Files with no reviewable changes (2)
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/calc.test.ts

Comment threaddocs/rewrite-status.md
Comment threadfrontend/src/features/crm/CompaniesListPage.tsx
Comment threadfrontend/src/features/purchasing/JobSelect.tsx
Comment threadfrontend/src/features/purchasing/JobSelect.tsx
Comment threadfrontend/src/features/purchasing/PoListPage.tsx
Comment threadfrontend/src/features/shared/decimal.ts
Comment threadfrontend/src/features/shared/ItemSelect.tsx

@coderabbitaicoderabbitaiBot 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.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 10

🧹 Nitpick comments (11)
docs/cutover-checklist.md (1)

115-123: 🩺 Stability & Availability | 🔵 Trivial

Define the ASGI acceptance criteria.

or the ASGI equivalent is not an executable serving requirement. Define the minimum concurrency, timeout, keep-alive, database-connection, and ORM-safety requirements. Link this checkbox to the deployment configuration that enforces them.

🤖 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 `@docs/cutover-checklist.md` around lines 115 - 123, Update the serving-model
checklist item to define executable ASGI acceptance criteria: minimum
concurrency, request timeout, keep-alive, database-connection capacity, and
ORM-safety requirements. Link the checkbox to the deployment configuration or
command that enforces these settings, while retaining the existing WSGI option
and cutover context.
frontend/src/features/purchasing/StockPage.tsx (2)

25-31: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Send the trimmed query.

searchActive is computed from query.trim(), but the request sends the untrimmed query. For an input of " abc" the gate opens and q is " abc". Each whitespace variation also produces a distinct query key, which adds duplicate cache entries and duplicate requests for the same search term.

♻️ Proposed fix
- const searchActive = query.trim().length >= MIN_QUERY_LENGTH+ const trimmedQuery = query.trim()+ const searchActive = trimmedQuery.length >= MIN_QUERY_LENGTH
const list = useQuery(purchasingStockListOptions())
const search = useQuery({
- ...purchasingStockSearchRetrieveOptions({ query: { q: query } }),+ ...purchasingStockSearchRetrieveOptions({ query: { q: trimmedQuery } }),
enabled: searchActive,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 25 - 31, Update
the search query construction near searchActive in StockPage so the request and
its query key use the trimmed query value, while preserving the existing
minimum-length gating behavior.

27-33: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Preserve the previous search results while fetching a new query.

Each active search term creates a new query key. search.isPending then replaces the table with Loading stock items.... Use placeholderData: keepPreviousData to keep the current rows visible during the fetch.

♻️ Proposed refactor
-import { useQuery } from '`@tanstack/react-query`'+import { keepPreviousData, useQuery } from '`@tanstack/react-query`'
 const search = useQuery({
...purchasingStockSearchRetrieveOptions({ query: { q: query } }),
enabled: searchActive,
+ placeholderData: keepPreviousData,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 27 - 33, Update
the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
frontend/tests/e2e/helpers.ts (1)

444-446: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace Math.random with a collision-free identifier.

Math.floor(Math.random() * 100000) gives 100,000 possible values. Parallel Playwright workers and repeated local runs can produce the same supplier name. A duplicate [TEST] Supplier N creates a second live Xero contact push with the same name, which makes later lookups ambiguous.

Use crypto.randomUUID() or combine the timestamp with the worker index.

♻️ Proposed change
- const randomSuffix = Math.floor(Math.random() * 100000)- const supplierName = `[TEST] Supplier ${randomSuffix}`+ const randomSuffix = crypto.randomUUID().slice(0, 8)+ const supplierName = `[TEST] Supplier ${randomSuffix}`
🤖 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 `@frontend/tests/e2e/helpers.ts` around lines 444 - 446, Update
createTestPurchaseOrder so supplierName uses a collision-free identifier instead
of Math.random(), preferably crypto.randomUUID() or a timestamp combined with
the Playwright worker index, while preserving the existing “[TEST] Supplier”
naming format.
frontend/tests/e2e/purchasing/stock-search.spec.ts (1)

117-134: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The counter filter can miss a regression.

The listener ignores every /search/ response whose URL contains q=5mm. The exclusion exists to skip the first search, but it also hides the most likely regression: clearing the input re-fires the same q=5mm request. That request is a real post-clear search call, and the assertion at Line 134 still passes.

Gate on time instead of on the query value.

♻️ Proposed change
- // Track whether any further /search/ request fires when we clear the box.+ // Gate on the clear action, not on the query value: a re-fired `q=5mm`+ // request after clearing is still a post-clear search call.
let postClearSearchCalls = 0
+ let cleared = false
page.on('response', (response) => {
- if (- response.url().includes('/api/purchasing/stock/search/') &&- !response.url().includes('q=5mm')- ) {+ if (cleared && response.url().includes('/api/purchasing/stock/search/')) {
postClearSearchCalls += 1
}
})
+ cleared = true
await input.fill('')
🤖 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 `@frontend/tests/e2e/purchasing/stock-search.spec.ts` around lines 117 - 134,
Update the response listener in the stock-search test to count search responses
based on whether they occur after the input is cleared, rather than excluding
URLs containing q=5mm. Start tracking post-clear responses only immediately
before input.fill(''), so the initial search is ignored while any repeated q=5mm
request after clearing is counted and fails the existing assertion.
frontend/src/features/shared/ListTable.tsx (1)

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

Document that renderRow must supply the React key.

Line 66 calls rows.map(renderRow) and applies no key. Every current caller sets key on its own <tr>. That contract is not stated on the prop, so a future caller can omit the key and cause a React key warning plus incorrect row reconciliation.

♻️ Proposed doc addition
+ /** Must set a stable React `key` on the returned row element; ListTable+ maps rows directly and applies no key of its own. */
renderRow: (row: TRow) => ReactNode

Also applies to: 66-66

🤖 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 `@frontend/src/features/shared/ListTable.tsx` at line 21, Document the
`renderRow` prop contract in `ListTable`: implementations must return each row
with its own stable React `key`, since the `rows.map(renderRow)` call does not
apply one. Add this requirement to the prop’s existing documentation without
changing the rendering logic.
frontend/src/features/shared/company/PersonSelectionModal.test.tsx (1)

19-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering the create and update flows.

The single test covers accessibility only. handleCreate and handleUpdate hold the business rules: first person is always primary, blank optional fields are omitted from the request body, and an invalid email blocks submission. These rules are unit-testable with mocked mutations.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx` around
lines 19 - 48, Extend the PersonSelectionModal tests beyond accessibility to
cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
frontend/src/features/shared/company/PersonSelectionModal.tsx (3)

294-303: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The person card is a clickable div.

The card carries onClick but no role, tabIndex, or key handler. Keyboard users reach the person through the hover-revealed Select button, which group-focus-within exposes, so the flow is not blocked. Removing the card-level onClick and relying on the explicit Select button would remove the duplicated activation path and the event.stopPropagation() calls at Lines 341, 354, and 367.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
294 - 303, The person card in the person-selection rendering should no longer be
clickable via its container. Remove the card-level onClick handler from the div
around person.person_id, rely on the explicit Select button for activation, and
remove the associated event.stopPropagation() calls in the button handlers.

218-220: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse invalidatePeople here.

Lines 218-220 repeat the query-key invalidation that invalidatePeople defines at Lines 118-121. handleUpdate and handleConfirmDelete already call the helper. A future key change would need two edits.

♻️ Proposed fix
- await queryClient.invalidateQueries({- queryKey: companiesPeopleListQueryKey({ path: { company_id: companyId } }),- })+ await invalidatePeople()
As per coding guidelines: "Use one implementation per concept; search before implementing and extend a near-match instead of creating a parallel implementation."
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
218 - 220, Replace the direct companies-people query invalidation in the
relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.

Source: Coding guidelines


534-542: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Handle the returned promise on the submit button.

handleUpdate and handleCreate are async. Passing them directly to onClick returns a floating promise from the event handler. The delete button at Line 272 already wraps its call with void. Use the same form here for consistency and to satisfy @typescript-eslint/no-misused-promises if that rule is enabled.

♻️ Proposed fix
- onClick={editingPerson ? handleUpdate : handleCreate}+ onClick={() => {+ void (editingPerson ? handleUpdate() : handleCreate())+ }}
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
534 - 542, Update the submit button’s onClick handler to explicitly discard the
promise returned by handleUpdate or handleCreate, matching the existing
delete-button pattern. Preserve the editingPerson conditional selection and
button behavior.
frontend/src/features/shared/useDebouncedValue.test.tsx (1)

39-46: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the fake-timer queue on unmount.

clearTimeout is global, so another cleanup can satisfy toHaveBeenCalled(). Since this suite enables fake timers, assert that vi.getTimerCount() is greater than zero after renderHook, then zero after unmount().

🤖 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 `@frontend/src/features/shared/useDebouncedValue.test.tsx` around lines 39 -
46, Update the “clears a pending timer on unmount” test to assert the fake-timer
queue directly: verify vi.getTimerCount() is greater than zero after renderHook
creates the debounced timer, then verify it is zero after unmount(). Remove the
clearTimeout spy assertion.
🤖 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 `@docs/rewrite-status.md`:
- Around line 57-61: Update the frontend rebuild table in the rewrite-status
document to replace references to features/company/PersonSelectionModal.tsx and
features/company/CompanyLookup.tsx with their canonical features/shared/company/
paths, leaving other entries unchanged.
In `@frontend/src/features/crm/CompaniesListPage.tsx`:
- Around line 113-119: Apply the first-load-only error rule at both QueryState
call sites: in frontend/src/features/crm/CompaniesListPage.tsx lines 113-119,
change the ListTable isError value to companies.isError && companies.data ===
undefined; in frontend/src/features/job/JobFinishTab.tsx lines 209-213, use a
loadError derived per query from isError && data === undefined so cached summary
and checklist content remains visible during failed refetches.
In `@frontend/src/features/purchasing/JobSelect.tsx`:
- Around line 113-150: Update JobSelect to support keyboard navigation by
tracking an active option index, moving it with ArrowUp/ArrowDown, and selecting
the active job on Enter. Add role="listbox" to the dropdown and role="option"
with aria-selected to each mapped job row, making options keyboard-accessible
and reusing the shared picker pattern if available.
- Around line 91-111: Update the closing paths in JobSelect’s onBlur timeout and
Escape handler to reset editing and clear the stale search term when the picker
closes without selection, so value returns to the bound job and filtering does
not reuse old input. Store the 150ms blur timeout in a ref and add a useEffect
cleanup that clears it on unmount, while preserving the existing select
behavior.
In `@frontend/src/features/purchasing/PoListPage.tsx`:
- Around line 57-74: Update the purchase-order row in PoListPage’s renderRow
callback to be keyboard accessible: make the tr focusable, expose it as an
interactive control, and handle keyboard activation by navigating to the same
po/$poId destination as onClick. Preserve the existing mouse navigation
behavior.
In `@frontend/src/features/shared/company/CompanyLookup.tsx`:
- Around line 238-242: Update the suggestions empty-state rendering near
companiesSearchRetrieveOptions to check search.isError first and display an
error state when the request fails. Gate the existing “No companies found”
message on a successful, non-error search so rejected requests never appear as
empty results.
- Around line 88-99: Associate quickCreateCompany with an active request token
and call handleCompanyCreated only when the response still matches the current
lookup interaction; otherwise ignore the completion. In
frontend/src/features/shared/company/CompanyLookup.tsx lines 88-99, update
quickCreateCompany accordingly. In
frontend/src/features/shared/company/CreateCompanyModal.tsx lines 49-64, either
prevent dismissal while creation is pending or ignore completion after the modal
closes. Add regression tests covering abandoned quick-create and modal-creation
interactions.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 242-281: Update the form footer submit state in the component’s
submitDisabled logic to also disable submission whenever deleteTarget is
non-null or removeLink.isPending is true. Keep the existing confirmation overlay
behavior and ensure create/edit actions cannot start while deletion is pending
or awaiting confirmation.
In `@frontend/src/features/shared/decimal.ts`:
- Around line 10-16: Update parseDecimalInput to validate cleaned input against
an explicit fixed-point decimal syntax before numeric finiteness checks,
rejecting hex, octal, binary, and other non-decimal forms. Normalize accepted
values such as “.5”, “+5”, and “5.” into the backend-supported decimal
representation, and ensure trimDecimal cannot return invalid syntax verbatim.
In `@frontend/src/features/shared/ItemSelect.tsx`:
- Around line 19-43: Rewrite or remove the comments at
frontend/src/features/shared/ItemSelect.tsx:19-43, replacing implementation,
E2E, and history narration with the rejected API alternative and factual
constraint, or remove them. Apply the same constraint-based approach at
frontend/src/features/shared/SaveFailedBadge.tsx:1-4,
frontend/src/features/shared/decimal.test.ts:33-34 (including the rejected
formatting behavior and exponent constraint),
frontend/src/features/shared/company/CompanyLookup.test.tsx:154-155 (including
the Sonner rendering constraint if retained),
frontend/src/features/shared/company/CompanyLookup.tsx:88-90, and
frontend/src/features/shared/company/CreateCompanyModal.tsx:16-26 and :39
(document only rejected alternatives and current product/state constraints,
otherwise remove the comments).
---
Nitpick comments:
In `@docs/cutover-checklist.md`:
- Around line 115-123: Update the serving-model checklist item to define
executable ASGI acceptance criteria: minimum concurrency, request timeout,
keep-alive, database-connection capacity, and ORM-safety requirements. Link the
checkbox to the deployment configuration or command that enforces these
settings, while retaining the existing WSGI option and cutover context.
In `@frontend/src/features/purchasing/StockPage.tsx`:
- Around line 25-31: Update the search query construction near searchActive in
StockPage so the request and its query key use the trimmed query value, while
preserving the existing minimum-length gating behavior.
- Around line 27-33: Update the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
In `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx`:
- Around line 19-48: Extend the PersonSelectionModal tests beyond accessibility
to cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 294-303: The person card in the person-selection rendering should
no longer be clickable via its container. Remove the card-level onClick handler
from the div around person.person_id, rely on the explicit Select button for
activation, and remove the associated event.stopPropagation() calls in the
button handlers.
- Around line 218-220: Replace the direct companies-people query invalidation in
the relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.
- Around line 534-542: Update the submit button’s onClick handler to explicitly
discard the promise returned by handleUpdate or handleCreate, matching the
existing delete-button pattern. Preserve the editingPerson conditional selection
and button behavior.
In `@frontend/src/features/shared/ListTable.tsx`:
- Line 21: Document the `renderRow` prop contract in `ListTable`:
implementations must return each row with its own stable React `key`, since the
`rows.map(renderRow)` call does not apply one. Add this requirement to the
prop’s existing documentation without changing the rendering logic.
In `@frontend/src/features/shared/useDebouncedValue.test.tsx`:
- Around line 39-46: Update the “clears a pending timer on unmount” test to
assert the fake-timer queue directly: verify vi.getTimerCount() is greater than
zero after renderHook creates the debounced timer, then verify it is zero after
unmount(). Remove the clearTimeout spy assertion.
In `@frontend/tests/e2e/helpers.ts`:
- Around line 444-446: Update createTestPurchaseOrder so supplierName uses a
collision-free identifier instead of Math.random(), preferably
crypto.randomUUID() or a timestamp combined with the Playwright worker index,
while preserving the existing “[TEST] Supplier” naming format.
In `@frontend/tests/e2e/purchasing/stock-search.spec.ts`:
- Around line 117-134: Update the response listener in the stock-search test to
count search responses based on whether they occur after the input is cleared,
rather than excluding URLs containing q=5mm. Start tracking post-clear responses
only immediately before input.fill(''), so the initial search is ignored while
any repeated q=5mm request after clearing is counted and fails the existing
assertion.
🪄 Autofix

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 Plus

Run ID: 66ca2b61-202f-4ca9-a415-b773f6995a0a

📥 Commits

Reviewing files that changed from the base of the PR and between 3030b60 and a3451f2.

📒 Files selected for processing (60)
  • docs/adr/0039-one-implementation-per-concept.md
  • docs/code-quality.md
  • docs/cutover-checklist.md
  • docs/rewrite-status.md
  • frontend/src/api/index.ts
  • frontend/src/api/query-client.ts
  • frontend/src/features/crm/CompaniesListPage.tsx
  • frontend/src/features/crm/CompanyDetailPage.tsx
  • frontend/src/features/job/JobCreatePage.tsx
  • frontend/src/features/job/JobFinishTab.tsx
  • frontend/src/features/job/JobSettingsTab.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/purchasing/JobSelect.test.ts
  • frontend/src/features/purchasing/JobSelect.tsx
  • frontend/src/features/purchasing/PoCreatePage.tsx
  • frontend/src/features/purchasing/PoDetailPage.tsx
  • frontend/src/features/purchasing/PoLinesTable.tsx
  • frontend/src/features/purchasing/PoListPage.tsx
  • frontend/src/features/purchasing/PoSummaryCard.tsx
  • frontend/src/features/purchasing/StockPage.tsx
  • frontend/src/features/purchasing/index.ts
  • frontend/src/features/purchasing/lines.test.ts
  • frontend/src/features/purchasing/lines.ts
  • frontend/src/features/purchasing/usePoLines.ts
  • frontend/src/features/reports/JobMovementReportPage.tsx
  • frontend/src/features/reports/WipReportPage.tsx
  • frontend/src/features/shared/DataTable.tsx
  • frontend/src/features/shared/ItemSelect.tsx
  • frontend/src/features/shared/ListTable.test.tsx
  • frontend/src/features/shared/ListTable.tsx
  • frontend/src/features/shared/QueryState.test.tsx
  • frontend/src/features/shared/QueryState.tsx
  • frontend/src/features/shared/SaveFailedBadge.tsx
  • frontend/src/features/shared/company/CompanyLookup.test.tsx
  • frontend/src/features/shared/company/CompanyLookup.tsx
  • frontend/src/features/shared/company/CreateCompanyModal.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.test.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.tsx
  • frontend/src/features/shared/company/PersonSelector.tsx
  • frontend/src/features/shared/company/create-company.ts
  • frontend/src/features/shared/company/index.ts
  • frontend/src/features/shared/company/xero-contact.ts
  • frontend/src/features/shared/decimal.test.ts
  • frontend/src/features/shared/decimal.ts
  • frontend/src/features/shared/useDebouncedValue.test.tsx
  • frontend/src/features/shared/useDebouncedValue.ts
  • frontend/src/features/timesheet/DailyOverviewPage.tsx
  • frontend/src/features/timesheet/SmartTimesheetTable.tsx
  • frontend/src/features/timesheet/TimesheetEntryPage.tsx
  • frontend/src/routeTree.gen.ts
  • frontend/src/routes/_authed/purchasing/po/$poId.tsx
  • frontend/src/routes/_authed/purchasing/po/create.tsx
  • frontend/src/routes/_authed/purchasing/po/index.tsx
  • frontend/src/routes/_authed/purchasing/stock.tsx
  • frontend/tests/e2e/helpers.ts
  • frontend/tests/e2e/purchasing/create-purchase-order.spec.ts
  • frontend/tests/e2e/purchasing/po-created-by.spec.ts
  • frontend/tests/e2e/purchasing/stock-search.spec.ts
💤 Files with no reviewable changes (2)
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/calc.test.ts
🛑 Comments failed to post (3)
frontend/src/features/shared/company/CompanyLookup.tsx (2)

88-99: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Ignore completion from abandoned company-creation interactions.

Both creation paths apply a completed mutation after the initiating UI state can change. A quick-create response can select an old query after the user continues searching. A modal response can select a company after the user closes the dialog.

  • frontend/src/features/shared/company/CompanyLookup.tsx#L88-L99: associate quick creation with an active request token, and select the returned company only if the request still matches the current interaction.
  • frontend/src/features/shared/company/CreateCompanyModal.tsx#L49-L64: prevent dismissal while creating or ignore a completion after the modal closes. Add regression tests for both paths.
📍 Affects 2 files
  • frontend/src/features/shared/company/CompanyLookup.tsx#L88-L99 (this comment)
  • frontend/src/features/shared/company/CreateCompanyModal.tsx#L49-L64
🤖 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 `@frontend/src/features/shared/company/CompanyLookup.tsx` around lines 88 - 99,
Associate quickCreateCompany with an active request token and call
handleCompanyCreated only when the response still matches the current lookup
interaction; otherwise ignore the completion. In
frontend/src/features/shared/company/CompanyLookup.tsx lines 88-99, update
quickCreateCompany accordingly. In
frontend/src/features/shared/company/CreateCompanyModal.tsx lines 49-64, either
prevent dismissal while creation is pending or ignore completion after the modal
closes. Add regression tests covering abandoned quick-create and modal-creation
interactions.

238-242: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Render a search failure state.

If companiesSearchRetrieveOptions rejects, search.isPending is false and this branch renders No companies found.. The user receives a false empty-result message. Render an error state when search.isError is true, and reserve the empty state for successful empty responses.

🤖 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 `@frontend/src/features/shared/company/CompanyLookup.tsx` around lines 238 -
242, Update the suggestions empty-state rendering near
companiesSearchRetrieveOptions to check search.isError first and display an
error state when the request fails. Gate the existing “No companies found”
message on a successful, non-error search so rejected requests never appear as
empty results.
frontend/src/features/shared/company/PersonSelectionModal.tsx (1)

242-281: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The delete overlay does not block the form column.

The confirmation panel is absolute inset-0 inside the people-list column only. The create/edit form and the submit button stay interactive while the confirmation is open. submitDisabled at Line 226 also ignores removeLink.isPending, so a user can start a create while a delete is in flight. Consider disabling the footer submit while deleteTarget !== null or removeLink.isPending.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
242 - 281, Update the form footer submit state in the component’s submitDisabled
logic to also disable submission whenever deleteTarget is non-null or
removeLink.isPending is true. Keep the existing confirmation overlay behavior
and ensure create/edit actions cannot start while deletion is pending or
awaiting confirmation.

corrinand others added 4 commits August 11, 2026 12:47
- CompaniesListPage/JobFinishTab: QueryState was passed the raw
isError, breaking the first-load-only rule the other converted
sites already follow -- a background refetch failure was
unmounting already-rendered content instead of leaving it on
screen.
- JobSelect: closing the picker without a selection (blur or
Escape) left `editing` true forever, so the input kept showing
the abandoned search term instead of reverting to the bound job;
the blur timer was untracked and could fire a state update after
unmount. Also added keyboard selection (arrow keys + Enter,
role=listbox/option, aria-activedescendant) -- the dropdown was
mouse-only.
- PoListPage: the row was reachable only by mouse click; added a
real Link on the PO number cell, matching CompaniesListPage's
existing pattern.
- decimal.ts: parseDecimalInput accepted anything Number() parses,
including hex/octal/binary literals and bare exponents, and sent
them to the wire verbatim as garbage Decimal strings. Validates
fixed-point syntax explicitly now.
- Stale features/company/* paths in rewrite-status.md's build-order
table, left behind by the features/shared/company move.
- Comments narrating port history or test intent rewritten to state
the rejected alternative and the constraint (ADR 0043) in
ItemSelect, SaveFailedBadge, CreateCompanyModal, CompanyLookup;
two flagged sites (the E2E-repair-loop comment, the sonner test
comment) were already compliant and left as-is.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
PR #54 (main) widened pyproject.toml's django specifier to
>=6.0,<6.2 but never regenerated the matching uv.lock, which still
recorded <6.1 in its requires-dist metadata. Every `uv run` this
session silently self-corrected the lock locally; committing that
fix rather than leaving a lockfile permanently out of sync with the
manifest it's supposed to pin.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
frontend/test-results/ was covered; run_e2e.sh's Playwright
invocation also writes artifacts relative to the repo root in some
invocations, leaving an untracked test-results/ dangling after every
E2E run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
…config
playwright.config.ts resolved .env.test, testDir, outputDir and the
html reporter's folder relative to process.cwd(). Any invocation
whose cwd wasn't frontend/ (npm --prefix from the repo root, a bare
npx playwright test) silently missed .env.test -- dropping
E2E_TEST_USERNAME/PASSWORD with no error -- or wrote artifacts to
the wrong location instead of erroring loudly, which is how a
root-level test-results/ kept reappearing. Anchored every one of
these to import.meta.dirname instead: verified from an unrelated
cwd, .env/.env.test still resolve correctly and no stray directory
gets created anywhere. The root-level gitignore entry from the
previous commit stays as a defensive backstop, but this is the real
fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
@corrin
corrin merged commit f7a8080 into mainAug 11, 2026
3 checks passed
@corrin
corrin deleted the purchasing-po branch August 11, 2026 02:01
Sign up for freeto 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.

1 participant

@corrin
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Purchasing: PO create/list/detail, stock page (29/40 specs) - #65

Merged
corrin merged 14 commits into
mainfrom
purchasing-po
Aug 11, 2026
Merged

Purchasing: PO create/list/detail, stock page (29/40 specs)#65
corrin merged 14 commits into
mainfrom
purchasing-po

Conversation

@corrin

@corrincorrin commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Summary

Ports three of the five purchasing-cluster E2E specs (po-created-by, create-purchase-order, stock-search) with a full purchasing UI: PO list/create/detail pages, an editable PO-lines grid, and a stock page. supplier-alias-search and pickup-address (the two remaining, Google-Places-dependent specs) are next.

Backend was already ported in a prior slice; this is frontend-only.

Architecture work beyond the target specs

Three rounds of adversarial review found this slice was about to leave (or was leaving) real structural debt behind, and each was fixed before merge rather than deferred:

  • features/shared/DataTable.tsx — the one owner of the editable-grid E2E contract (DataTable-row-N, data-grid-*). The new PoLinesTable would otherwise have been a third hand-rolled copy alongside SmartTimesheetTable and CostLineGrid; all three now render through it.
  • features/shared/QueryState.tsx + features/shared/ListTable.tsx — the one owner of the pending/error gate every query-backed page or panel repeats, and the plain-rows-table shell layered over it. PoListPage/StockPage had copied CompaniesListPage's loading/error/retry block verbatim; an exhaustive sweep (three passes, each catching what the last missed) found and converted every genuine instance across the frontend — PoDetailPage, CostLineGrid, JobMovementReportPage, CompanyDetailPage, JobFinishTab, DailyOverviewPage, TimesheetEntryPage. A few sites stay deliberately unconverted (embedded card widgets with richer branching than binary success/fail; one guard-clause-shaped early return matching CLAUDE.md's own preference) — reasoning recorded in rewrite-status.md.
  • features/companyfeatures/shared/company — it had no route of its own and was already cross-imported by job; this slice's PoSummaryCard would have been a third cross-domain import of what was really a shared widget library sitting in a domain-shaped directory.
  • Bug fix: PoLinesTable's item-picker label had no description fallback, so a bound stock item with a null item_code (nullable, v1 parity) misread as unbound.

ADR 0039 was strengthened with the underlying principle: architectural unification is never deferred to a later slice, and shared concepts get shared homes.

Also (unrelated to purchasing, fixed in the same branch at the user's direction): docs/rewrite-status.md and docs/cutover-checklist.md corrected — the SSE/live-updates work (Slice 3) was misfiled as a post-cutover deferral and is actually MUST-before-cutover, and the release-gate section now states both go/no-go criteria (functional parity, architecture quality) rather than only the E2E proxy for the first.

Test plan

  • npm run type-check clean
  • npx vitest run src/features — 197/197 passing
  • Cheap + expensive pre-commit gate tiers green
  • ./scripts/ops/run_e2e.sh (full 88-case suite) green
  • Three rounds of adversarial subagent review, all findings resolved

🤖 Generated with Claude Code

https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L

Summary by CodeRabbit

  • New Features
    • Added purchasing pages for stock browsing and purchase-order creation, listing, and details.
    • Added supplier and job search, purchase-order line editing, autosave, status updates, and draft-line handling.
    • Added company and contact lookup, creation, selection, and editing workflows.
    • Added debounced stock and company searches with cached results when searches are cleared.
  • Bug Fixes
    • Improved loading, error, retry, and save-failure feedback across key screens.
    • Added safer purchase-order updates when concurrent changes occur.
  • Tests
    • Expanded automated coverage for purchasing, shared controls, search, accessibility, and end-to-end workflows.

corrinand others added 10 commits August 10, 2026 22:28
PoCreatePage (CompanyLookup + reference + save -> 201 redirect),
PoListPage, PoDetailPage with PoSummaryCard (created-by input,
reference autosave, status select), usePoLines over the single PATCH
endpoint, and the missing 'po' concurrency invalidator so 412/428
recovery actually refetches. Ports createTestPurchaseOrder +
waitForPoAutosave and the po-created-by spec.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
PoLinesTable as the third grid on the useReactTable + useDraftRows
pattern (phantom row, no add-line button, row-exit draft commit —
unit-cost stays the row's last focusable cell so the spec's Tab exits
the row). ItemSelect generalised for stock-only consumers (optional
jobId/line, label + wrapper overrides; labour-rates query gated on
jobId presence only, since textOnly labels need rate names). Inline
JobSelect over purchasing_all_jobs_retrieve — the unfiltered endpoint
v1's PO page uses, because fresh jobs are draft and the filtered
sibling excludes them. Spec ported with the autosave waiter armed
before the pick/status clicks (v2 saves immediately; v1's debounce is
what made arm-after work).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Spec-lean StockPage: full active-stock list on load, 300ms-debounced
server FTS from 3 characters, enabled-gated so clearing the box
renders the cached list with no /search/ request.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
…ports
The purchasing slice's PoLinesTable was about to become a third
hand-rolled grid emitting the DataTable-row-N/data-grid-* contract
inline. features/shared/DataTable.tsx is now the one owner of that
contract; SmartTimesheetTable, CostLineGrid and PoLinesTable all
render through it.
Auditing further found the same pathology in the plain list pages:
PoListPage and StockPage (both new this slice) had copied
CompaniesListPage's table shell and loading/error/retry block
verbatim, taking an existing duplicate (also in WipReportPage) from
2 instances to 4. features/shared/ListTable.tsx is the one owner of
that block instead -- deliberately separate from DataTable, since it
has no react-table dependency and forcing static lists through
column-def machinery would be indirection, not rigor. A hand-rolled
debounce-into-query-state pattern in CompaniesListPage and StockPage
is now features/shared/useDebouncedValue.ts (KanbanSearchInput keeps
its own URL-driven debounce -- a different concept).
ItemSelect, the decimal helpers and the Save-failed badge move to
features/shared/ because purchasing consumed them cross-domain -- a
domain feature is not a library.
ADR 0039 strengthened: unification is never deferred, shared concepts
get shared homes, the bar is reference quality.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Adversarial review on the branch found the ListTable audit had stopped
short: JobMovementReportPage and CompanyDetailPage hand-rolled the same
loading/error/retry block ListTable was built to own (6 real instances,
only 4 fixed). Split the block itself out as
features/shared/QueryState.tsx -- the pending/error gate alone, no
table -- so it fits pages that show something other than a table too.
ListTable now composes QueryState instead of duplicating it. PoDetailPage,
CostLineGrid, JobMovementReportPage and CompanyDetailPage all render
through it.
Fixed: PoLinesTable's item-picker label read `item_code ?? 'Select Item'`
with no description fallback, so a bound stock item with a null code
(nullable, v1 parity) misread as unbound. Now poLineItemLabel() in
lines.ts, unit-tested.
Moved features/company to features/shared/company: it was already
cross-imported by features/job (JobCreatePage, JobSettingsTab) before
this slice added a third importer (purchasing/PoSummaryCard) -- it never
had a route of its own, a shared widget library in a domain-shaped box.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Reversed 2026-08-11. The 2026-08-10 record said SSE ships with the
production-serving decision, filed under "Post-cutover -- decided,
deliberately NOT before 15 August." That was overturned: racing bad
architecture into production defeats the point of the rewrite
(non-negotiable #3 in the Cutover section), and the interim polling
shape plus the un-runnable apps/xero/sync_stream.py view are exactly
that. Slice 3 -- live updates done properly (serving model fix + SSE
ticker + discard the interim shortcuts) moves to its own MUST-tier
section with a milestone checkbox, and every stale "deferred
post-cutover" cross-reference in the file is corrected. The
purchasing-slice PR is unaffected -- it is unrelated and merges as
planned; this correction only fixes the durable record for the next
session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
QueryState grew optional loadingNode/errorNode override props so a
spinner-based caller keeps its visual shell instead of losing it to
the plain-text default. JobFinishTab, DailyOverviewPage, and both
gates in TimesheetEntryPage's EntryWorkspace now render through it.
XeroQuoteCard, JobInvoiceCard and JobSettingsTab's pay-item field stay
excluded -- richer branching than binary success/fail, not the
page-level gate QueryState owns. TimesheetEntryPage's own outer gate
stays as guard-clause `if` returns, matching CLAUDE.md's stated
preference rather than converting to a shape that would abandon it.
Also: rewrite-status.md's narration of "review round found X, we
fixed Y" replaced with what the outcomes actually are -- which shared
component owns which contract, which sites are deliberately excluded
and why, which constraints a future change must respect. And the
Cutover section now states the actual two-question go/no-go criteria
(functional parity, proxied by MUST-tier E2E; materially better
architecture, judged directly) rather than only the E2E proxy, with
the honest fallback (abort and stay on v1) stated explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
@coderabbitai

coderabbitaiBot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@corrin, you've reached your PR review limit, so we couldn't start this review.

Next review available in:12 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 756f5951-a815-47ee-9870-21c9be6081d2

📥 Commits

Reviewing files that changed from the base of the PR and between a3451f2 and ac028e2.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (14)
  • .gitignore
  • docs/rewrite-status.md
  • frontend/playwright.config.ts
  • frontend/src/features/crm/CompaniesListPage.tsx
  • frontend/src/features/job/JobFinishTab.tsx
  • frontend/src/features/purchasing/JobSelect.test.tsx
  • frontend/src/features/purchasing/JobSelect.tsx
  • frontend/src/features/purchasing/PoListPage.tsx
  • frontend/src/features/shared/ItemSelect.tsx
  • frontend/src/features/shared/SaveFailedBadge.tsx
  • frontend/src/features/shared/company/CompanyLookup.tsx
  • frontend/src/features/shared/company/CreateCompanyModal.tsx
  • frontend/src/features/shared/decimal.test.ts
  • frontend/src/features/shared/decimal.ts
📝 Walkthrough

Walkthrough

The PR adds authenticated purchasing pages for stock and purchase orders, including editable lines, autosave, optimistic updates, and E2E coverage. It also extracts shared query, table, company, decimal, and debounce components and updates architecture and cutover documentation.

Changes

Purchasing frontend

Layer / File(s)Summary
Purchasing contracts and persistence
frontend/src/api/..., frontend/src/features/purchasing/JobSelect.tsx, frontend/src/features/purchasing/lines.ts, frontend/src/features/purchasing/usePoLines.ts
Adds purchasing API exports, job filtering, PO draft utilities, optimistic mutations, concurrency invalidation, and refetch reconciliation.
Purchasing pages and routing
frontend/src/features/purchasing/..., frontend/src/routes/_authed/purchasing/..., frontend/src/routeTree.gen.ts
Adds PO list, create, detail, editable line, summary, and stock-search pages with authenticated routes.
Purchasing validation
frontend/tests/e2e/purchasing/..., frontend/tests/e2e/helpers.ts, frontend/src/features/purchasing/*.test.ts
Adds unit and E2E coverage for PO creation, autosave, status updates, created-by values, job filtering, draft serialization, and stock search.

Shared frontend consolidation

Layer / File(s)Summary
Shared query and grid infrastructure
frontend/src/features/shared/..., frontend/src/features/crm/..., frontend/src/features/job/..., frontend/src/features/reports/..., frontend/src/features/timesheet/...
Adds QueryState, ListTable, DataTable, SaveFailedBadge, decimal utilities, and useDebouncedValue, then adopts them across existing pages.
Shared company and person features
frontend/src/features/shared/company/..., frontend/src/features/job/JobCreatePage.tsx, frontend/src/features/job/JobSettingsTab.tsx
Adds shared company lookup, creation, Xero validation, person selection, and person management components, and updates consumers to use the shared module.

Architecture and cutover documentation

Layer / File(s)Summary
Architecture and release rules
docs/adr/0039-one-implementation-per-concept.md, docs/cutover-checklist.md, docs/rewrite-status.md, docs/code-quality.md
Documents immediate implementation unification, independent functionality and architecture gates, threaded serving requirements, purchasing progress, and updated suppression counts.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 54.24% 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 nameStatusExplanation
Title check✅ PassedThe title clearly identifies the purchasing pages and spec progress covered by the pull request.
Description check✅ PassedThe description explains the scope, architectural changes, remaining work, and validation results in sufficient detail.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch purchasing-po

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.

@coderabbitaicoderabbitaiBot 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: 10

🧹 Nitpick comments (11)
docs/cutover-checklist.md (1)

115-123: 🩺 Stability & Availability | 🔵 Trivial

Define the ASGI acceptance criteria.

or the ASGI equivalent is not an executable serving requirement. Define the minimum concurrency, timeout, keep-alive, database-connection, and ORM-safety requirements. Link this checkbox to the deployment configuration that enforces them.

🤖 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 `@docs/cutover-checklist.md` around lines 115 - 123, Update the serving-model
checklist item to define executable ASGI acceptance criteria: minimum
concurrency, request timeout, keep-alive, database-connection capacity, and
ORM-safety requirements. Link the checkbox to the deployment configuration or
command that enforces these settings, while retaining the existing WSGI option
and cutover context.
frontend/src/features/purchasing/StockPage.tsx (2)

25-31: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Send the trimmed query.

searchActive is computed from query.trim(), but the request sends the untrimmed query. For an input of " abc" the gate opens and q is " abc". Each whitespace variation also produces a distinct query key, which adds duplicate cache entries and duplicate requests for the same search term.

♻️ Proposed fix
- const searchActive = query.trim().length >= MIN_QUERY_LENGTH+ const trimmedQuery = query.trim()+ const searchActive = trimmedQuery.length >= MIN_QUERY_LENGTH
const list = useQuery(purchasingStockListOptions())
const search = useQuery({
- ...purchasingStockSearchRetrieveOptions({ query: { q: query } }),+ ...purchasingStockSearchRetrieveOptions({ query: { q: trimmedQuery } }),
enabled: searchActive,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 25 - 31, Update
the search query construction near searchActive in StockPage so the request and
its query key use the trimmed query value, while preserving the existing
minimum-length gating behavior.

27-33: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Preserve the previous search results while fetching a new query.

Each active search term creates a new query key. search.isPending then replaces the table with Loading stock items.... Use placeholderData: keepPreviousData to keep the current rows visible during the fetch.

♻️ Proposed refactor
-import { useQuery } from '`@tanstack/react-query`'+import { keepPreviousData, useQuery } from '`@tanstack/react-query`'
 const search = useQuery({
...purchasingStockSearchRetrieveOptions({ query: { q: query } }),
enabled: searchActive,
+ placeholderData: keepPreviousData,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 27 - 33, Update
the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
frontend/tests/e2e/helpers.ts (1)

444-446: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace Math.random with a collision-free identifier.

Math.floor(Math.random() * 100000) gives 100,000 possible values. Parallel Playwright workers and repeated local runs can produce the same supplier name. A duplicate [TEST] Supplier N creates a second live Xero contact push with the same name, which makes later lookups ambiguous.

Use crypto.randomUUID() or combine the timestamp with the worker index.

♻️ Proposed change
- const randomSuffix = Math.floor(Math.random() * 100000)- const supplierName = `[TEST] Supplier ${randomSuffix}`+ const randomSuffix = crypto.randomUUID().slice(0, 8)+ const supplierName = `[TEST] Supplier ${randomSuffix}`
🤖 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 `@frontend/tests/e2e/helpers.ts` around lines 444 - 446, Update
createTestPurchaseOrder so supplierName uses a collision-free identifier instead
of Math.random(), preferably crypto.randomUUID() or a timestamp combined with
the Playwright worker index, while preserving the existing “[TEST] Supplier”
naming format.
frontend/tests/e2e/purchasing/stock-search.spec.ts (1)

117-134: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The counter filter can miss a regression.

The listener ignores every /search/ response whose URL contains q=5mm. The exclusion exists to skip the first search, but it also hides the most likely regression: clearing the input re-fires the same q=5mm request. That request is a real post-clear search call, and the assertion at Line 134 still passes.

Gate on time instead of on the query value.

♻️ Proposed change
- // Track whether any further /search/ request fires when we clear the box.+ // Gate on the clear action, not on the query value: a re-fired `q=5mm`+ // request after clearing is still a post-clear search call.
let postClearSearchCalls = 0
+ let cleared = false
page.on('response', (response) => {
- if (- response.url().includes('/api/purchasing/stock/search/') &&- !response.url().includes('q=5mm')- ) {+ if (cleared && response.url().includes('/api/purchasing/stock/search/')) {
postClearSearchCalls += 1
}
})
+ cleared = true
await input.fill('')
🤖 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 `@frontend/tests/e2e/purchasing/stock-search.spec.ts` around lines 117 - 134,
Update the response listener in the stock-search test to count search responses
based on whether they occur after the input is cleared, rather than excluding
URLs containing q=5mm. Start tracking post-clear responses only immediately
before input.fill(''), so the initial search is ignored while any repeated q=5mm
request after clearing is counted and fails the existing assertion.
frontend/src/features/shared/ListTable.tsx (1)

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

Document that renderRow must supply the React key.

Line 66 calls rows.map(renderRow) and applies no key. Every current caller sets key on its own <tr>. That contract is not stated on the prop, so a future caller can omit the key and cause a React key warning plus incorrect row reconciliation.

♻️ Proposed doc addition
+ /** Must set a stable React `key` on the returned row element; ListTable+ maps rows directly and applies no key of its own. */
renderRow: (row: TRow) => ReactNode

Also applies to: 66-66

🤖 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 `@frontend/src/features/shared/ListTable.tsx` at line 21, Document the
`renderRow` prop contract in `ListTable`: implementations must return each row
with its own stable React `key`, since the `rows.map(renderRow)` call does not
apply one. Add this requirement to the prop’s existing documentation without
changing the rendering logic.
frontend/src/features/shared/company/PersonSelectionModal.test.tsx (1)

19-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering the create and update flows.

The single test covers accessibility only. handleCreate and handleUpdate hold the business rules: first person is always primary, blank optional fields are omitted from the request body, and an invalid email blocks submission. These rules are unit-testable with mocked mutations.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx` around
lines 19 - 48, Extend the PersonSelectionModal tests beyond accessibility to
cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
frontend/src/features/shared/company/PersonSelectionModal.tsx (3)

294-303: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The person card is a clickable div.

The card carries onClick but no role, tabIndex, or key handler. Keyboard users reach the person through the hover-revealed Select button, which group-focus-within exposes, so the flow is not blocked. Removing the card-level onClick and relying on the explicit Select button would remove the duplicated activation path and the event.stopPropagation() calls at Lines 341, 354, and 367.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
294 - 303, The person card in the person-selection rendering should no longer be
clickable via its container. Remove the card-level onClick handler from the div
around person.person_id, rely on the explicit Select button for activation, and
remove the associated event.stopPropagation() calls in the button handlers.

218-220: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse invalidatePeople here.

Lines 218-220 repeat the query-key invalidation that invalidatePeople defines at Lines 118-121. handleUpdate and handleConfirmDelete already call the helper. A future key change would need two edits.

♻️ Proposed fix
- await queryClient.invalidateQueries({- queryKey: companiesPeopleListQueryKey({ path: { company_id: companyId } }),- })+ await invalidatePeople()
As per coding guidelines: "Use one implementation per concept; search before implementing and extend a near-match instead of creating a parallel implementation."
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
218 - 220, Replace the direct companies-people query invalidation in the
relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.

Source: Coding guidelines


534-542: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Handle the returned promise on the submit button.

handleUpdate and handleCreate are async. Passing them directly to onClick returns a floating promise from the event handler. The delete button at Line 272 already wraps its call with void. Use the same form here for consistency and to satisfy @typescript-eslint/no-misused-promises if that rule is enabled.

♻️ Proposed fix
- onClick={editingPerson ? handleUpdate : handleCreate}+ onClick={() => {+ void (editingPerson ? handleUpdate() : handleCreate())+ }}
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
534 - 542, Update the submit button’s onClick handler to explicitly discard the
promise returned by handleUpdate or handleCreate, matching the existing
delete-button pattern. Preserve the editingPerson conditional selection and
button behavior.
frontend/src/features/shared/useDebouncedValue.test.tsx (1)

39-46: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the fake-timer queue on unmount.

clearTimeout is global, so another cleanup can satisfy toHaveBeenCalled(). Since this suite enables fake timers, assert that vi.getTimerCount() is greater than zero after renderHook, then zero after unmount().

🤖 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 `@frontend/src/features/shared/useDebouncedValue.test.tsx` around lines 39 -
46, Update the “clears a pending timer on unmount” test to assert the fake-timer
queue directly: verify vi.getTimerCount() is greater than zero after renderHook
creates the debounced timer, then verify it is zero after unmount(). Remove the
clearTimeout spy assertion.
🤖 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 `@docs/rewrite-status.md`:
- Around line 57-61: Update the frontend rebuild table in the rewrite-status
document to replace references to features/company/PersonSelectionModal.tsx and
features/company/CompanyLookup.tsx with their canonical features/shared/company/
paths, leaving other entries unchanged.
In `@frontend/src/features/crm/CompaniesListPage.tsx`:
- Around line 113-119: Apply the first-load-only error rule at both QueryState
call sites: in frontend/src/features/crm/CompaniesListPage.tsx lines 113-119,
change the ListTable isError value to companies.isError && companies.data ===
undefined; in frontend/src/features/job/JobFinishTab.tsx lines 209-213, use a
loadError derived per query from isError && data === undefined so cached summary
and checklist content remains visible during failed refetches.
In `@frontend/src/features/purchasing/JobSelect.tsx`:
- Around line 113-150: Update JobSelect to support keyboard navigation by
tracking an active option index, moving it with ArrowUp/ArrowDown, and selecting
the active job on Enter. Add role="listbox" to the dropdown and role="option"
with aria-selected to each mapped job row, making options keyboard-accessible
and reusing the shared picker pattern if available.
- Around line 91-111: Update the closing paths in JobSelect’s onBlur timeout and
Escape handler to reset editing and clear the stale search term when the picker
closes without selection, so value returns to the bound job and filtering does
not reuse old input. Store the 150ms blur timeout in a ref and add a useEffect
cleanup that clears it on unmount, while preserving the existing select
behavior.
In `@frontend/src/features/purchasing/PoListPage.tsx`:
- Around line 57-74: Update the purchase-order row in PoListPage’s renderRow
callback to be keyboard accessible: make the tr focusable, expose it as an
interactive control, and handle keyboard activation by navigating to the same
po/$poId destination as onClick. Preserve the existing mouse navigation
behavior.
In `@frontend/src/features/shared/company/CompanyLookup.tsx`:
- Around line 238-242: Update the suggestions empty-state rendering near
companiesSearchRetrieveOptions to check search.isError first and display an
error state when the request fails. Gate the existing “No companies found”
message on a successful, non-error search so rejected requests never appear as
empty results.
- Around line 88-99: Associate quickCreateCompany with an active request token
and call handleCompanyCreated only when the response still matches the current
lookup interaction; otherwise ignore the completion. In
frontend/src/features/shared/company/CompanyLookup.tsx lines 88-99, update
quickCreateCompany accordingly. In
frontend/src/features/shared/company/CreateCompanyModal.tsx lines 49-64, either
prevent dismissal while creation is pending or ignore completion after the modal
closes. Add regression tests covering abandoned quick-create and modal-creation
interactions.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 242-281: Update the form footer submit state in the component’s
submitDisabled logic to also disable submission whenever deleteTarget is
non-null or removeLink.isPending is true. Keep the existing confirmation overlay
behavior and ensure create/edit actions cannot start while deletion is pending
or awaiting confirmation.
In `@frontend/src/features/shared/decimal.ts`:
- Around line 10-16: Update parseDecimalInput to validate cleaned input against
an explicit fixed-point decimal syntax before numeric finiteness checks,
rejecting hex, octal, binary, and other non-decimal forms. Normalize accepted
values such as “.5”, “+5”, and “5.” into the backend-supported decimal
representation, and ensure trimDecimal cannot return invalid syntax verbatim.
In `@frontend/src/features/shared/ItemSelect.tsx`:
- Around line 19-43: Rewrite or remove the comments at
frontend/src/features/shared/ItemSelect.tsx:19-43, replacing implementation,
E2E, and history narration with the rejected API alternative and factual
constraint, or remove them. Apply the same constraint-based approach at
frontend/src/features/shared/SaveFailedBadge.tsx:1-4,
frontend/src/features/shared/decimal.test.ts:33-34 (including the rejected
formatting behavior and exponent constraint),
frontend/src/features/shared/company/CompanyLookup.test.tsx:154-155 (including
the Sonner rendering constraint if retained),
frontend/src/features/shared/company/CompanyLookup.tsx:88-90, and
frontend/src/features/shared/company/CreateCompanyModal.tsx:16-26 and :39
(document only rejected alternatives and current product/state constraints,
otherwise remove the comments).
---
Nitpick comments:
In `@docs/cutover-checklist.md`:
- Around line 115-123: Update the serving-model checklist item to define
executable ASGI acceptance criteria: minimum concurrency, request timeout,
keep-alive, database-connection capacity, and ORM-safety requirements. Link the
checkbox to the deployment configuration or command that enforces these
settings, while retaining the existing WSGI option and cutover context.
In `@frontend/src/features/purchasing/StockPage.tsx`:
- Around line 25-31: Update the search query construction near searchActive in
StockPage so the request and its query key use the trimmed query value, while
preserving the existing minimum-length gating behavior.
- Around line 27-33: Update the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
In `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx`:
- Around line 19-48: Extend the PersonSelectionModal tests beyond accessibility
to cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 294-303: The person card in the person-selection rendering should
no longer be clickable via its container. Remove the card-level onClick handler
from the div around person.person_id, rely on the explicit Select button for
activation, and remove the associated event.stopPropagation() calls in the
button handlers.
- Around line 218-220: Replace the direct companies-people query invalidation in
the relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.
- Around line 534-542: Update the submit button’s onClick handler to explicitly
discard the promise returned by handleUpdate or handleCreate, matching the
existing delete-button pattern. Preserve the editingPerson conditional selection
and button behavior.
In `@frontend/src/features/shared/ListTable.tsx`:
- Line 21: Document the `renderRow` prop contract in `ListTable`:
implementations must return each row with its own stable React `key`, since the
`rows.map(renderRow)` call does not apply one. Add this requirement to the
prop’s existing documentation without changing the rendering logic.
In `@frontend/src/features/shared/useDebouncedValue.test.tsx`:
- Around line 39-46: Update the “clears a pending timer on unmount” test to
assert the fake-timer queue directly: verify vi.getTimerCount() is greater than
zero after renderHook creates the debounced timer, then verify it is zero after
unmount(). Remove the clearTimeout spy assertion.
In `@frontend/tests/e2e/helpers.ts`:
- Around line 444-446: Update createTestPurchaseOrder so supplierName uses a
collision-free identifier instead of Math.random(), preferably
crypto.randomUUID() or a timestamp combined with the Playwright worker index,
while preserving the existing “[TEST] Supplier” naming format.
In `@frontend/tests/e2e/purchasing/stock-search.spec.ts`:
- Around line 117-134: Update the response listener in the stock-search test to
count search responses based on whether they occur after the input is cleared,
rather than excluding URLs containing q=5mm. Start tracking post-clear responses
only immediately before input.fill(''), so the initial search is ignored while
any repeated q=5mm request after clearing is counted and fails the existing
assertion.
🪄 Autofix

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 Plus

Run ID: 66ca2b61-202f-4ca9-a415-b773f6995a0a

📥 Commits

Reviewing files that changed from the base of the PR and between 3030b60 and a3451f2.

📒 Files selected for processing (60)
  • docs/adr/0039-one-implementation-per-concept.md
  • docs/code-quality.md
  • docs/cutover-checklist.md
  • docs/rewrite-status.md
  • frontend/src/api/index.ts
  • frontend/src/api/query-client.ts
  • frontend/src/features/crm/CompaniesListPage.tsx
  • frontend/src/features/crm/CompanyDetailPage.tsx
  • frontend/src/features/job/JobCreatePage.tsx
  • frontend/src/features/job/JobFinishTab.tsx
  • frontend/src/features/job/JobSettingsTab.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/purchasing/JobSelect.test.ts
  • frontend/src/features/purchasing/JobSelect.tsx
  • frontend/src/features/purchasing/PoCreatePage.tsx
  • frontend/src/features/purchasing/PoDetailPage.tsx
  • frontend/src/features/purchasing/PoLinesTable.tsx
  • frontend/src/features/purchasing/PoListPage.tsx
  • frontend/src/features/purchasing/PoSummaryCard.tsx
  • frontend/src/features/purchasing/StockPage.tsx
  • frontend/src/features/purchasing/index.ts
  • frontend/src/features/purchasing/lines.test.ts
  • frontend/src/features/purchasing/lines.ts
  • frontend/src/features/purchasing/usePoLines.ts
  • frontend/src/features/reports/JobMovementReportPage.tsx
  • frontend/src/features/reports/WipReportPage.tsx
  • frontend/src/features/shared/DataTable.tsx
  • frontend/src/features/shared/ItemSelect.tsx
  • frontend/src/features/shared/ListTable.test.tsx
  • frontend/src/features/shared/ListTable.tsx
  • frontend/src/features/shared/QueryState.test.tsx
  • frontend/src/features/shared/QueryState.tsx
  • frontend/src/features/shared/SaveFailedBadge.tsx
  • frontend/src/features/shared/company/CompanyLookup.test.tsx
  • frontend/src/features/shared/company/CompanyLookup.tsx
  • frontend/src/features/shared/company/CreateCompanyModal.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.test.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.tsx
  • frontend/src/features/shared/company/PersonSelector.tsx
  • frontend/src/features/shared/company/create-company.ts
  • frontend/src/features/shared/company/index.ts
  • frontend/src/features/shared/company/xero-contact.ts
  • frontend/src/features/shared/decimal.test.ts
  • frontend/src/features/shared/decimal.ts
  • frontend/src/features/shared/useDebouncedValue.test.tsx
  • frontend/src/features/shared/useDebouncedValue.ts
  • frontend/src/features/timesheet/DailyOverviewPage.tsx
  • frontend/src/features/timesheet/SmartTimesheetTable.tsx
  • frontend/src/features/timesheet/TimesheetEntryPage.tsx
  • frontend/src/routeTree.gen.ts
  • frontend/src/routes/_authed/purchasing/po/$poId.tsx
  • frontend/src/routes/_authed/purchasing/po/create.tsx
  • frontend/src/routes/_authed/purchasing/po/index.tsx
  • frontend/src/routes/_authed/purchasing/stock.tsx
  • frontend/tests/e2e/helpers.ts
  • frontend/tests/e2e/purchasing/create-purchase-order.spec.ts
  • frontend/tests/e2e/purchasing/po-created-by.spec.ts
  • frontend/tests/e2e/purchasing/stock-search.spec.ts
💤 Files with no reviewable changes (2)
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/calc.test.ts

Comment threaddocs/rewrite-status.md
Comment threadfrontend/src/features/crm/CompaniesListPage.tsx
Comment threadfrontend/src/features/purchasing/JobSelect.tsx
Comment threadfrontend/src/features/purchasing/JobSelect.tsx
Comment threadfrontend/src/features/purchasing/PoListPage.tsx
Comment threadfrontend/src/features/shared/decimal.ts
Comment threadfrontend/src/features/shared/ItemSelect.tsx

@coderabbitaicoderabbitaiBot 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.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 10

🧹 Nitpick comments (11)
docs/cutover-checklist.md (1)

115-123: 🩺 Stability & Availability | 🔵 Trivial

Define the ASGI acceptance criteria.

or the ASGI equivalent is not an executable serving requirement. Define the minimum concurrency, timeout, keep-alive, database-connection, and ORM-safety requirements. Link this checkbox to the deployment configuration that enforces them.

🤖 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 `@docs/cutover-checklist.md` around lines 115 - 123, Update the serving-model
checklist item to define executable ASGI acceptance criteria: minimum
concurrency, request timeout, keep-alive, database-connection capacity, and
ORM-safety requirements. Link the checkbox to the deployment configuration or
command that enforces these settings, while retaining the existing WSGI option
and cutover context.
frontend/src/features/purchasing/StockPage.tsx (2)

25-31: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Send the trimmed query.

searchActive is computed from query.trim(), but the request sends the untrimmed query. For an input of " abc" the gate opens and q is " abc". Each whitespace variation also produces a distinct query key, which adds duplicate cache entries and duplicate requests for the same search term.

♻️ Proposed fix
- const searchActive = query.trim().length >= MIN_QUERY_LENGTH+ const trimmedQuery = query.trim()+ const searchActive = trimmedQuery.length >= MIN_QUERY_LENGTH
const list = useQuery(purchasingStockListOptions())
const search = useQuery({
- ...purchasingStockSearchRetrieveOptions({ query: { q: query } }),+ ...purchasingStockSearchRetrieveOptions({ query: { q: trimmedQuery } }),
enabled: searchActive,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 25 - 31, Update
the search query construction near searchActive in StockPage so the request and
its query key use the trimmed query value, while preserving the existing
minimum-length gating behavior.

27-33: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Preserve the previous search results while fetching a new query.

Each active search term creates a new query key. search.isPending then replaces the table with Loading stock items.... Use placeholderData: keepPreviousData to keep the current rows visible during the fetch.

♻️ Proposed refactor
-import { useQuery } from '`@tanstack/react-query`'+import { keepPreviousData, useQuery } from '`@tanstack/react-query`'
 const search = useQuery({
...purchasingStockSearchRetrieveOptions({ query: { q: query } }),
enabled: searchActive,
+ placeholderData: keepPreviousData,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 27 - 33, Update
the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
frontend/tests/e2e/helpers.ts (1)

444-446: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace Math.random with a collision-free identifier.

Math.floor(Math.random() * 100000) gives 100,000 possible values. Parallel Playwright workers and repeated local runs can produce the same supplier name. A duplicate [TEST] Supplier N creates a second live Xero contact push with the same name, which makes later lookups ambiguous.

Use crypto.randomUUID() or combine the timestamp with the worker index.

♻️ Proposed change
- const randomSuffix = Math.floor(Math.random() * 100000)- const supplierName = `[TEST] Supplier ${randomSuffix}`+ const randomSuffix = crypto.randomUUID().slice(0, 8)+ const supplierName = `[TEST] Supplier ${randomSuffix}`
🤖 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 `@frontend/tests/e2e/helpers.ts` around lines 444 - 446, Update
createTestPurchaseOrder so supplierName uses a collision-free identifier instead
of Math.random(), preferably crypto.randomUUID() or a timestamp combined with
the Playwright worker index, while preserving the existing “[TEST] Supplier”
naming format.
frontend/tests/e2e/purchasing/stock-search.spec.ts (1)

117-134: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The counter filter can miss a regression.

The listener ignores every /search/ response whose URL contains q=5mm. The exclusion exists to skip the first search, but it also hides the most likely regression: clearing the input re-fires the same q=5mm request. That request is a real post-clear search call, and the assertion at Line 134 still passes.

Gate on time instead of on the query value.

♻️ Proposed change
- // Track whether any further /search/ request fires when we clear the box.+ // Gate on the clear action, not on the query value: a re-fired `q=5mm`+ // request after clearing is still a post-clear search call.
let postClearSearchCalls = 0
+ let cleared = false
page.on('response', (response) => {
- if (- response.url().includes('/api/purchasing/stock/search/') &&- !response.url().includes('q=5mm')- ) {+ if (cleared && response.url().includes('/api/purchasing/stock/search/')) {
postClearSearchCalls += 1
}
})
+ cleared = true
await input.fill('')
🤖 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 `@frontend/tests/e2e/purchasing/stock-search.spec.ts` around lines 117 - 134,
Update the response listener in the stock-search test to count search responses
based on whether they occur after the input is cleared, rather than excluding
URLs containing q=5mm. Start tracking post-clear responses only immediately
before input.fill(''), so the initial search is ignored while any repeated q=5mm
request after clearing is counted and fails the existing assertion.
frontend/src/features/shared/ListTable.tsx (1)

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

Document that renderRow must supply the React key.

Line 66 calls rows.map(renderRow) and applies no key. Every current caller sets key on its own <tr>. That contract is not stated on the prop, so a future caller can omit the key and cause a React key warning plus incorrect row reconciliation.

♻️ Proposed doc addition
+ /** Must set a stable React `key` on the returned row element; ListTable+ maps rows directly and applies no key of its own. */
renderRow: (row: TRow) => ReactNode

Also applies to: 66-66

🤖 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 `@frontend/src/features/shared/ListTable.tsx` at line 21, Document the
`renderRow` prop contract in `ListTable`: implementations must return each row
with its own stable React `key`, since the `rows.map(renderRow)` call does not
apply one. Add this requirement to the prop’s existing documentation without
changing the rendering logic.
frontend/src/features/shared/company/PersonSelectionModal.test.tsx (1)

19-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering the create and update flows.

The single test covers accessibility only. handleCreate and handleUpdate hold the business rules: first person is always primary, blank optional fields are omitted from the request body, and an invalid email blocks submission. These rules are unit-testable with mocked mutations.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx` around
lines 19 - 48, Extend the PersonSelectionModal tests beyond accessibility to
cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
frontend/src/features/shared/company/PersonSelectionModal.tsx (3)

294-303: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The person card is a clickable div.

The card carries onClick but no role, tabIndex, or key handler. Keyboard users reach the person through the hover-revealed Select button, which group-focus-within exposes, so the flow is not blocked. Removing the card-level onClick and relying on the explicit Select button would remove the duplicated activation path and the event.stopPropagation() calls at Lines 341, 354, and 367.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
294 - 303, The person card in the person-selection rendering should no longer be
clickable via its container. Remove the card-level onClick handler from the div
around person.person_id, rely on the explicit Select button for activation, and
remove the associated event.stopPropagation() calls in the button handlers.

218-220: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse invalidatePeople here.

Lines 218-220 repeat the query-key invalidation that invalidatePeople defines at Lines 118-121. handleUpdate and handleConfirmDelete already call the helper. A future key change would need two edits.

♻️ Proposed fix
- await queryClient.invalidateQueries({- queryKey: companiesPeopleListQueryKey({ path: { company_id: companyId } }),- })+ await invalidatePeople()
As per coding guidelines: "Use one implementation per concept; search before implementing and extend a near-match instead of creating a parallel implementation."
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
218 - 220, Replace the direct companies-people query invalidation in the
relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.

Source: Coding guidelines


534-542: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Handle the returned promise on the submit button.

handleUpdate and handleCreate are async. Passing them directly to onClick returns a floating promise from the event handler. The delete button at Line 272 already wraps its call with void. Use the same form here for consistency and to satisfy @typescript-eslint/no-misused-promises if that rule is enabled.

♻️ Proposed fix
- onClick={editingPerson ? handleUpdate : handleCreate}+ onClick={() => {+ void (editingPerson ? handleUpdate() : handleCreate())+ }}
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
534 - 542, Update the submit button’s onClick handler to explicitly discard the
promise returned by handleUpdate or handleCreate, matching the existing
delete-button pattern. Preserve the editingPerson conditional selection and
button behavior.
frontend/src/features/shared/useDebouncedValue.test.tsx (1)

39-46: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the fake-timer queue on unmount.

clearTimeout is global, so another cleanup can satisfy toHaveBeenCalled(). Since this suite enables fake timers, assert that vi.getTimerCount() is greater than zero after renderHook, then zero after unmount().

🤖 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 `@frontend/src/features/shared/useDebouncedValue.test.tsx` around lines 39 -
46, Update the “clears a pending timer on unmount” test to assert the fake-timer
queue directly: verify vi.getTimerCount() is greater than zero after renderHook
creates the debounced timer, then verify it is zero after unmount(). Remove the
clearTimeout spy assertion.
🤖 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 `@docs/rewrite-status.md`:
- Around line 57-61: Update the frontend rebuild table in the rewrite-status
document to replace references to features/company/PersonSelectionModal.tsx and
features/company/CompanyLookup.tsx with their canonical features/shared/company/
paths, leaving other entries unchanged.
In `@frontend/src/features/crm/CompaniesListPage.tsx`:
- Around line 113-119: Apply the first-load-only error rule at both QueryState
call sites: in frontend/src/features/crm/CompaniesListPage.tsx lines 113-119,
change the ListTable isError value to companies.isError && companies.data ===
undefined; in frontend/src/features/job/JobFinishTab.tsx lines 209-213, use a
loadError derived per query from isError && data === undefined so cached summary
and checklist content remains visible during failed refetches.
In `@frontend/src/features/purchasing/JobSelect.tsx`:
- Around line 113-150: Update JobSelect to support keyboard navigation by
tracking an active option index, moving it with ArrowUp/ArrowDown, and selecting
the active job on Enter. Add role="listbox" to the dropdown and role="option"
with aria-selected to each mapped job row, making options keyboard-accessible
and reusing the shared picker pattern if available.
- Around line 91-111: Update the closing paths in JobSelect’s onBlur timeout and
Escape handler to reset editing and clear the stale search term when the picker
closes without selection, so value returns to the bound job and filtering does
not reuse old input. Store the 150ms blur timeout in a ref and add a useEffect
cleanup that clears it on unmount, while preserving the existing select
behavior.
In `@frontend/src/features/purchasing/PoListPage.tsx`:
- Around line 57-74: Update the purchase-order row in PoListPage’s renderRow
callback to be keyboard accessible: make the tr focusable, expose it as an
interactive control, and handle keyboard activation by navigating to the same
po/$poId destination as onClick. Preserve the existing mouse navigation
behavior.
In `@frontend/src/features/shared/company/CompanyLookup.tsx`:
- Around line 238-242: Update the suggestions empty-state rendering near
companiesSearchRetrieveOptions to check search.isError first and display an
error state when the request fails. Gate the existing “No companies found”
message on a successful, non-error search so rejected requests never appear as
empty results.
- Around line 88-99: Associate quickCreateCompany with an active request token
and call handleCompanyCreated only when the response still matches the current
lookup interaction; otherwise ignore the completion. In
frontend/src/features/shared/company/CompanyLookup.tsx lines 88-99, update
quickCreateCompany accordingly. In
frontend/src/features/shared/company/CreateCompanyModal.tsx lines 49-64, either
prevent dismissal while creation is pending or ignore completion after the modal
closes. Add regression tests covering abandoned quick-create and modal-creation
interactions.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 242-281: Update the form footer submit state in the component’s
submitDisabled logic to also disable submission whenever deleteTarget is
non-null or removeLink.isPending is true. Keep the existing confirmation overlay
behavior and ensure create/edit actions cannot start while deletion is pending
or awaiting confirmation.
In `@frontend/src/features/shared/decimal.ts`:
- Around line 10-16: Update parseDecimalInput to validate cleaned input against
an explicit fixed-point decimal syntax before numeric finiteness checks,
rejecting hex, octal, binary, and other non-decimal forms. Normalize accepted
values such as “.5”, “+5”, and “5.” into the backend-supported decimal
representation, and ensure trimDecimal cannot return invalid syntax verbatim.
In `@frontend/src/features/shared/ItemSelect.tsx`:
- Around line 19-43: Rewrite or remove the comments at
frontend/src/features/shared/ItemSelect.tsx:19-43, replacing implementation,
E2E, and history narration with the rejected API alternative and factual
constraint, or remove them. Apply the same constraint-based approach at
frontend/src/features/shared/SaveFailedBadge.tsx:1-4,
frontend/src/features/shared/decimal.test.ts:33-34 (including the rejected
formatting behavior and exponent constraint),
frontend/src/features/shared/company/CompanyLookup.test.tsx:154-155 (including
the Sonner rendering constraint if retained),
frontend/src/features/shared/company/CompanyLookup.tsx:88-90, and
frontend/src/features/shared/company/CreateCompanyModal.tsx:16-26 and :39
(document only rejected alternatives and current product/state constraints,
otherwise remove the comments).
---
Nitpick comments:
In `@docs/cutover-checklist.md`:
- Around line 115-123: Update the serving-model checklist item to define
executable ASGI acceptance criteria: minimum concurrency, request timeout,
keep-alive, database-connection capacity, and ORM-safety requirements. Link the
checkbox to the deployment configuration or command that enforces these
settings, while retaining the existing WSGI option and cutover context.
In `@frontend/src/features/purchasing/StockPage.tsx`:
- Around line 25-31: Update the search query construction near searchActive in
StockPage so the request and its query key use the trimmed query value, while
preserving the existing minimum-length gating behavior.
- Around line 27-33: Update the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
In `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx`:
- Around line 19-48: Extend the PersonSelectionModal tests beyond accessibility
to cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 294-303: The person card in the person-selection rendering should
no longer be clickable via its container. Remove the card-level onClick handler
from the div around person.person_id, rely on the explicit Select button for
activation, and remove the associated event.stopPropagation() calls in the
button handlers.
- Around line 218-220: Replace the direct companies-people query invalidation in
the relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.
- Around line 534-542: Update the submit button’s onClick handler to explicitly
discard the promise returned by handleUpdate or handleCreate, matching the
existing delete-button pattern. Preserve the editingPerson conditional selection
and button behavior.
In `@frontend/src/features/shared/ListTable.tsx`:
- Line 21: Document the `renderRow` prop contract in `ListTable`:
implementations must return each row with its own stable React `key`, since the
`rows.map(renderRow)` call does not apply one. Add this requirement to the
prop’s existing documentation without changing the rendering logic.
In `@frontend/src/features/shared/useDebouncedValue.test.tsx`:
- Around line 39-46: Update the “clears a pending timer on unmount” test to
assert the fake-timer queue directly: verify vi.getTimerCount() is greater than
zero after renderHook creates the debounced timer, then verify it is zero after
unmount(). Remove the clearTimeout spy assertion.
In `@frontend/tests/e2e/helpers.ts`:
- Around line 444-446: Update createTestPurchaseOrder so supplierName uses a
collision-free identifier instead of Math.random(), preferably
crypto.randomUUID() or a timestamp combined with the Playwright worker index,
while preserving the existing “[TEST] Supplier” naming format.
In `@frontend/tests/e2e/purchasing/stock-search.spec.ts`:
- Around line 117-134: Update the response listener in the stock-search test to
count search responses based on whether they occur after the input is cleared,
rather than excluding URLs containing q=5mm. Start tracking post-clear responses
only immediately before input.fill(''), so the initial search is ignored while
any repeated q=5mm request after clearing is counted and fails the existing
assertion.
🪄 Autofix

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 Plus

Run ID: 66ca2b61-202f-4ca9-a415-b773f6995a0a

📥 Commits

Reviewing files that changed from the base of the PR and between 3030b60 and a3451f2.

📒 Files selected for processing (60)
  • docs/adr/0039-one-implementation-per-concept.md
  • docs/code-quality.md
  • docs/cutover-checklist.md
  • docs/rewrite-status.md
  • frontend/src/api/index.ts
  • frontend/src/api/query-client.ts
  • frontend/src/features/crm/CompaniesListPage.tsx
  • frontend/src/features/crm/CompanyDetailPage.tsx
  • frontend/src/features/job/JobCreatePage.tsx
  • frontend/src/features/job/JobFinishTab.tsx
  • frontend/src/features/job/JobSettingsTab.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/purchasing/JobSelect.test.ts
  • frontend/src/features/purchasing/JobSelect.tsx
  • frontend/src/features/purchasing/PoCreatePage.tsx
  • frontend/src/features/purchasing/PoDetailPage.tsx
  • frontend/src/features/purchasing/PoLinesTable.tsx
  • frontend/src/features/purchasing/PoListPage.tsx
  • frontend/src/features/purchasing/PoSummaryCard.tsx
  • frontend/src/features/purchasing/StockPage.tsx
  • frontend/src/features/purchasing/index.ts
  • frontend/src/features/purchasing/lines.test.ts
  • frontend/src/features/purchasing/lines.ts
  • frontend/src/features/purchasing/usePoLines.ts
  • frontend/src/features/reports/JobMovementReportPage.tsx
  • frontend/src/features/reports/WipReportPage.tsx
  • frontend/src/features/shared/DataTable.tsx
  • frontend/src/features/shared/ItemSelect.tsx
  • frontend/src/features/shared/ListTable.test.tsx
  • frontend/src/features/shared/ListTable.tsx
  • frontend/src/features/shared/QueryState.test.tsx
  • frontend/src/features/shared/QueryState.tsx
  • frontend/src/features/shared/SaveFailedBadge.tsx
  • frontend/src/features/shared/company/CompanyLookup.test.tsx
  • frontend/src/features/shared/company/CompanyLookup.tsx
  • frontend/src/features/shared/company/CreateCompanyModal.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.test.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.tsx
  • frontend/src/features/shared/company/PersonSelector.tsx
  • frontend/src/features/shared/company/create-company.ts
  • frontend/src/features/shared/company/index.ts
  • frontend/src/features/shared/company/xero-contact.ts
  • frontend/src/features/shared/decimal.test.ts
  • frontend/src/features/shared/decimal.ts
  • frontend/src/features/shared/useDebouncedValue.test.tsx
  • frontend/src/features/shared/useDebouncedValue.ts
  • frontend/src/features/timesheet/DailyOverviewPage.tsx
  • frontend/src/features/timesheet/SmartTimesheetTable.tsx
  • frontend/src/features/timesheet/TimesheetEntryPage.tsx
  • frontend/src/routeTree.gen.ts
  • frontend/src/routes/_authed/purchasing/po/$poId.tsx
  • frontend/src/routes/_authed/purchasing/po/create.tsx
  • frontend/src/routes/_authed/purchasing/po/index.tsx
  • frontend/src/routes/_authed/purchasing/stock.tsx
  • frontend/tests/e2e/helpers.ts
  • frontend/tests/e2e/purchasing/create-purchase-order.spec.ts
  • frontend/tests/e2e/purchasing/po-created-by.spec.ts
  • frontend/tests/e2e/purchasing/stock-search.spec.ts
💤 Files with no reviewable changes (2)
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/calc.test.ts
🛑 Comments failed to post (3)
frontend/src/features/shared/company/CompanyLookup.tsx (2)

88-99: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Ignore completion from abandoned company-creation interactions.

Both creation paths apply a completed mutation after the initiating UI state can change. A quick-create response can select an old query after the user continues searching. A modal response can select a company after the user closes the dialog.

  • frontend/src/features/shared/company/CompanyLookup.tsx#L88-L99: associate quick creation with an active request token, and select the returned company only if the request still matches the current interaction.
  • frontend/src/features/shared/company/CreateCompanyModal.tsx#L49-L64: prevent dismissal while creating or ignore a completion after the modal closes. Add regression tests for both paths.
📍 Affects 2 files
  • frontend/src/features/shared/company/CompanyLookup.tsx#L88-L99 (this comment)
  • frontend/src/features/shared/company/CreateCompanyModal.tsx#L49-L64
🤖 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 `@frontend/src/features/shared/company/CompanyLookup.tsx` around lines 88 - 99,
Associate quickCreateCompany with an active request token and call
handleCompanyCreated only when the response still matches the current lookup
interaction; otherwise ignore the completion. In
frontend/src/features/shared/company/CompanyLookup.tsx lines 88-99, update
quickCreateCompany accordingly. In
frontend/src/features/shared/company/CreateCompanyModal.tsx lines 49-64, either
prevent dismissal while creation is pending or ignore completion after the modal
closes. Add regression tests covering abandoned quick-create and modal-creation
interactions.

238-242: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Render a search failure state.

If companiesSearchRetrieveOptions rejects, search.isPending is false and this branch renders No companies found.. The user receives a false empty-result message. Render an error state when search.isError is true, and reserve the empty state for successful empty responses.

🤖 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 `@frontend/src/features/shared/company/CompanyLookup.tsx` around lines 238 -
242, Update the suggestions empty-state rendering near
companiesSearchRetrieveOptions to check search.isError first and display an
error state when the request fails. Gate the existing “No companies found”
message on a successful, non-error search so rejected requests never appear as
empty results.
frontend/src/features/shared/company/PersonSelectionModal.tsx (1)

242-281: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The delete overlay does not block the form column.

The confirmation panel is absolute inset-0 inside the people-list column only. The create/edit form and the submit button stay interactive while the confirmation is open. submitDisabled at Line 226 also ignores removeLink.isPending, so a user can start a create while a delete is in flight. Consider disabling the footer submit while deleteTarget !== null or removeLink.isPending.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
242 - 281, Update the form footer submit state in the component’s submitDisabled
logic to also disable submission whenever deleteTarget is non-null or
removeLink.isPending is true. Keep the existing confirmation overlay behavior
and ensure create/edit actions cannot start while deletion is pending or
awaiting confirmation.

corrinand others added 4 commits August 11, 2026 12:47
- CompaniesListPage/JobFinishTab: QueryState was passed the raw
isError, breaking the first-load-only rule the other converted
sites already follow -- a background refetch failure was
unmounting already-rendered content instead of leaving it on
screen.
- JobSelect: closing the picker without a selection (blur or
Escape) left `editing` true forever, so the input kept showing
the abandoned search term instead of reverting to the bound job;
the blur timer was untracked and could fire a state update after
unmount. Also added keyboard selection (arrow keys + Enter,
role=listbox/option, aria-activedescendant) -- the dropdown was
mouse-only.
- PoListPage: the row was reachable only by mouse click; added a
real Link on the PO number cell, matching CompaniesListPage's
existing pattern.
- decimal.ts: parseDecimalInput accepted anything Number() parses,
including hex/octal/binary literals and bare exponents, and sent
them to the wire verbatim as garbage Decimal strings. Validates
fixed-point syntax explicitly now.
- Stale features/company/* paths in rewrite-status.md's build-order
table, left behind by the features/shared/company move.
- Comments narrating port history or test intent rewritten to state
the rejected alternative and the constraint (ADR 0043) in
ItemSelect, SaveFailedBadge, CreateCompanyModal, CompanyLookup;
two flagged sites (the E2E-repair-loop comment, the sonner test
comment) were already compliant and left as-is.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
PR #54 (main) widened pyproject.toml's django specifier to
>=6.0,<6.2 but never regenerated the matching uv.lock, which still
recorded <6.1 in its requires-dist metadata. Every `uv run` this
session silently self-corrected the lock locally; committing that
fix rather than leaving a lockfile permanently out of sync with the
manifest it's supposed to pin.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
frontend/test-results/ was covered; run_e2e.sh's Playwright
invocation also writes artifacts relative to the repo root in some
invocations, leaving an untracked test-results/ dangling after every
E2E run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
…config
playwright.config.ts resolved .env.test, testDir, outputDir and the
html reporter's folder relative to process.cwd(). Any invocation
whose cwd wasn't frontend/ (npm --prefix from the repo root, a bare
npx playwright test) silently missed .env.test -- dropping
E2E_TEST_USERNAME/PASSWORD with no error -- or wrote artifacts to
the wrong location instead of erroring loudly, which is how a
root-level test-results/ kept reappearing. Anchored every one of
these to import.meta.dirname instead: verified from an unrelated
cwd, .env/.env.test still resolve correctly and no stray directory
gets created anywhere. The root-level gitignore entry from the
previous commit stays as a defensive backstop, but this is the real
fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
@corrin
corrin merged commit f7a8080 into mainAug 11, 2026
3 checks passed
@corrin
corrin deleted the purchasing-po branch August 11, 2026 02:01
Sign up for freeto 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.

1 participant

@corrin
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Purchasing: PO create/list/detail, stock page (29/40 specs) - #65

Merged
corrin merged 14 commits into
mainfrom
purchasing-po
Aug 11, 2026
Merged

Purchasing: PO create/list/detail, stock page (29/40 specs)#65
corrin merged 14 commits into
mainfrom
purchasing-po

Conversation

@corrin

@corrincorrin commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Summary

Ports three of the five purchasing-cluster E2E specs (po-created-by, create-purchase-order, stock-search) with a full purchasing UI: PO list/create/detail pages, an editable PO-lines grid, and a stock page. supplier-alias-search and pickup-address (the two remaining, Google-Places-dependent specs) are next.

Backend was already ported in a prior slice; this is frontend-only.

Architecture work beyond the target specs

Three rounds of adversarial review found this slice was about to leave (or was leaving) real structural debt behind, and each was fixed before merge rather than deferred:

  • features/shared/DataTable.tsx — the one owner of the editable-grid E2E contract (DataTable-row-N, data-grid-*). The new PoLinesTable would otherwise have been a third hand-rolled copy alongside SmartTimesheetTable and CostLineGrid; all three now render through it.
  • features/shared/QueryState.tsx + features/shared/ListTable.tsx — the one owner of the pending/error gate every query-backed page or panel repeats, and the plain-rows-table shell layered over it. PoListPage/StockPage had copied CompaniesListPage's loading/error/retry block verbatim; an exhaustive sweep (three passes, each catching what the last missed) found and converted every genuine instance across the frontend — PoDetailPage, CostLineGrid, JobMovementReportPage, CompanyDetailPage, JobFinishTab, DailyOverviewPage, TimesheetEntryPage. A few sites stay deliberately unconverted (embedded card widgets with richer branching than binary success/fail; one guard-clause-shaped early return matching CLAUDE.md's own preference) — reasoning recorded in rewrite-status.md.
  • features/companyfeatures/shared/company — it had no route of its own and was already cross-imported by job; this slice's PoSummaryCard would have been a third cross-domain import of what was really a shared widget library sitting in a domain-shaped directory.
  • Bug fix: PoLinesTable's item-picker label had no description fallback, so a bound stock item with a null item_code (nullable, v1 parity) misread as unbound.

ADR 0039 was strengthened with the underlying principle: architectural unification is never deferred to a later slice, and shared concepts get shared homes.

Also (unrelated to purchasing, fixed in the same branch at the user's direction): docs/rewrite-status.md and docs/cutover-checklist.md corrected — the SSE/live-updates work (Slice 3) was misfiled as a post-cutover deferral and is actually MUST-before-cutover, and the release-gate section now states both go/no-go criteria (functional parity, architecture quality) rather than only the E2E proxy for the first.

Test plan

  • npm run type-check clean
  • npx vitest run src/features — 197/197 passing
  • Cheap + expensive pre-commit gate tiers green
  • ./scripts/ops/run_e2e.sh (full 88-case suite) green
  • Three rounds of adversarial subagent review, all findings resolved

🤖 Generated with Claude Code

https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L

Summary by CodeRabbit

  • New Features
    • Added purchasing pages for stock browsing and purchase-order creation, listing, and details.
    • Added supplier and job search, purchase-order line editing, autosave, status updates, and draft-line handling.
    • Added company and contact lookup, creation, selection, and editing workflows.
    • Added debounced stock and company searches with cached results when searches are cleared.
  • Bug Fixes
    • Improved loading, error, retry, and save-failure feedback across key screens.
    • Added safer purchase-order updates when concurrent changes occur.
  • Tests
    • Expanded automated coverage for purchasing, shared controls, search, accessibility, and end-to-end workflows.

corrinand others added 10 commits August 10, 2026 22:28
PoCreatePage (CompanyLookup + reference + save -> 201 redirect),
PoListPage, PoDetailPage with PoSummaryCard (created-by input,
reference autosave, status select), usePoLines over the single PATCH
endpoint, and the missing 'po' concurrency invalidator so 412/428
recovery actually refetches. Ports createTestPurchaseOrder +
waitForPoAutosave and the po-created-by spec.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
PoLinesTable as the third grid on the useReactTable + useDraftRows
pattern (phantom row, no add-line button, row-exit draft commit —
unit-cost stays the row's last focusable cell so the spec's Tab exits
the row). ItemSelect generalised for stock-only consumers (optional
jobId/line, label + wrapper overrides; labour-rates query gated on
jobId presence only, since textOnly labels need rate names). Inline
JobSelect over purchasing_all_jobs_retrieve — the unfiltered endpoint
v1's PO page uses, because fresh jobs are draft and the filtered
sibling excludes them. Spec ported with the autosave waiter armed
before the pick/status clicks (v2 saves immediately; v1's debounce is
what made arm-after work).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Spec-lean StockPage: full active-stock list on load, 300ms-debounced
server FTS from 3 characters, enabled-gated so clearing the box
renders the cached list with no /search/ request.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
…ports
The purchasing slice's PoLinesTable was about to become a third
hand-rolled grid emitting the DataTable-row-N/data-grid-* contract
inline. features/shared/DataTable.tsx is now the one owner of that
contract; SmartTimesheetTable, CostLineGrid and PoLinesTable all
render through it.
Auditing further found the same pathology in the plain list pages:
PoListPage and StockPage (both new this slice) had copied
CompaniesListPage's table shell and loading/error/retry block
verbatim, taking an existing duplicate (also in WipReportPage) from
2 instances to 4. features/shared/ListTable.tsx is the one owner of
that block instead -- deliberately separate from DataTable, since it
has no react-table dependency and forcing static lists through
column-def machinery would be indirection, not rigor. A hand-rolled
debounce-into-query-state pattern in CompaniesListPage and StockPage
is now features/shared/useDebouncedValue.ts (KanbanSearchInput keeps
its own URL-driven debounce -- a different concept).
ItemSelect, the decimal helpers and the Save-failed badge move to
features/shared/ because purchasing consumed them cross-domain -- a
domain feature is not a library.
ADR 0039 strengthened: unification is never deferred, shared concepts
get shared homes, the bar is reference quality.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Adversarial review on the branch found the ListTable audit had stopped
short: JobMovementReportPage and CompanyDetailPage hand-rolled the same
loading/error/retry block ListTable was built to own (6 real instances,
only 4 fixed). Split the block itself out as
features/shared/QueryState.tsx -- the pending/error gate alone, no
table -- so it fits pages that show something other than a table too.
ListTable now composes QueryState instead of duplicating it. PoDetailPage,
CostLineGrid, JobMovementReportPage and CompanyDetailPage all render
through it.
Fixed: PoLinesTable's item-picker label read `item_code ?? 'Select Item'`
with no description fallback, so a bound stock item with a null code
(nullable, v1 parity) misread as unbound. Now poLineItemLabel() in
lines.ts, unit-tested.
Moved features/company to features/shared/company: it was already
cross-imported by features/job (JobCreatePage, JobSettingsTab) before
this slice added a third importer (purchasing/PoSummaryCard) -- it never
had a route of its own, a shared widget library in a domain-shaped box.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Reversed 2026-08-11. The 2026-08-10 record said SSE ships with the
production-serving decision, filed under "Post-cutover -- decided,
deliberately NOT before 15 August." That was overturned: racing bad
architecture into production defeats the point of the rewrite
(non-negotiable #3 in the Cutover section), and the interim polling
shape plus the un-runnable apps/xero/sync_stream.py view are exactly
that. Slice 3 -- live updates done properly (serving model fix + SSE
ticker + discard the interim shortcuts) moves to its own MUST-tier
section with a milestone checkbox, and every stale "deferred
post-cutover" cross-reference in the file is corrected. The
purchasing-slice PR is unaffected -- it is unrelated and merges as
planned; this correction only fixes the durable record for the next
session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
QueryState grew optional loadingNode/errorNode override props so a
spinner-based caller keeps its visual shell instead of losing it to
the plain-text default. JobFinishTab, DailyOverviewPage, and both
gates in TimesheetEntryPage's EntryWorkspace now render through it.
XeroQuoteCard, JobInvoiceCard and JobSettingsTab's pay-item field stay
excluded -- richer branching than binary success/fail, not the
page-level gate QueryState owns. TimesheetEntryPage's own outer gate
stays as guard-clause `if` returns, matching CLAUDE.md's stated
preference rather than converting to a shape that would abandon it.
Also: rewrite-status.md's narration of "review round found X, we
fixed Y" replaced with what the outcomes actually are -- which shared
component owns which contract, which sites are deliberately excluded
and why, which constraints a future change must respect. And the
Cutover section now states the actual two-question go/no-go criteria
(functional parity, proxied by MUST-tier E2E; materially better
architecture, judged directly) rather than only the E2E proxy, with
the honest fallback (abort and stay on v1) stated explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
@coderabbitai

coderabbitaiBot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@corrin, you've reached your PR review limit, so we couldn't start this review.

Next review available in:12 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 756f5951-a815-47ee-9870-21c9be6081d2

📥 Commits

Reviewing files that changed from the base of the PR and between a3451f2 and ac028e2.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (14)
  • .gitignore
  • docs/rewrite-status.md
  • frontend/playwright.config.ts
  • frontend/src/features/crm/CompaniesListPage.tsx
  • frontend/src/features/job/JobFinishTab.tsx
  • frontend/src/features/purchasing/JobSelect.test.tsx
  • frontend/src/features/purchasing/JobSelect.tsx
  • frontend/src/features/purchasing/PoListPage.tsx
  • frontend/src/features/shared/ItemSelect.tsx
  • frontend/src/features/shared/SaveFailedBadge.tsx
  • frontend/src/features/shared/company/CompanyLookup.tsx
  • frontend/src/features/shared/company/CreateCompanyModal.tsx
  • frontend/src/features/shared/decimal.test.ts
  • frontend/src/features/shared/decimal.ts
📝 Walkthrough

Walkthrough

The PR adds authenticated purchasing pages for stock and purchase orders, including editable lines, autosave, optimistic updates, and E2E coverage. It also extracts shared query, table, company, decimal, and debounce components and updates architecture and cutover documentation.

Changes

Purchasing frontend

Layer / File(s)Summary
Purchasing contracts and persistence
frontend/src/api/..., frontend/src/features/purchasing/JobSelect.tsx, frontend/src/features/purchasing/lines.ts, frontend/src/features/purchasing/usePoLines.ts
Adds purchasing API exports, job filtering, PO draft utilities, optimistic mutations, concurrency invalidation, and refetch reconciliation.
Purchasing pages and routing
frontend/src/features/purchasing/..., frontend/src/routes/_authed/purchasing/..., frontend/src/routeTree.gen.ts
Adds PO list, create, detail, editable line, summary, and stock-search pages with authenticated routes.
Purchasing validation
frontend/tests/e2e/purchasing/..., frontend/tests/e2e/helpers.ts, frontend/src/features/purchasing/*.test.ts
Adds unit and E2E coverage for PO creation, autosave, status updates, created-by values, job filtering, draft serialization, and stock search.

Shared frontend consolidation

Layer / File(s)Summary
Shared query and grid infrastructure
frontend/src/features/shared/..., frontend/src/features/crm/..., frontend/src/features/job/..., frontend/src/features/reports/..., frontend/src/features/timesheet/...
Adds QueryState, ListTable, DataTable, SaveFailedBadge, decimal utilities, and useDebouncedValue, then adopts them across existing pages.
Shared company and person features
frontend/src/features/shared/company/..., frontend/src/features/job/JobCreatePage.tsx, frontend/src/features/job/JobSettingsTab.tsx
Adds shared company lookup, creation, Xero validation, person selection, and person management components, and updates consumers to use the shared module.

Architecture and cutover documentation

Layer / File(s)Summary
Architecture and release rules
docs/adr/0039-one-implementation-per-concept.md, docs/cutover-checklist.md, docs/rewrite-status.md, docs/code-quality.md
Documents immediate implementation unification, independent functionality and architecture gates, threaded serving requirements, purchasing progress, and updated suppression counts.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 54.24% 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 nameStatusExplanation
Title check✅ PassedThe title clearly identifies the purchasing pages and spec progress covered by the pull request.
Description check✅ PassedThe description explains the scope, architectural changes, remaining work, and validation results in sufficient detail.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch purchasing-po

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.

@coderabbitaicoderabbitaiBot 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: 10

🧹 Nitpick comments (11)
docs/cutover-checklist.md (1)

115-123: 🩺 Stability & Availability | 🔵 Trivial

Define the ASGI acceptance criteria.

or the ASGI equivalent is not an executable serving requirement. Define the minimum concurrency, timeout, keep-alive, database-connection, and ORM-safety requirements. Link this checkbox to the deployment configuration that enforces them.

🤖 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 `@docs/cutover-checklist.md` around lines 115 - 123, Update the serving-model
checklist item to define executable ASGI acceptance criteria: minimum
concurrency, request timeout, keep-alive, database-connection capacity, and
ORM-safety requirements. Link the checkbox to the deployment configuration or
command that enforces these settings, while retaining the existing WSGI option
and cutover context.
frontend/src/features/purchasing/StockPage.tsx (2)

25-31: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Send the trimmed query.

searchActive is computed from query.trim(), but the request sends the untrimmed query. For an input of " abc" the gate opens and q is " abc". Each whitespace variation also produces a distinct query key, which adds duplicate cache entries and duplicate requests for the same search term.

♻️ Proposed fix
- const searchActive = query.trim().length >= MIN_QUERY_LENGTH+ const trimmedQuery = query.trim()+ const searchActive = trimmedQuery.length >= MIN_QUERY_LENGTH
const list = useQuery(purchasingStockListOptions())
const search = useQuery({
- ...purchasingStockSearchRetrieveOptions({ query: { q: query } }),+ ...purchasingStockSearchRetrieveOptions({ query: { q: trimmedQuery } }),
enabled: searchActive,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 25 - 31, Update
the search query construction near searchActive in StockPage so the request and
its query key use the trimmed query value, while preserving the existing
minimum-length gating behavior.

27-33: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Preserve the previous search results while fetching a new query.

Each active search term creates a new query key. search.isPending then replaces the table with Loading stock items.... Use placeholderData: keepPreviousData to keep the current rows visible during the fetch.

♻️ Proposed refactor
-import { useQuery } from '`@tanstack/react-query`'+import { keepPreviousData, useQuery } from '`@tanstack/react-query`'
 const search = useQuery({
...purchasingStockSearchRetrieveOptions({ query: { q: query } }),
enabled: searchActive,
+ placeholderData: keepPreviousData,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 27 - 33, Update
the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
frontend/tests/e2e/helpers.ts (1)

444-446: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace Math.random with a collision-free identifier.

Math.floor(Math.random() * 100000) gives 100,000 possible values. Parallel Playwright workers and repeated local runs can produce the same supplier name. A duplicate [TEST] Supplier N creates a second live Xero contact push with the same name, which makes later lookups ambiguous.

Use crypto.randomUUID() or combine the timestamp with the worker index.

♻️ Proposed change
- const randomSuffix = Math.floor(Math.random() * 100000)- const supplierName = `[TEST] Supplier ${randomSuffix}`+ const randomSuffix = crypto.randomUUID().slice(0, 8)+ const supplierName = `[TEST] Supplier ${randomSuffix}`
🤖 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 `@frontend/tests/e2e/helpers.ts` around lines 444 - 446, Update
createTestPurchaseOrder so supplierName uses a collision-free identifier instead
of Math.random(), preferably crypto.randomUUID() or a timestamp combined with
the Playwright worker index, while preserving the existing “[TEST] Supplier”
naming format.
frontend/tests/e2e/purchasing/stock-search.spec.ts (1)

117-134: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The counter filter can miss a regression.

The listener ignores every /search/ response whose URL contains q=5mm. The exclusion exists to skip the first search, but it also hides the most likely regression: clearing the input re-fires the same q=5mm request. That request is a real post-clear search call, and the assertion at Line 134 still passes.

Gate on time instead of on the query value.

♻️ Proposed change
- // Track whether any further /search/ request fires when we clear the box.+ // Gate on the clear action, not on the query value: a re-fired `q=5mm`+ // request after clearing is still a post-clear search call.
let postClearSearchCalls = 0
+ let cleared = false
page.on('response', (response) => {
- if (- response.url().includes('/api/purchasing/stock/search/') &&- !response.url().includes('q=5mm')- ) {+ if (cleared && response.url().includes('/api/purchasing/stock/search/')) {
postClearSearchCalls += 1
}
})
+ cleared = true
await input.fill('')
🤖 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 `@frontend/tests/e2e/purchasing/stock-search.spec.ts` around lines 117 - 134,
Update the response listener in the stock-search test to count search responses
based on whether they occur after the input is cleared, rather than excluding
URLs containing q=5mm. Start tracking post-clear responses only immediately
before input.fill(''), so the initial search is ignored while any repeated q=5mm
request after clearing is counted and fails the existing assertion.
frontend/src/features/shared/ListTable.tsx (1)

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

Document that renderRow must supply the React key.

Line 66 calls rows.map(renderRow) and applies no key. Every current caller sets key on its own <tr>. That contract is not stated on the prop, so a future caller can omit the key and cause a React key warning plus incorrect row reconciliation.

♻️ Proposed doc addition
+ /** Must set a stable React `key` on the returned row element; ListTable+ maps rows directly and applies no key of its own. */
renderRow: (row: TRow) => ReactNode

Also applies to: 66-66

🤖 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 `@frontend/src/features/shared/ListTable.tsx` at line 21, Document the
`renderRow` prop contract in `ListTable`: implementations must return each row
with its own stable React `key`, since the `rows.map(renderRow)` call does not
apply one. Add this requirement to the prop’s existing documentation without
changing the rendering logic.
frontend/src/features/shared/company/PersonSelectionModal.test.tsx (1)

19-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering the create and update flows.

The single test covers accessibility only. handleCreate and handleUpdate hold the business rules: first person is always primary, blank optional fields are omitted from the request body, and an invalid email blocks submission. These rules are unit-testable with mocked mutations.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx` around
lines 19 - 48, Extend the PersonSelectionModal tests beyond accessibility to
cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
frontend/src/features/shared/company/PersonSelectionModal.tsx (3)

294-303: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The person card is a clickable div.

The card carries onClick but no role, tabIndex, or key handler. Keyboard users reach the person through the hover-revealed Select button, which group-focus-within exposes, so the flow is not blocked. Removing the card-level onClick and relying on the explicit Select button would remove the duplicated activation path and the event.stopPropagation() calls at Lines 341, 354, and 367.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
294 - 303, The person card in the person-selection rendering should no longer be
clickable via its container. Remove the card-level onClick handler from the div
around person.person_id, rely on the explicit Select button for activation, and
remove the associated event.stopPropagation() calls in the button handlers.

218-220: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse invalidatePeople here.

Lines 218-220 repeat the query-key invalidation that invalidatePeople defines at Lines 118-121. handleUpdate and handleConfirmDelete already call the helper. A future key change would need two edits.

♻️ Proposed fix
- await queryClient.invalidateQueries({- queryKey: companiesPeopleListQueryKey({ path: { company_id: companyId } }),- })+ await invalidatePeople()
As per coding guidelines: "Use one implementation per concept; search before implementing and extend a near-match instead of creating a parallel implementation."
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
218 - 220, Replace the direct companies-people query invalidation in the
relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.

Source: Coding guidelines


534-542: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Handle the returned promise on the submit button.

handleUpdate and handleCreate are async. Passing them directly to onClick returns a floating promise from the event handler. The delete button at Line 272 already wraps its call with void. Use the same form here for consistency and to satisfy @typescript-eslint/no-misused-promises if that rule is enabled.

♻️ Proposed fix
- onClick={editingPerson ? handleUpdate : handleCreate}+ onClick={() => {+ void (editingPerson ? handleUpdate() : handleCreate())+ }}
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
534 - 542, Update the submit button’s onClick handler to explicitly discard the
promise returned by handleUpdate or handleCreate, matching the existing
delete-button pattern. Preserve the editingPerson conditional selection and
button behavior.
frontend/src/features/shared/useDebouncedValue.test.tsx (1)

39-46: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the fake-timer queue on unmount.

clearTimeout is global, so another cleanup can satisfy toHaveBeenCalled(). Since this suite enables fake timers, assert that vi.getTimerCount() is greater than zero after renderHook, then zero after unmount().

🤖 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 `@frontend/src/features/shared/useDebouncedValue.test.tsx` around lines 39 -
46, Update the “clears a pending timer on unmount” test to assert the fake-timer
queue directly: verify vi.getTimerCount() is greater than zero after renderHook
creates the debounced timer, then verify it is zero after unmount(). Remove the
clearTimeout spy assertion.
🤖 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 `@docs/rewrite-status.md`:
- Around line 57-61: Update the frontend rebuild table in the rewrite-status
document to replace references to features/company/PersonSelectionModal.tsx and
features/company/CompanyLookup.tsx with their canonical features/shared/company/
paths, leaving other entries unchanged.
In `@frontend/src/features/crm/CompaniesListPage.tsx`:
- Around line 113-119: Apply the first-load-only error rule at both QueryState
call sites: in frontend/src/features/crm/CompaniesListPage.tsx lines 113-119,
change the ListTable isError value to companies.isError && companies.data ===
undefined; in frontend/src/features/job/JobFinishTab.tsx lines 209-213, use a
loadError derived per query from isError && data === undefined so cached summary
and checklist content remains visible during failed refetches.
In `@frontend/src/features/purchasing/JobSelect.tsx`:
- Around line 113-150: Update JobSelect to support keyboard navigation by
tracking an active option index, moving it with ArrowUp/ArrowDown, and selecting
the active job on Enter. Add role="listbox" to the dropdown and role="option"
with aria-selected to each mapped job row, making options keyboard-accessible
and reusing the shared picker pattern if available.
- Around line 91-111: Update the closing paths in JobSelect’s onBlur timeout and
Escape handler to reset editing and clear the stale search term when the picker
closes without selection, so value returns to the bound job and filtering does
not reuse old input. Store the 150ms blur timeout in a ref and add a useEffect
cleanup that clears it on unmount, while preserving the existing select
behavior.
In `@frontend/src/features/purchasing/PoListPage.tsx`:
- Around line 57-74: Update the purchase-order row in PoListPage’s renderRow
callback to be keyboard accessible: make the tr focusable, expose it as an
interactive control, and handle keyboard activation by navigating to the same
po/$poId destination as onClick. Preserve the existing mouse navigation
behavior.
In `@frontend/src/features/shared/company/CompanyLookup.tsx`:
- Around line 238-242: Update the suggestions empty-state rendering near
companiesSearchRetrieveOptions to check search.isError first and display an
error state when the request fails. Gate the existing “No companies found”
message on a successful, non-error search so rejected requests never appear as
empty results.
- Around line 88-99: Associate quickCreateCompany with an active request token
and call handleCompanyCreated only when the response still matches the current
lookup interaction; otherwise ignore the completion. In
frontend/src/features/shared/company/CompanyLookup.tsx lines 88-99, update
quickCreateCompany accordingly. In
frontend/src/features/shared/company/CreateCompanyModal.tsx lines 49-64, either
prevent dismissal while creation is pending or ignore completion after the modal
closes. Add regression tests covering abandoned quick-create and modal-creation
interactions.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 242-281: Update the form footer submit state in the component’s
submitDisabled logic to also disable submission whenever deleteTarget is
non-null or removeLink.isPending is true. Keep the existing confirmation overlay
behavior and ensure create/edit actions cannot start while deletion is pending
or awaiting confirmation.
In `@frontend/src/features/shared/decimal.ts`:
- Around line 10-16: Update parseDecimalInput to validate cleaned input against
an explicit fixed-point decimal syntax before numeric finiteness checks,
rejecting hex, octal, binary, and other non-decimal forms. Normalize accepted
values such as “.5”, “+5”, and “5.” into the backend-supported decimal
representation, and ensure trimDecimal cannot return invalid syntax verbatim.
In `@frontend/src/features/shared/ItemSelect.tsx`:
- Around line 19-43: Rewrite or remove the comments at
frontend/src/features/shared/ItemSelect.tsx:19-43, replacing implementation,
E2E, and history narration with the rejected API alternative and factual
constraint, or remove them. Apply the same constraint-based approach at
frontend/src/features/shared/SaveFailedBadge.tsx:1-4,
frontend/src/features/shared/decimal.test.ts:33-34 (including the rejected
formatting behavior and exponent constraint),
frontend/src/features/shared/company/CompanyLookup.test.tsx:154-155 (including
the Sonner rendering constraint if retained),
frontend/src/features/shared/company/CompanyLookup.tsx:88-90, and
frontend/src/features/shared/company/CreateCompanyModal.tsx:16-26 and :39
(document only rejected alternatives and current product/state constraints,
otherwise remove the comments).
---
Nitpick comments:
In `@docs/cutover-checklist.md`:
- Around line 115-123: Update the serving-model checklist item to define
executable ASGI acceptance criteria: minimum concurrency, request timeout,
keep-alive, database-connection capacity, and ORM-safety requirements. Link the
checkbox to the deployment configuration or command that enforces these
settings, while retaining the existing WSGI option and cutover context.
In `@frontend/src/features/purchasing/StockPage.tsx`:
- Around line 25-31: Update the search query construction near searchActive in
StockPage so the request and its query key use the trimmed query value, while
preserving the existing minimum-length gating behavior.
- Around line 27-33: Update the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
In `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx`:
- Around line 19-48: Extend the PersonSelectionModal tests beyond accessibility
to cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 294-303: The person card in the person-selection rendering should
no longer be clickable via its container. Remove the card-level onClick handler
from the div around person.person_id, rely on the explicit Select button for
activation, and remove the associated event.stopPropagation() calls in the
button handlers.
- Around line 218-220: Replace the direct companies-people query invalidation in
the relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.
- Around line 534-542: Update the submit button’s onClick handler to explicitly
discard the promise returned by handleUpdate or handleCreate, matching the
existing delete-button pattern. Preserve the editingPerson conditional selection
and button behavior.
In `@frontend/src/features/shared/ListTable.tsx`:
- Line 21: Document the `renderRow` prop contract in `ListTable`:
implementations must return each row with its own stable React `key`, since the
`rows.map(renderRow)` call does not apply one. Add this requirement to the
prop’s existing documentation without changing the rendering logic.
In `@frontend/src/features/shared/useDebouncedValue.test.tsx`:
- Around line 39-46: Update the “clears a pending timer on unmount” test to
assert the fake-timer queue directly: verify vi.getTimerCount() is greater than
zero after renderHook creates the debounced timer, then verify it is zero after
unmount(). Remove the clearTimeout spy assertion.
In `@frontend/tests/e2e/helpers.ts`:
- Around line 444-446: Update createTestPurchaseOrder so supplierName uses a
collision-free identifier instead of Math.random(), preferably
crypto.randomUUID() or a timestamp combined with the Playwright worker index,
while preserving the existing “[TEST] Supplier” naming format.
In `@frontend/tests/e2e/purchasing/stock-search.spec.ts`:
- Around line 117-134: Update the response listener in the stock-search test to
count search responses based on whether they occur after the input is cleared,
rather than excluding URLs containing q=5mm. Start tracking post-clear responses
only immediately before input.fill(''), so the initial search is ignored while
any repeated q=5mm request after clearing is counted and fails the existing
assertion.
🪄 Autofix

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 Plus

Run ID: 66ca2b61-202f-4ca9-a415-b773f6995a0a

📥 Commits

Reviewing files that changed from the base of the PR and between 3030b60 and a3451f2.

📒 Files selected for processing (60)
  • docs/adr/0039-one-implementation-per-concept.md
  • docs/code-quality.md
  • docs/cutover-checklist.md
  • docs/rewrite-status.md
  • frontend/src/api/index.ts
  • frontend/src/api/query-client.ts
  • frontend/src/features/crm/CompaniesListPage.tsx
  • frontend/src/features/crm/CompanyDetailPage.tsx
  • frontend/src/features/job/JobCreatePage.tsx
  • frontend/src/features/job/JobFinishTab.tsx
  • frontend/src/features/job/JobSettingsTab.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/purchasing/JobSelect.test.ts
  • frontend/src/features/purchasing/JobSelect.tsx
  • frontend/src/features/purchasing/PoCreatePage.tsx
  • frontend/src/features/purchasing/PoDetailPage.tsx
  • frontend/src/features/purchasing/PoLinesTable.tsx
  • frontend/src/features/purchasing/PoListPage.tsx
  • frontend/src/features/purchasing/PoSummaryCard.tsx
  • frontend/src/features/purchasing/StockPage.tsx
  • frontend/src/features/purchasing/index.ts
  • frontend/src/features/purchasing/lines.test.ts
  • frontend/src/features/purchasing/lines.ts
  • frontend/src/features/purchasing/usePoLines.ts
  • frontend/src/features/reports/JobMovementReportPage.tsx
  • frontend/src/features/reports/WipReportPage.tsx
  • frontend/src/features/shared/DataTable.tsx
  • frontend/src/features/shared/ItemSelect.tsx
  • frontend/src/features/shared/ListTable.test.tsx
  • frontend/src/features/shared/ListTable.tsx
  • frontend/src/features/shared/QueryState.test.tsx
  • frontend/src/features/shared/QueryState.tsx
  • frontend/src/features/shared/SaveFailedBadge.tsx
  • frontend/src/features/shared/company/CompanyLookup.test.tsx
  • frontend/src/features/shared/company/CompanyLookup.tsx
  • frontend/src/features/shared/company/CreateCompanyModal.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.test.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.tsx
  • frontend/src/features/shared/company/PersonSelector.tsx
  • frontend/src/features/shared/company/create-company.ts
  • frontend/src/features/shared/company/index.ts
  • frontend/src/features/shared/company/xero-contact.ts
  • frontend/src/features/shared/decimal.test.ts
  • frontend/src/features/shared/decimal.ts
  • frontend/src/features/shared/useDebouncedValue.test.tsx
  • frontend/src/features/shared/useDebouncedValue.ts
  • frontend/src/features/timesheet/DailyOverviewPage.tsx
  • frontend/src/features/timesheet/SmartTimesheetTable.tsx
  • frontend/src/features/timesheet/TimesheetEntryPage.tsx
  • frontend/src/routeTree.gen.ts
  • frontend/src/routes/_authed/purchasing/po/$poId.tsx
  • frontend/src/routes/_authed/purchasing/po/create.tsx
  • frontend/src/routes/_authed/purchasing/po/index.tsx
  • frontend/src/routes/_authed/purchasing/stock.tsx
  • frontend/tests/e2e/helpers.ts
  • frontend/tests/e2e/purchasing/create-purchase-order.spec.ts
  • frontend/tests/e2e/purchasing/po-created-by.spec.ts
  • frontend/tests/e2e/purchasing/stock-search.spec.ts
💤 Files with no reviewable changes (2)
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/calc.test.ts

Comment threaddocs/rewrite-status.md
Comment threadfrontend/src/features/crm/CompaniesListPage.tsx
Comment threadfrontend/src/features/purchasing/JobSelect.tsx
Comment threadfrontend/src/features/purchasing/JobSelect.tsx
Comment threadfrontend/src/features/purchasing/PoListPage.tsx
Comment threadfrontend/src/features/shared/decimal.ts
Comment threadfrontend/src/features/shared/ItemSelect.tsx

@coderabbitaicoderabbitaiBot 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.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 10

🧹 Nitpick comments (11)
docs/cutover-checklist.md (1)

115-123: 🩺 Stability & Availability | 🔵 Trivial

Define the ASGI acceptance criteria.

or the ASGI equivalent is not an executable serving requirement. Define the minimum concurrency, timeout, keep-alive, database-connection, and ORM-safety requirements. Link this checkbox to the deployment configuration that enforces them.

🤖 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 `@docs/cutover-checklist.md` around lines 115 - 123, Update the serving-model
checklist item to define executable ASGI acceptance criteria: minimum
concurrency, request timeout, keep-alive, database-connection capacity, and
ORM-safety requirements. Link the checkbox to the deployment configuration or
command that enforces these settings, while retaining the existing WSGI option
and cutover context.
frontend/src/features/purchasing/StockPage.tsx (2)

25-31: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Send the trimmed query.

searchActive is computed from query.trim(), but the request sends the untrimmed query. For an input of " abc" the gate opens and q is " abc". Each whitespace variation also produces a distinct query key, which adds duplicate cache entries and duplicate requests for the same search term.

♻️ Proposed fix
- const searchActive = query.trim().length >= MIN_QUERY_LENGTH+ const trimmedQuery = query.trim()+ const searchActive = trimmedQuery.length >= MIN_QUERY_LENGTH
const list = useQuery(purchasingStockListOptions())
const search = useQuery({
- ...purchasingStockSearchRetrieveOptions({ query: { q: query } }),+ ...purchasingStockSearchRetrieveOptions({ query: { q: trimmedQuery } }),
enabled: searchActive,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 25 - 31, Update
the search query construction near searchActive in StockPage so the request and
its query key use the trimmed query value, while preserving the existing
minimum-length gating behavior.

27-33: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Preserve the previous search results while fetching a new query.

Each active search term creates a new query key. search.isPending then replaces the table with Loading stock items.... Use placeholderData: keepPreviousData to keep the current rows visible during the fetch.

♻️ Proposed refactor
-import { useQuery } from '`@tanstack/react-query`'+import { keepPreviousData, useQuery } from '`@tanstack/react-query`'
 const search = useQuery({
...purchasingStockSearchRetrieveOptions({ query: { q: query } }),
enabled: searchActive,
+ placeholderData: keepPreviousData,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 27 - 33, Update
the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
frontend/tests/e2e/helpers.ts (1)

444-446: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace Math.random with a collision-free identifier.

Math.floor(Math.random() * 100000) gives 100,000 possible values. Parallel Playwright workers and repeated local runs can produce the same supplier name. A duplicate [TEST] Supplier N creates a second live Xero contact push with the same name, which makes later lookups ambiguous.

Use crypto.randomUUID() or combine the timestamp with the worker index.

♻️ Proposed change
- const randomSuffix = Math.floor(Math.random() * 100000)- const supplierName = `[TEST] Supplier ${randomSuffix}`+ const randomSuffix = crypto.randomUUID().slice(0, 8)+ const supplierName = `[TEST] Supplier ${randomSuffix}`
🤖 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 `@frontend/tests/e2e/helpers.ts` around lines 444 - 446, Update
createTestPurchaseOrder so supplierName uses a collision-free identifier instead
of Math.random(), preferably crypto.randomUUID() or a timestamp combined with
the Playwright worker index, while preserving the existing “[TEST] Supplier”
naming format.
frontend/tests/e2e/purchasing/stock-search.spec.ts (1)

117-134: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The counter filter can miss a regression.

The listener ignores every /search/ response whose URL contains q=5mm. The exclusion exists to skip the first search, but it also hides the most likely regression: clearing the input re-fires the same q=5mm request. That request is a real post-clear search call, and the assertion at Line 134 still passes.

Gate on time instead of on the query value.

♻️ Proposed change
- // Track whether any further /search/ request fires when we clear the box.+ // Gate on the clear action, not on the query value: a re-fired `q=5mm`+ // request after clearing is still a post-clear search call.
let postClearSearchCalls = 0
+ let cleared = false
page.on('response', (response) => {
- if (- response.url().includes('/api/purchasing/stock/search/') &&- !response.url().includes('q=5mm')- ) {+ if (cleared && response.url().includes('/api/purchasing/stock/search/')) {
postClearSearchCalls += 1
}
})
+ cleared = true
await input.fill('')
🤖 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 `@frontend/tests/e2e/purchasing/stock-search.spec.ts` around lines 117 - 134,
Update the response listener in the stock-search test to count search responses
based on whether they occur after the input is cleared, rather than excluding
URLs containing q=5mm. Start tracking post-clear responses only immediately
before input.fill(''), so the initial search is ignored while any repeated q=5mm
request after clearing is counted and fails the existing assertion.
frontend/src/features/shared/ListTable.tsx (1)

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

Document that renderRow must supply the React key.

Line 66 calls rows.map(renderRow) and applies no key. Every current caller sets key on its own <tr>. That contract is not stated on the prop, so a future caller can omit the key and cause a React key warning plus incorrect row reconciliation.

♻️ Proposed doc addition
+ /** Must set a stable React `key` on the returned row element; ListTable+ maps rows directly and applies no key of its own. */
renderRow: (row: TRow) => ReactNode

Also applies to: 66-66

🤖 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 `@frontend/src/features/shared/ListTable.tsx` at line 21, Document the
`renderRow` prop contract in `ListTable`: implementations must return each row
with its own stable React `key`, since the `rows.map(renderRow)` call does not
apply one. Add this requirement to the prop’s existing documentation without
changing the rendering logic.
frontend/src/features/shared/company/PersonSelectionModal.test.tsx (1)

19-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering the create and update flows.

The single test covers accessibility only. handleCreate and handleUpdate hold the business rules: first person is always primary, blank optional fields are omitted from the request body, and an invalid email blocks submission. These rules are unit-testable with mocked mutations.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx` around
lines 19 - 48, Extend the PersonSelectionModal tests beyond accessibility to
cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
frontend/src/features/shared/company/PersonSelectionModal.tsx (3)

294-303: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The person card is a clickable div.

The card carries onClick but no role, tabIndex, or key handler. Keyboard users reach the person through the hover-revealed Select button, which group-focus-within exposes, so the flow is not blocked. Removing the card-level onClick and relying on the explicit Select button would remove the duplicated activation path and the event.stopPropagation() calls at Lines 341, 354, and 367.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
294 - 303, The person card in the person-selection rendering should no longer be
clickable via its container. Remove the card-level onClick handler from the div
around person.person_id, rely on the explicit Select button for activation, and
remove the associated event.stopPropagation() calls in the button handlers.

218-220: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse invalidatePeople here.

Lines 218-220 repeat the query-key invalidation that invalidatePeople defines at Lines 118-121. handleUpdate and handleConfirmDelete already call the helper. A future key change would need two edits.

♻️ Proposed fix
- await queryClient.invalidateQueries({- queryKey: companiesPeopleListQueryKey({ path: { company_id: companyId } }),- })+ await invalidatePeople()
As per coding guidelines: "Use one implementation per concept; search before implementing and extend a near-match instead of creating a parallel implementation."
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
218 - 220, Replace the direct companies-people query invalidation in the
relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.

Source: Coding guidelines


534-542: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Handle the returned promise on the submit button.

handleUpdate and handleCreate are async. Passing them directly to onClick returns a floating promise from the event handler. The delete button at Line 272 already wraps its call with void. Use the same form here for consistency and to satisfy @typescript-eslint/no-misused-promises if that rule is enabled.

♻️ Proposed fix
- onClick={editingPerson ? handleUpdate : handleCreate}+ onClick={() => {+ void (editingPerson ? handleUpdate() : handleCreate())+ }}
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
534 - 542, Update the submit button’s onClick handler to explicitly discard the
promise returned by handleUpdate or handleCreate, matching the existing
delete-button pattern. Preserve the editingPerson conditional selection and
button behavior.
frontend/src/features/shared/useDebouncedValue.test.tsx (1)

39-46: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the fake-timer queue on unmount.

clearTimeout is global, so another cleanup can satisfy toHaveBeenCalled(). Since this suite enables fake timers, assert that vi.getTimerCount() is greater than zero after renderHook, then zero after unmount().

🤖 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 `@frontend/src/features/shared/useDebouncedValue.test.tsx` around lines 39 -
46, Update the “clears a pending timer on unmount” test to assert the fake-timer
queue directly: verify vi.getTimerCount() is greater than zero after renderHook
creates the debounced timer, then verify it is zero after unmount(). Remove the
clearTimeout spy assertion.
🤖 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 `@docs/rewrite-status.md`:
- Around line 57-61: Update the frontend rebuild table in the rewrite-status
document to replace references to features/company/PersonSelectionModal.tsx and
features/company/CompanyLookup.tsx with their canonical features/shared/company/
paths, leaving other entries unchanged.
In `@frontend/src/features/crm/CompaniesListPage.tsx`:
- Around line 113-119: Apply the first-load-only error rule at both QueryState
call sites: in frontend/src/features/crm/CompaniesListPage.tsx lines 113-119,
change the ListTable isError value to companies.isError && companies.data ===
undefined; in frontend/src/features/job/JobFinishTab.tsx lines 209-213, use a
loadError derived per query from isError && data === undefined so cached summary
and checklist content remains visible during failed refetches.
In `@frontend/src/features/purchasing/JobSelect.tsx`:
- Around line 113-150: Update JobSelect to support keyboard navigation by
tracking an active option index, moving it with ArrowUp/ArrowDown, and selecting
the active job on Enter. Add role="listbox" to the dropdown and role="option"
with aria-selected to each mapped job row, making options keyboard-accessible
and reusing the shared picker pattern if available.
- Around line 91-111: Update the closing paths in JobSelect’s onBlur timeout and
Escape handler to reset editing and clear the stale search term when the picker
closes without selection, so value returns to the bound job and filtering does
not reuse old input. Store the 150ms blur timeout in a ref and add a useEffect
cleanup that clears it on unmount, while preserving the existing select
behavior.
In `@frontend/src/features/purchasing/PoListPage.tsx`:
- Around line 57-74: Update the purchase-order row in PoListPage’s renderRow
callback to be keyboard accessible: make the tr focusable, expose it as an
interactive control, and handle keyboard activation by navigating to the same
po/$poId destination as onClick. Preserve the existing mouse navigation
behavior.
In `@frontend/src/features/shared/company/CompanyLookup.tsx`:
- Around line 238-242: Update the suggestions empty-state rendering near
companiesSearchRetrieveOptions to check search.isError first and display an
error state when the request fails. Gate the existing “No companies found”
message on a successful, non-error search so rejected requests never appear as
empty results.
- Around line 88-99: Associate quickCreateCompany with an active request token
and call handleCompanyCreated only when the response still matches the current
lookup interaction; otherwise ignore the completion. In
frontend/src/features/shared/company/CompanyLookup.tsx lines 88-99, update
quickCreateCompany accordingly. In
frontend/src/features/shared/company/CreateCompanyModal.tsx lines 49-64, either
prevent dismissal while creation is pending or ignore completion after the modal
closes. Add regression tests covering abandoned quick-create and modal-creation
interactions.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 242-281: Update the form footer submit state in the component’s
submitDisabled logic to also disable submission whenever deleteTarget is
non-null or removeLink.isPending is true. Keep the existing confirmation overlay
behavior and ensure create/edit actions cannot start while deletion is pending
or awaiting confirmation.
In `@frontend/src/features/shared/decimal.ts`:
- Around line 10-16: Update parseDecimalInput to validate cleaned input against
an explicit fixed-point decimal syntax before numeric finiteness checks,
rejecting hex, octal, binary, and other non-decimal forms. Normalize accepted
values such as “.5”, “+5”, and “5.” into the backend-supported decimal
representation, and ensure trimDecimal cannot return invalid syntax verbatim.
In `@frontend/src/features/shared/ItemSelect.tsx`:
- Around line 19-43: Rewrite or remove the comments at
frontend/src/features/shared/ItemSelect.tsx:19-43, replacing implementation,
E2E, and history narration with the rejected API alternative and factual
constraint, or remove them. Apply the same constraint-based approach at
frontend/src/features/shared/SaveFailedBadge.tsx:1-4,
frontend/src/features/shared/decimal.test.ts:33-34 (including the rejected
formatting behavior and exponent constraint),
frontend/src/features/shared/company/CompanyLookup.test.tsx:154-155 (including
the Sonner rendering constraint if retained),
frontend/src/features/shared/company/CompanyLookup.tsx:88-90, and
frontend/src/features/shared/company/CreateCompanyModal.tsx:16-26 and :39
(document only rejected alternatives and current product/state constraints,
otherwise remove the comments).
---
Nitpick comments:
In `@docs/cutover-checklist.md`:
- Around line 115-123: Update the serving-model checklist item to define
executable ASGI acceptance criteria: minimum concurrency, request timeout,
keep-alive, database-connection capacity, and ORM-safety requirements. Link the
checkbox to the deployment configuration or command that enforces these
settings, while retaining the existing WSGI option and cutover context.
In `@frontend/src/features/purchasing/StockPage.tsx`:
- Around line 25-31: Update the search query construction near searchActive in
StockPage so the request and its query key use the trimmed query value, while
preserving the existing minimum-length gating behavior.
- Around line 27-33: Update the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
In `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx`:
- Around line 19-48: Extend the PersonSelectionModal tests beyond accessibility
to cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 294-303: The person card in the person-selection rendering should
no longer be clickable via its container. Remove the card-level onClick handler
from the div around person.person_id, rely on the explicit Select button for
activation, and remove the associated event.stopPropagation() calls in the
button handlers.
- Around line 218-220: Replace the direct companies-people query invalidation in
the relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.
- Around line 534-542: Update the submit button’s onClick handler to explicitly
discard the promise returned by handleUpdate or handleCreate, matching the
existing delete-button pattern. Preserve the editingPerson conditional selection
and button behavior.
In `@frontend/src/features/shared/ListTable.tsx`:
- Line 21: Document the `renderRow` prop contract in `ListTable`:
implementations must return each row with its own stable React `key`, since the
`rows.map(renderRow)` call does not apply one. Add this requirement to the
prop’s existing documentation without changing the rendering logic.
In `@frontend/src/features/shared/useDebouncedValue.test.tsx`:
- Around line 39-46: Update the “clears a pending timer on unmount” test to
assert the fake-timer queue directly: verify vi.getTimerCount() is greater than
zero after renderHook creates the debounced timer, then verify it is zero after
unmount(). Remove the clearTimeout spy assertion.
In `@frontend/tests/e2e/helpers.ts`:
- Around line 444-446: Update createTestPurchaseOrder so supplierName uses a
collision-free identifier instead of Math.random(), preferably
crypto.randomUUID() or a timestamp combined with the Playwright worker index,
while preserving the existing “[TEST] Supplier” naming format.
In `@frontend/tests/e2e/purchasing/stock-search.spec.ts`:
- Around line 117-134: Update the response listener in the stock-search test to
count search responses based on whether they occur after the input is cleared,
rather than excluding URLs containing q=5mm. Start tracking post-clear responses
only immediately before input.fill(''), so the initial search is ignored while
any repeated q=5mm request after clearing is counted and fails the existing
assertion.
🪄 Autofix

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 Plus

Run ID: 66ca2b61-202f-4ca9-a415-b773f6995a0a

📥 Commits

Reviewing files that changed from the base of the PR and between 3030b60 and a3451f2.

📒 Files selected for processing (60)
  • docs/adr/0039-one-implementation-per-concept.md
  • docs/code-quality.md
  • docs/cutover-checklist.md
  • docs/rewrite-status.md
  • frontend/src/api/index.ts
  • frontend/src/api/query-client.ts
  • frontend/src/features/crm/CompaniesListPage.tsx
  • frontend/src/features/crm/CompanyDetailPage.tsx
  • frontend/src/features/job/JobCreatePage.tsx
  • frontend/src/features/job/JobFinishTab.tsx
  • frontend/src/features/job/JobSettingsTab.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/purchasing/JobSelect.test.ts
  • frontend/src/features/purchasing/JobSelect.tsx
  • frontend/src/features/purchasing/PoCreatePage.tsx
  • frontend/src/features/purchasing/PoDetailPage.tsx
  • frontend/src/features/purchasing/PoLinesTable.tsx
  • frontend/src/features/purchasing/PoListPage.tsx
  • frontend/src/features/purchasing/PoSummaryCard.tsx
  • frontend/src/features/purchasing/StockPage.tsx
  • frontend/src/features/purchasing/index.ts
  • frontend/src/features/purchasing/lines.test.ts
  • frontend/src/features/purchasing/lines.ts
  • frontend/src/features/purchasing/usePoLines.ts
  • frontend/src/features/reports/JobMovementReportPage.tsx
  • frontend/src/features/reports/WipReportPage.tsx
  • frontend/src/features/shared/DataTable.tsx
  • frontend/src/features/shared/ItemSelect.tsx
  • frontend/src/features/shared/ListTable.test.tsx
  • frontend/src/features/shared/ListTable.tsx
  • frontend/src/features/shared/QueryState.test.tsx
  • frontend/src/features/shared/QueryState.tsx
  • frontend/src/features/shared/SaveFailedBadge.tsx
  • frontend/src/features/shared/company/CompanyLookup.test.tsx
  • frontend/src/features/shared/company/CompanyLookup.tsx
  • frontend/src/features/shared/company/CreateCompanyModal.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.test.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.tsx
  • frontend/src/features/shared/company/PersonSelector.tsx
  • frontend/src/features/shared/company/create-company.ts
  • frontend/src/features/shared/company/index.ts
  • frontend/src/features/shared/company/xero-contact.ts
  • frontend/src/features/shared/decimal.test.ts
  • frontend/src/features/shared/decimal.ts
  • frontend/src/features/shared/useDebouncedValue.test.tsx
  • frontend/src/features/shared/useDebouncedValue.ts
  • frontend/src/features/timesheet/DailyOverviewPage.tsx
  • frontend/src/features/timesheet/SmartTimesheetTable.tsx
  • frontend/src/features/timesheet/TimesheetEntryPage.tsx
  • frontend/src/routeTree.gen.ts
  • frontend/src/routes/_authed/purchasing/po/$poId.tsx
  • frontend/src/routes/_authed/purchasing/po/create.tsx
  • frontend/src/routes/_authed/purchasing/po/index.tsx
  • frontend/src/routes/_authed/purchasing/stock.tsx
  • frontend/tests/e2e/helpers.ts
  • frontend/tests/e2e/purchasing/create-purchase-order.spec.ts
  • frontend/tests/e2e/purchasing/po-created-by.spec.ts
  • frontend/tests/e2e/purchasing/stock-search.spec.ts
💤 Files with no reviewable changes (2)
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/calc.test.ts
🛑 Comments failed to post (3)
frontend/src/features/shared/company/CompanyLookup.tsx (2)

88-99: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Ignore completion from abandoned company-creation interactions.

Both creation paths apply a completed mutation after the initiating UI state can change. A quick-create response can select an old query after the user continues searching. A modal response can select a company after the user closes the dialog.

  • frontend/src/features/shared/company/CompanyLookup.tsx#L88-L99: associate quick creation with an active request token, and select the returned company only if the request still matches the current interaction.
  • frontend/src/features/shared/company/CreateCompanyModal.tsx#L49-L64: prevent dismissal while creating or ignore a completion after the modal closes. Add regression tests for both paths.
📍 Affects 2 files
  • frontend/src/features/shared/company/CompanyLookup.tsx#L88-L99 (this comment)
  • frontend/src/features/shared/company/CreateCompanyModal.tsx#L49-L64
🤖 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 `@frontend/src/features/shared/company/CompanyLookup.tsx` around lines 88 - 99,
Associate quickCreateCompany with an active request token and call
handleCompanyCreated only when the response still matches the current lookup
interaction; otherwise ignore the completion. In
frontend/src/features/shared/company/CompanyLookup.tsx lines 88-99, update
quickCreateCompany accordingly. In
frontend/src/features/shared/company/CreateCompanyModal.tsx lines 49-64, either
prevent dismissal while creation is pending or ignore completion after the modal
closes. Add regression tests covering abandoned quick-create and modal-creation
interactions.

238-242: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Render a search failure state.

If companiesSearchRetrieveOptions rejects, search.isPending is false and this branch renders No companies found.. The user receives a false empty-result message. Render an error state when search.isError is true, and reserve the empty state for successful empty responses.

🤖 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 `@frontend/src/features/shared/company/CompanyLookup.tsx` around lines 238 -
242, Update the suggestions empty-state rendering near
companiesSearchRetrieveOptions to check search.isError first and display an
error state when the request fails. Gate the existing “No companies found”
message on a successful, non-error search so rejected requests never appear as
empty results.
frontend/src/features/shared/company/PersonSelectionModal.tsx (1)

242-281: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The delete overlay does not block the form column.

The confirmation panel is absolute inset-0 inside the people-list column only. The create/edit form and the submit button stay interactive while the confirmation is open. submitDisabled at Line 226 also ignores removeLink.isPending, so a user can start a create while a delete is in flight. Consider disabling the footer submit while deleteTarget !== null or removeLink.isPending.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
242 - 281, Update the form footer submit state in the component’s submitDisabled
logic to also disable submission whenever deleteTarget is non-null or
removeLink.isPending is true. Keep the existing confirmation overlay behavior
and ensure create/edit actions cannot start while deletion is pending or
awaiting confirmation.

corrinand others added 4 commits August 11, 2026 12:47
- CompaniesListPage/JobFinishTab: QueryState was passed the raw
isError, breaking the first-load-only rule the other converted
sites already follow -- a background refetch failure was
unmounting already-rendered content instead of leaving it on
screen.
- JobSelect: closing the picker without a selection (blur or
Escape) left `editing` true forever, so the input kept showing
the abandoned search term instead of reverting to the bound job;
the blur timer was untracked and could fire a state update after
unmount. Also added keyboard selection (arrow keys + Enter,
role=listbox/option, aria-activedescendant) -- the dropdown was
mouse-only.
- PoListPage: the row was reachable only by mouse click; added a
real Link on the PO number cell, matching CompaniesListPage's
existing pattern.
- decimal.ts: parseDecimalInput accepted anything Number() parses,
including hex/octal/binary literals and bare exponents, and sent
them to the wire verbatim as garbage Decimal strings. Validates
fixed-point syntax explicitly now.
- Stale features/company/* paths in rewrite-status.md's build-order
table, left behind by the features/shared/company move.
- Comments narrating port history or test intent rewritten to state
the rejected alternative and the constraint (ADR 0043) in
ItemSelect, SaveFailedBadge, CreateCompanyModal, CompanyLookup;
two flagged sites (the E2E-repair-loop comment, the sonner test
comment) were already compliant and left as-is.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
PR #54 (main) widened pyproject.toml's django specifier to
>=6.0,<6.2 but never regenerated the matching uv.lock, which still
recorded <6.1 in its requires-dist metadata. Every `uv run` this
session silently self-corrected the lock locally; committing that
fix rather than leaving a lockfile permanently out of sync with the
manifest it's supposed to pin.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
frontend/test-results/ was covered; run_e2e.sh's Playwright
invocation also writes artifacts relative to the repo root in some
invocations, leaving an untracked test-results/ dangling after every
E2E run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
…config
playwright.config.ts resolved .env.test, testDir, outputDir and the
html reporter's folder relative to process.cwd(). Any invocation
whose cwd wasn't frontend/ (npm --prefix from the repo root, a bare
npx playwright test) silently missed .env.test -- dropping
E2E_TEST_USERNAME/PASSWORD with no error -- or wrote artifacts to
the wrong location instead of erroring loudly, which is how a
root-level test-results/ kept reappearing. Anchored every one of
these to import.meta.dirname instead: verified from an unrelated
cwd, .env/.env.test still resolve correctly and no stray directory
gets created anywhere. The root-level gitignore entry from the
previous commit stays as a defensive backstop, but this is the real
fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
@corrin
corrin merged commit f7a8080 into mainAug 11, 2026
3 checks passed
@corrin
corrin deleted the purchasing-po branch August 11, 2026 02:01
Sign up for freeto 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.

1 participant

@corrin
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Purchasing: PO create/list/detail, stock page (29/40 specs) - #65

Merged
corrin merged 14 commits into
mainfrom
purchasing-po
Aug 11, 2026
Merged

Purchasing: PO create/list/detail, stock page (29/40 specs)#65
corrin merged 14 commits into
mainfrom
purchasing-po

Conversation

@corrin

@corrincorrin commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Summary

Ports three of the five purchasing-cluster E2E specs (po-created-by, create-purchase-order, stock-search) with a full purchasing UI: PO list/create/detail pages, an editable PO-lines grid, and a stock page. supplier-alias-search and pickup-address (the two remaining, Google-Places-dependent specs) are next.

Backend was already ported in a prior slice; this is frontend-only.

Architecture work beyond the target specs

Three rounds of adversarial review found this slice was about to leave (or was leaving) real structural debt behind, and each was fixed before merge rather than deferred:

  • features/shared/DataTable.tsx — the one owner of the editable-grid E2E contract (DataTable-row-N, data-grid-*). The new PoLinesTable would otherwise have been a third hand-rolled copy alongside SmartTimesheetTable and CostLineGrid; all three now render through it.
  • features/shared/QueryState.tsx + features/shared/ListTable.tsx — the one owner of the pending/error gate every query-backed page or panel repeats, and the plain-rows-table shell layered over it. PoListPage/StockPage had copied CompaniesListPage's loading/error/retry block verbatim; an exhaustive sweep (three passes, each catching what the last missed) found and converted every genuine instance across the frontend — PoDetailPage, CostLineGrid, JobMovementReportPage, CompanyDetailPage, JobFinishTab, DailyOverviewPage, TimesheetEntryPage. A few sites stay deliberately unconverted (embedded card widgets with richer branching than binary success/fail; one guard-clause-shaped early return matching CLAUDE.md's own preference) — reasoning recorded in rewrite-status.md.
  • features/companyfeatures/shared/company — it had no route of its own and was already cross-imported by job; this slice's PoSummaryCard would have been a third cross-domain import of what was really a shared widget library sitting in a domain-shaped directory.
  • Bug fix: PoLinesTable's item-picker label had no description fallback, so a bound stock item with a null item_code (nullable, v1 parity) misread as unbound.

ADR 0039 was strengthened with the underlying principle: architectural unification is never deferred to a later slice, and shared concepts get shared homes.

Also (unrelated to purchasing, fixed in the same branch at the user's direction): docs/rewrite-status.md and docs/cutover-checklist.md corrected — the SSE/live-updates work (Slice 3) was misfiled as a post-cutover deferral and is actually MUST-before-cutover, and the release-gate section now states both go/no-go criteria (functional parity, architecture quality) rather than only the E2E proxy for the first.

Test plan

  • npm run type-check clean
  • npx vitest run src/features — 197/197 passing
  • Cheap + expensive pre-commit gate tiers green
  • ./scripts/ops/run_e2e.sh (full 88-case suite) green
  • Three rounds of adversarial subagent review, all findings resolved

🤖 Generated with Claude Code

https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L

Summary by CodeRabbit

  • New Features
    • Added purchasing pages for stock browsing and purchase-order creation, listing, and details.
    • Added supplier and job search, purchase-order line editing, autosave, status updates, and draft-line handling.
    • Added company and contact lookup, creation, selection, and editing workflows.
    • Added debounced stock and company searches with cached results when searches are cleared.
  • Bug Fixes
    • Improved loading, error, retry, and save-failure feedback across key screens.
    • Added safer purchase-order updates when concurrent changes occur.
  • Tests
    • Expanded automated coverage for purchasing, shared controls, search, accessibility, and end-to-end workflows.

corrinand others added 10 commits August 10, 2026 22:28
PoCreatePage (CompanyLookup + reference + save -> 201 redirect),
PoListPage, PoDetailPage with PoSummaryCard (created-by input,
reference autosave, status select), usePoLines over the single PATCH
endpoint, and the missing 'po' concurrency invalidator so 412/428
recovery actually refetches. Ports createTestPurchaseOrder +
waitForPoAutosave and the po-created-by spec.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
PoLinesTable as the third grid on the useReactTable + useDraftRows
pattern (phantom row, no add-line button, row-exit draft commit —
unit-cost stays the row's last focusable cell so the spec's Tab exits
the row). ItemSelect generalised for stock-only consumers (optional
jobId/line, label + wrapper overrides; labour-rates query gated on
jobId presence only, since textOnly labels need rate names). Inline
JobSelect over purchasing_all_jobs_retrieve — the unfiltered endpoint
v1's PO page uses, because fresh jobs are draft and the filtered
sibling excludes them. Spec ported with the autosave waiter armed
before the pick/status clicks (v2 saves immediately; v1's debounce is
what made arm-after work).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Spec-lean StockPage: full active-stock list on load, 300ms-debounced
server FTS from 3 characters, enabled-gated so clearing the box
renders the cached list with no /search/ request.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
…ports
The purchasing slice's PoLinesTable was about to become a third
hand-rolled grid emitting the DataTable-row-N/data-grid-* contract
inline. features/shared/DataTable.tsx is now the one owner of that
contract; SmartTimesheetTable, CostLineGrid and PoLinesTable all
render through it.
Auditing further found the same pathology in the plain list pages:
PoListPage and StockPage (both new this slice) had copied
CompaniesListPage's table shell and loading/error/retry block
verbatim, taking an existing duplicate (also in WipReportPage) from
2 instances to 4. features/shared/ListTable.tsx is the one owner of
that block instead -- deliberately separate from DataTable, since it
has no react-table dependency and forcing static lists through
column-def machinery would be indirection, not rigor. A hand-rolled
debounce-into-query-state pattern in CompaniesListPage and StockPage
is now features/shared/useDebouncedValue.ts (KanbanSearchInput keeps
its own URL-driven debounce -- a different concept).
ItemSelect, the decimal helpers and the Save-failed badge move to
features/shared/ because purchasing consumed them cross-domain -- a
domain feature is not a library.
ADR 0039 strengthened: unification is never deferred, shared concepts
get shared homes, the bar is reference quality.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Adversarial review on the branch found the ListTable audit had stopped
short: JobMovementReportPage and CompanyDetailPage hand-rolled the same
loading/error/retry block ListTable was built to own (6 real instances,
only 4 fixed). Split the block itself out as
features/shared/QueryState.tsx -- the pending/error gate alone, no
table -- so it fits pages that show something other than a table too.
ListTable now composes QueryState instead of duplicating it. PoDetailPage,
CostLineGrid, JobMovementReportPage and CompanyDetailPage all render
through it.
Fixed: PoLinesTable's item-picker label read `item_code ?? 'Select Item'`
with no description fallback, so a bound stock item with a null code
(nullable, v1 parity) misread as unbound. Now poLineItemLabel() in
lines.ts, unit-tested.
Moved features/company to features/shared/company: it was already
cross-imported by features/job (JobCreatePage, JobSettingsTab) before
this slice added a third importer (purchasing/PoSummaryCard) -- it never
had a route of its own, a shared widget library in a domain-shaped box.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Reversed 2026-08-11. The 2026-08-10 record said SSE ships with the
production-serving decision, filed under "Post-cutover -- decided,
deliberately NOT before 15 August." That was overturned: racing bad
architecture into production defeats the point of the rewrite
(non-negotiable #3 in the Cutover section), and the interim polling
shape plus the un-runnable apps/xero/sync_stream.py view are exactly
that. Slice 3 -- live updates done properly (serving model fix + SSE
ticker + discard the interim shortcuts) moves to its own MUST-tier
section with a milestone checkbox, and every stale "deferred
post-cutover" cross-reference in the file is corrected. The
purchasing-slice PR is unaffected -- it is unrelated and merges as
planned; this correction only fixes the durable record for the next
session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
QueryState grew optional loadingNode/errorNode override props so a
spinner-based caller keeps its visual shell instead of losing it to
the plain-text default. JobFinishTab, DailyOverviewPage, and both
gates in TimesheetEntryPage's EntryWorkspace now render through it.
XeroQuoteCard, JobInvoiceCard and JobSettingsTab's pay-item field stay
excluded -- richer branching than binary success/fail, not the
page-level gate QueryState owns. TimesheetEntryPage's own outer gate
stays as guard-clause `if` returns, matching CLAUDE.md's stated
preference rather than converting to a shape that would abandon it.
Also: rewrite-status.md's narration of "review round found X, we
fixed Y" replaced with what the outcomes actually are -- which shared
component owns which contract, which sites are deliberately excluded
and why, which constraints a future change must respect. And the
Cutover section now states the actual two-question go/no-go criteria
(functional parity, proxied by MUST-tier E2E; materially better
architecture, judged directly) rather than only the E2E proxy, with
the honest fallback (abort and stay on v1) stated explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
@coderabbitai

coderabbitaiBot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@corrin, you've reached your PR review limit, so we couldn't start this review.

Next review available in:12 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 756f5951-a815-47ee-9870-21c9be6081d2

📥 Commits

Reviewing files that changed from the base of the PR and between a3451f2 and ac028e2.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (14)
  • .gitignore
  • docs/rewrite-status.md
  • frontend/playwright.config.ts
  • frontend/src/features/crm/CompaniesListPage.tsx
  • frontend/src/features/job/JobFinishTab.tsx
  • frontend/src/features/purchasing/JobSelect.test.tsx
  • frontend/src/features/purchasing/JobSelect.tsx
  • frontend/src/features/purchasing/PoListPage.tsx
  • frontend/src/features/shared/ItemSelect.tsx
  • frontend/src/features/shared/SaveFailedBadge.tsx
  • frontend/src/features/shared/company/CompanyLookup.tsx
  • frontend/src/features/shared/company/CreateCompanyModal.tsx
  • frontend/src/features/shared/decimal.test.ts
  • frontend/src/features/shared/decimal.ts
📝 Walkthrough

Walkthrough

The PR adds authenticated purchasing pages for stock and purchase orders, including editable lines, autosave, optimistic updates, and E2E coverage. It also extracts shared query, table, company, decimal, and debounce components and updates architecture and cutover documentation.

Changes

Purchasing frontend

Layer / File(s)Summary
Purchasing contracts and persistence
frontend/src/api/..., frontend/src/features/purchasing/JobSelect.tsx, frontend/src/features/purchasing/lines.ts, frontend/src/features/purchasing/usePoLines.ts
Adds purchasing API exports, job filtering, PO draft utilities, optimistic mutations, concurrency invalidation, and refetch reconciliation.
Purchasing pages and routing
frontend/src/features/purchasing/..., frontend/src/routes/_authed/purchasing/..., frontend/src/routeTree.gen.ts
Adds PO list, create, detail, editable line, summary, and stock-search pages with authenticated routes.
Purchasing validation
frontend/tests/e2e/purchasing/..., frontend/tests/e2e/helpers.ts, frontend/src/features/purchasing/*.test.ts
Adds unit and E2E coverage for PO creation, autosave, status updates, created-by values, job filtering, draft serialization, and stock search.

Shared frontend consolidation

Layer / File(s)Summary
Shared query and grid infrastructure
frontend/src/features/shared/..., frontend/src/features/crm/..., frontend/src/features/job/..., frontend/src/features/reports/..., frontend/src/features/timesheet/...
Adds QueryState, ListTable, DataTable, SaveFailedBadge, decimal utilities, and useDebouncedValue, then adopts them across existing pages.
Shared company and person features
frontend/src/features/shared/company/..., frontend/src/features/job/JobCreatePage.tsx, frontend/src/features/job/JobSettingsTab.tsx
Adds shared company lookup, creation, Xero validation, person selection, and person management components, and updates consumers to use the shared module.

Architecture and cutover documentation

Layer / File(s)Summary
Architecture and release rules
docs/adr/0039-one-implementation-per-concept.md, docs/cutover-checklist.md, docs/rewrite-status.md, docs/code-quality.md
Documents immediate implementation unification, independent functionality and architecture gates, threaded serving requirements, purchasing progress, and updated suppression counts.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 54.24% 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 nameStatusExplanation
Title check✅ PassedThe title clearly identifies the purchasing pages and spec progress covered by the pull request.
Description check✅ PassedThe description explains the scope, architectural changes, remaining work, and validation results in sufficient detail.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch purchasing-po

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.

@coderabbitaicoderabbitaiBot 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: 10

🧹 Nitpick comments (11)
docs/cutover-checklist.md (1)

115-123: 🩺 Stability & Availability | 🔵 Trivial

Define the ASGI acceptance criteria.

or the ASGI equivalent is not an executable serving requirement. Define the minimum concurrency, timeout, keep-alive, database-connection, and ORM-safety requirements. Link this checkbox to the deployment configuration that enforces them.

🤖 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 `@docs/cutover-checklist.md` around lines 115 - 123, Update the serving-model
checklist item to define executable ASGI acceptance criteria: minimum
concurrency, request timeout, keep-alive, database-connection capacity, and
ORM-safety requirements. Link the checkbox to the deployment configuration or
command that enforces these settings, while retaining the existing WSGI option
and cutover context.
frontend/src/features/purchasing/StockPage.tsx (2)

25-31: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Send the trimmed query.

searchActive is computed from query.trim(), but the request sends the untrimmed query. For an input of " abc" the gate opens and q is " abc". Each whitespace variation also produces a distinct query key, which adds duplicate cache entries and duplicate requests for the same search term.

♻️ Proposed fix
- const searchActive = query.trim().length >= MIN_QUERY_LENGTH+ const trimmedQuery = query.trim()+ const searchActive = trimmedQuery.length >= MIN_QUERY_LENGTH
const list = useQuery(purchasingStockListOptions())
const search = useQuery({
- ...purchasingStockSearchRetrieveOptions({ query: { q: query } }),+ ...purchasingStockSearchRetrieveOptions({ query: { q: trimmedQuery } }),
enabled: searchActive,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 25 - 31, Update
the search query construction near searchActive in StockPage so the request and
its query key use the trimmed query value, while preserving the existing
minimum-length gating behavior.

27-33: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Preserve the previous search results while fetching a new query.

Each active search term creates a new query key. search.isPending then replaces the table with Loading stock items.... Use placeholderData: keepPreviousData to keep the current rows visible during the fetch.

♻️ Proposed refactor
-import { useQuery } from '`@tanstack/react-query`'+import { keepPreviousData, useQuery } from '`@tanstack/react-query`'
 const search = useQuery({
...purchasingStockSearchRetrieveOptions({ query: { q: query } }),
enabled: searchActive,
+ placeholderData: keepPreviousData,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 27 - 33, Update
the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
frontend/tests/e2e/helpers.ts (1)

444-446: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace Math.random with a collision-free identifier.

Math.floor(Math.random() * 100000) gives 100,000 possible values. Parallel Playwright workers and repeated local runs can produce the same supplier name. A duplicate [TEST] Supplier N creates a second live Xero contact push with the same name, which makes later lookups ambiguous.

Use crypto.randomUUID() or combine the timestamp with the worker index.

♻️ Proposed change
- const randomSuffix = Math.floor(Math.random() * 100000)- const supplierName = `[TEST] Supplier ${randomSuffix}`+ const randomSuffix = crypto.randomUUID().slice(0, 8)+ const supplierName = `[TEST] Supplier ${randomSuffix}`
🤖 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 `@frontend/tests/e2e/helpers.ts` around lines 444 - 446, Update
createTestPurchaseOrder so supplierName uses a collision-free identifier instead
of Math.random(), preferably crypto.randomUUID() or a timestamp combined with
the Playwright worker index, while preserving the existing “[TEST] Supplier”
naming format.
frontend/tests/e2e/purchasing/stock-search.spec.ts (1)

117-134: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The counter filter can miss a regression.

The listener ignores every /search/ response whose URL contains q=5mm. The exclusion exists to skip the first search, but it also hides the most likely regression: clearing the input re-fires the same q=5mm request. That request is a real post-clear search call, and the assertion at Line 134 still passes.

Gate on time instead of on the query value.

♻️ Proposed change
- // Track whether any further /search/ request fires when we clear the box.+ // Gate on the clear action, not on the query value: a re-fired `q=5mm`+ // request after clearing is still a post-clear search call.
let postClearSearchCalls = 0
+ let cleared = false
page.on('response', (response) => {
- if (- response.url().includes('/api/purchasing/stock/search/') &&- !response.url().includes('q=5mm')- ) {+ if (cleared && response.url().includes('/api/purchasing/stock/search/')) {
postClearSearchCalls += 1
}
})
+ cleared = true
await input.fill('')
🤖 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 `@frontend/tests/e2e/purchasing/stock-search.spec.ts` around lines 117 - 134,
Update the response listener in the stock-search test to count search responses
based on whether they occur after the input is cleared, rather than excluding
URLs containing q=5mm. Start tracking post-clear responses only immediately
before input.fill(''), so the initial search is ignored while any repeated q=5mm
request after clearing is counted and fails the existing assertion.
frontend/src/features/shared/ListTable.tsx (1)

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

Document that renderRow must supply the React key.

Line 66 calls rows.map(renderRow) and applies no key. Every current caller sets key on its own <tr>. That contract is not stated on the prop, so a future caller can omit the key and cause a React key warning plus incorrect row reconciliation.

♻️ Proposed doc addition
+ /** Must set a stable React `key` on the returned row element; ListTable+ maps rows directly and applies no key of its own. */
renderRow: (row: TRow) => ReactNode

Also applies to: 66-66

🤖 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 `@frontend/src/features/shared/ListTable.tsx` at line 21, Document the
`renderRow` prop contract in `ListTable`: implementations must return each row
with its own stable React `key`, since the `rows.map(renderRow)` call does not
apply one. Add this requirement to the prop’s existing documentation without
changing the rendering logic.
frontend/src/features/shared/company/PersonSelectionModal.test.tsx (1)

19-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering the create and update flows.

The single test covers accessibility only. handleCreate and handleUpdate hold the business rules: first person is always primary, blank optional fields are omitted from the request body, and an invalid email blocks submission. These rules are unit-testable with mocked mutations.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx` around
lines 19 - 48, Extend the PersonSelectionModal tests beyond accessibility to
cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
frontend/src/features/shared/company/PersonSelectionModal.tsx (3)

294-303: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The person card is a clickable div.

The card carries onClick but no role, tabIndex, or key handler. Keyboard users reach the person through the hover-revealed Select button, which group-focus-within exposes, so the flow is not blocked. Removing the card-level onClick and relying on the explicit Select button would remove the duplicated activation path and the event.stopPropagation() calls at Lines 341, 354, and 367.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
294 - 303, The person card in the person-selection rendering should no longer be
clickable via its container. Remove the card-level onClick handler from the div
around person.person_id, rely on the explicit Select button for activation, and
remove the associated event.stopPropagation() calls in the button handlers.

218-220: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse invalidatePeople here.

Lines 218-220 repeat the query-key invalidation that invalidatePeople defines at Lines 118-121. handleUpdate and handleConfirmDelete already call the helper. A future key change would need two edits.

♻️ Proposed fix
- await queryClient.invalidateQueries({- queryKey: companiesPeopleListQueryKey({ path: { company_id: companyId } }),- })+ await invalidatePeople()
As per coding guidelines: "Use one implementation per concept; search before implementing and extend a near-match instead of creating a parallel implementation."
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
218 - 220, Replace the direct companies-people query invalidation in the
relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.

Source: Coding guidelines


534-542: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Handle the returned promise on the submit button.

handleUpdate and handleCreate are async. Passing them directly to onClick returns a floating promise from the event handler. The delete button at Line 272 already wraps its call with void. Use the same form here for consistency and to satisfy @typescript-eslint/no-misused-promises if that rule is enabled.

♻️ Proposed fix
- onClick={editingPerson ? handleUpdate : handleCreate}+ onClick={() => {+ void (editingPerson ? handleUpdate() : handleCreate())+ }}
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
534 - 542, Update the submit button’s onClick handler to explicitly discard the
promise returned by handleUpdate or handleCreate, matching the existing
delete-button pattern. Preserve the editingPerson conditional selection and
button behavior.
frontend/src/features/shared/useDebouncedValue.test.tsx (1)

39-46: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the fake-timer queue on unmount.

clearTimeout is global, so another cleanup can satisfy toHaveBeenCalled(). Since this suite enables fake timers, assert that vi.getTimerCount() is greater than zero after renderHook, then zero after unmount().

🤖 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 `@frontend/src/features/shared/useDebouncedValue.test.tsx` around lines 39 -
46, Update the “clears a pending timer on unmount” test to assert the fake-timer
queue directly: verify vi.getTimerCount() is greater than zero after renderHook
creates the debounced timer, then verify it is zero after unmount(). Remove the
clearTimeout spy assertion.
🤖 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 `@docs/rewrite-status.md`:
- Around line 57-61: Update the frontend rebuild table in the rewrite-status
document to replace references to features/company/PersonSelectionModal.tsx and
features/company/CompanyLookup.tsx with their canonical features/shared/company/
paths, leaving other entries unchanged.
In `@frontend/src/features/crm/CompaniesListPage.tsx`:
- Around line 113-119: Apply the first-load-only error rule at both QueryState
call sites: in frontend/src/features/crm/CompaniesListPage.tsx lines 113-119,
change the ListTable isError value to companies.isError && companies.data ===
undefined; in frontend/src/features/job/JobFinishTab.tsx lines 209-213, use a
loadError derived per query from isError && data === undefined so cached summary
and checklist content remains visible during failed refetches.
In `@frontend/src/features/purchasing/JobSelect.tsx`:
- Around line 113-150: Update JobSelect to support keyboard navigation by
tracking an active option index, moving it with ArrowUp/ArrowDown, and selecting
the active job on Enter. Add role="listbox" to the dropdown and role="option"
with aria-selected to each mapped job row, making options keyboard-accessible
and reusing the shared picker pattern if available.
- Around line 91-111: Update the closing paths in JobSelect’s onBlur timeout and
Escape handler to reset editing and clear the stale search term when the picker
closes without selection, so value returns to the bound job and filtering does
not reuse old input. Store the 150ms blur timeout in a ref and add a useEffect
cleanup that clears it on unmount, while preserving the existing select
behavior.
In `@frontend/src/features/purchasing/PoListPage.tsx`:
- Around line 57-74: Update the purchase-order row in PoListPage’s renderRow
callback to be keyboard accessible: make the tr focusable, expose it as an
interactive control, and handle keyboard activation by navigating to the same
po/$poId destination as onClick. Preserve the existing mouse navigation
behavior.
In `@frontend/src/features/shared/company/CompanyLookup.tsx`:
- Around line 238-242: Update the suggestions empty-state rendering near
companiesSearchRetrieveOptions to check search.isError first and display an
error state when the request fails. Gate the existing “No companies found”
message on a successful, non-error search so rejected requests never appear as
empty results.
- Around line 88-99: Associate quickCreateCompany with an active request token
and call handleCompanyCreated only when the response still matches the current
lookup interaction; otherwise ignore the completion. In
frontend/src/features/shared/company/CompanyLookup.tsx lines 88-99, update
quickCreateCompany accordingly. In
frontend/src/features/shared/company/CreateCompanyModal.tsx lines 49-64, either
prevent dismissal while creation is pending or ignore completion after the modal
closes. Add regression tests covering abandoned quick-create and modal-creation
interactions.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 242-281: Update the form footer submit state in the component’s
submitDisabled logic to also disable submission whenever deleteTarget is
non-null or removeLink.isPending is true. Keep the existing confirmation overlay
behavior and ensure create/edit actions cannot start while deletion is pending
or awaiting confirmation.
In `@frontend/src/features/shared/decimal.ts`:
- Around line 10-16: Update parseDecimalInput to validate cleaned input against
an explicit fixed-point decimal syntax before numeric finiteness checks,
rejecting hex, octal, binary, and other non-decimal forms. Normalize accepted
values such as “.5”, “+5”, and “5.” into the backend-supported decimal
representation, and ensure trimDecimal cannot return invalid syntax verbatim.
In `@frontend/src/features/shared/ItemSelect.tsx`:
- Around line 19-43: Rewrite or remove the comments at
frontend/src/features/shared/ItemSelect.tsx:19-43, replacing implementation,
E2E, and history narration with the rejected API alternative and factual
constraint, or remove them. Apply the same constraint-based approach at
frontend/src/features/shared/SaveFailedBadge.tsx:1-4,
frontend/src/features/shared/decimal.test.ts:33-34 (including the rejected
formatting behavior and exponent constraint),
frontend/src/features/shared/company/CompanyLookup.test.tsx:154-155 (including
the Sonner rendering constraint if retained),
frontend/src/features/shared/company/CompanyLookup.tsx:88-90, and
frontend/src/features/shared/company/CreateCompanyModal.tsx:16-26 and :39
(document only rejected alternatives and current product/state constraints,
otherwise remove the comments).
---
Nitpick comments:
In `@docs/cutover-checklist.md`:
- Around line 115-123: Update the serving-model checklist item to define
executable ASGI acceptance criteria: minimum concurrency, request timeout,
keep-alive, database-connection capacity, and ORM-safety requirements. Link the
checkbox to the deployment configuration or command that enforces these
settings, while retaining the existing WSGI option and cutover context.
In `@frontend/src/features/purchasing/StockPage.tsx`:
- Around line 25-31: Update the search query construction near searchActive in
StockPage so the request and its query key use the trimmed query value, while
preserving the existing minimum-length gating behavior.
- Around line 27-33: Update the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
In `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx`:
- Around line 19-48: Extend the PersonSelectionModal tests beyond accessibility
to cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 294-303: The person card in the person-selection rendering should
no longer be clickable via its container. Remove the card-level onClick handler
from the div around person.person_id, rely on the explicit Select button for
activation, and remove the associated event.stopPropagation() calls in the
button handlers.
- Around line 218-220: Replace the direct companies-people query invalidation in
the relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.
- Around line 534-542: Update the submit button’s onClick handler to explicitly
discard the promise returned by handleUpdate or handleCreate, matching the
existing delete-button pattern. Preserve the editingPerson conditional selection
and button behavior.
In `@frontend/src/features/shared/ListTable.tsx`:
- Line 21: Document the `renderRow` prop contract in `ListTable`:
implementations must return each row with its own stable React `key`, since the
`rows.map(renderRow)` call does not apply one. Add this requirement to the
prop’s existing documentation without changing the rendering logic.
In `@frontend/src/features/shared/useDebouncedValue.test.tsx`:
- Around line 39-46: Update the “clears a pending timer on unmount” test to
assert the fake-timer queue directly: verify vi.getTimerCount() is greater than
zero after renderHook creates the debounced timer, then verify it is zero after
unmount(). Remove the clearTimeout spy assertion.
In `@frontend/tests/e2e/helpers.ts`:
- Around line 444-446: Update createTestPurchaseOrder so supplierName uses a
collision-free identifier instead of Math.random(), preferably
crypto.randomUUID() or a timestamp combined with the Playwright worker index,
while preserving the existing “[TEST] Supplier” naming format.
In `@frontend/tests/e2e/purchasing/stock-search.spec.ts`:
- Around line 117-134: Update the response listener in the stock-search test to
count search responses based on whether they occur after the input is cleared,
rather than excluding URLs containing q=5mm. Start tracking post-clear responses
only immediately before input.fill(''), so the initial search is ignored while
any repeated q=5mm request after clearing is counted and fails the existing
assertion.
🪄 Autofix

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 Plus

Run ID: 66ca2b61-202f-4ca9-a415-b773f6995a0a

📥 Commits

Reviewing files that changed from the base of the PR and between 3030b60 and a3451f2.

📒 Files selected for processing (60)
  • docs/adr/0039-one-implementation-per-concept.md
  • docs/code-quality.md
  • docs/cutover-checklist.md
  • docs/rewrite-status.md
  • frontend/src/api/index.ts
  • frontend/src/api/query-client.ts
  • frontend/src/features/crm/CompaniesListPage.tsx
  • frontend/src/features/crm/CompanyDetailPage.tsx
  • frontend/src/features/job/JobCreatePage.tsx
  • frontend/src/features/job/JobFinishTab.tsx
  • frontend/src/features/job/JobSettingsTab.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/purchasing/JobSelect.test.ts
  • frontend/src/features/purchasing/JobSelect.tsx
  • frontend/src/features/purchasing/PoCreatePage.tsx
  • frontend/src/features/purchasing/PoDetailPage.tsx
  • frontend/src/features/purchasing/PoLinesTable.tsx
  • frontend/src/features/purchasing/PoListPage.tsx
  • frontend/src/features/purchasing/PoSummaryCard.tsx
  • frontend/src/features/purchasing/StockPage.tsx
  • frontend/src/features/purchasing/index.ts
  • frontend/src/features/purchasing/lines.test.ts
  • frontend/src/features/purchasing/lines.ts
  • frontend/src/features/purchasing/usePoLines.ts
  • frontend/src/features/reports/JobMovementReportPage.tsx
  • frontend/src/features/reports/WipReportPage.tsx
  • frontend/src/features/shared/DataTable.tsx
  • frontend/src/features/shared/ItemSelect.tsx
  • frontend/src/features/shared/ListTable.test.tsx
  • frontend/src/features/shared/ListTable.tsx
  • frontend/src/features/shared/QueryState.test.tsx
  • frontend/src/features/shared/QueryState.tsx
  • frontend/src/features/shared/SaveFailedBadge.tsx
  • frontend/src/features/shared/company/CompanyLookup.test.tsx
  • frontend/src/features/shared/company/CompanyLookup.tsx
  • frontend/src/features/shared/company/CreateCompanyModal.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.test.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.tsx
  • frontend/src/features/shared/company/PersonSelector.tsx
  • frontend/src/features/shared/company/create-company.ts
  • frontend/src/features/shared/company/index.ts
  • frontend/src/features/shared/company/xero-contact.ts
  • frontend/src/features/shared/decimal.test.ts
  • frontend/src/features/shared/decimal.ts
  • frontend/src/features/shared/useDebouncedValue.test.tsx
  • frontend/src/features/shared/useDebouncedValue.ts
  • frontend/src/features/timesheet/DailyOverviewPage.tsx
  • frontend/src/features/timesheet/SmartTimesheetTable.tsx
  • frontend/src/features/timesheet/TimesheetEntryPage.tsx
  • frontend/src/routeTree.gen.ts
  • frontend/src/routes/_authed/purchasing/po/$poId.tsx
  • frontend/src/routes/_authed/purchasing/po/create.tsx
  • frontend/src/routes/_authed/purchasing/po/index.tsx
  • frontend/src/routes/_authed/purchasing/stock.tsx
  • frontend/tests/e2e/helpers.ts
  • frontend/tests/e2e/purchasing/create-purchase-order.spec.ts
  • frontend/tests/e2e/purchasing/po-created-by.spec.ts
  • frontend/tests/e2e/purchasing/stock-search.spec.ts
💤 Files with no reviewable changes (2)
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/calc.test.ts

Comment threaddocs/rewrite-status.md
Comment threadfrontend/src/features/crm/CompaniesListPage.tsx
Comment threadfrontend/src/features/purchasing/JobSelect.tsx
Comment threadfrontend/src/features/purchasing/JobSelect.tsx
Comment threadfrontend/src/features/purchasing/PoListPage.tsx
Comment threadfrontend/src/features/shared/decimal.ts
Comment threadfrontend/src/features/shared/ItemSelect.tsx

@coderabbitaicoderabbitaiBot 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.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 10

🧹 Nitpick comments (11)
docs/cutover-checklist.md (1)

115-123: 🩺 Stability & Availability | 🔵 Trivial

Define the ASGI acceptance criteria.

or the ASGI equivalent is not an executable serving requirement. Define the minimum concurrency, timeout, keep-alive, database-connection, and ORM-safety requirements. Link this checkbox to the deployment configuration that enforces them.

🤖 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 `@docs/cutover-checklist.md` around lines 115 - 123, Update the serving-model
checklist item to define executable ASGI acceptance criteria: minimum
concurrency, request timeout, keep-alive, database-connection capacity, and
ORM-safety requirements. Link the checkbox to the deployment configuration or
command that enforces these settings, while retaining the existing WSGI option
and cutover context.
frontend/src/features/purchasing/StockPage.tsx (2)

25-31: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Send the trimmed query.

searchActive is computed from query.trim(), but the request sends the untrimmed query. For an input of " abc" the gate opens and q is " abc". Each whitespace variation also produces a distinct query key, which adds duplicate cache entries and duplicate requests for the same search term.

♻️ Proposed fix
- const searchActive = query.trim().length >= MIN_QUERY_LENGTH+ const trimmedQuery = query.trim()+ const searchActive = trimmedQuery.length >= MIN_QUERY_LENGTH
const list = useQuery(purchasingStockListOptions())
const search = useQuery({
- ...purchasingStockSearchRetrieveOptions({ query: { q: query } }),+ ...purchasingStockSearchRetrieveOptions({ query: { q: trimmedQuery } }),
enabled: searchActive,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 25 - 31, Update
the search query construction near searchActive in StockPage so the request and
its query key use the trimmed query value, while preserving the existing
minimum-length gating behavior.

27-33: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Preserve the previous search results while fetching a new query.

Each active search term creates a new query key. search.isPending then replaces the table with Loading stock items.... Use placeholderData: keepPreviousData to keep the current rows visible during the fetch.

♻️ Proposed refactor
-import { useQuery } from '`@tanstack/react-query`'+import { keepPreviousData, useQuery } from '`@tanstack/react-query`'
 const search = useQuery({
...purchasingStockSearchRetrieveOptions({ query: { q: query } }),
enabled: searchActive,
+ placeholderData: keepPreviousData,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 27 - 33, Update
the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
frontend/tests/e2e/helpers.ts (1)

444-446: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace Math.random with a collision-free identifier.

Math.floor(Math.random() * 100000) gives 100,000 possible values. Parallel Playwright workers and repeated local runs can produce the same supplier name. A duplicate [TEST] Supplier N creates a second live Xero contact push with the same name, which makes later lookups ambiguous.

Use crypto.randomUUID() or combine the timestamp with the worker index.

♻️ Proposed change
- const randomSuffix = Math.floor(Math.random() * 100000)- const supplierName = `[TEST] Supplier ${randomSuffix}`+ const randomSuffix = crypto.randomUUID().slice(0, 8)+ const supplierName = `[TEST] Supplier ${randomSuffix}`
🤖 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 `@frontend/tests/e2e/helpers.ts` around lines 444 - 446, Update
createTestPurchaseOrder so supplierName uses a collision-free identifier instead
of Math.random(), preferably crypto.randomUUID() or a timestamp combined with
the Playwright worker index, while preserving the existing “[TEST] Supplier”
naming format.
frontend/tests/e2e/purchasing/stock-search.spec.ts (1)

117-134: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The counter filter can miss a regression.

The listener ignores every /search/ response whose URL contains q=5mm. The exclusion exists to skip the first search, but it also hides the most likely regression: clearing the input re-fires the same q=5mm request. That request is a real post-clear search call, and the assertion at Line 134 still passes.

Gate on time instead of on the query value.

♻️ Proposed change
- // Track whether any further /search/ request fires when we clear the box.+ // Gate on the clear action, not on the query value: a re-fired `q=5mm`+ // request after clearing is still a post-clear search call.
let postClearSearchCalls = 0
+ let cleared = false
page.on('response', (response) => {
- if (- response.url().includes('/api/purchasing/stock/search/') &&- !response.url().includes('q=5mm')- ) {+ if (cleared && response.url().includes('/api/purchasing/stock/search/')) {
postClearSearchCalls += 1
}
})
+ cleared = true
await input.fill('')
🤖 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 `@frontend/tests/e2e/purchasing/stock-search.spec.ts` around lines 117 - 134,
Update the response listener in the stock-search test to count search responses
based on whether they occur after the input is cleared, rather than excluding
URLs containing q=5mm. Start tracking post-clear responses only immediately
before input.fill(''), so the initial search is ignored while any repeated q=5mm
request after clearing is counted and fails the existing assertion.
frontend/src/features/shared/ListTable.tsx (1)

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

Document that renderRow must supply the React key.

Line 66 calls rows.map(renderRow) and applies no key. Every current caller sets key on its own <tr>. That contract is not stated on the prop, so a future caller can omit the key and cause a React key warning plus incorrect row reconciliation.

♻️ Proposed doc addition
+ /** Must set a stable React `key` on the returned row element; ListTable+ maps rows directly and applies no key of its own. */
renderRow: (row: TRow) => ReactNode

Also applies to: 66-66

🤖 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 `@frontend/src/features/shared/ListTable.tsx` at line 21, Document the
`renderRow` prop contract in `ListTable`: implementations must return each row
with its own stable React `key`, since the `rows.map(renderRow)` call does not
apply one. Add this requirement to the prop’s existing documentation without
changing the rendering logic.
frontend/src/features/shared/company/PersonSelectionModal.test.tsx (1)

19-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering the create and update flows.

The single test covers accessibility only. handleCreate and handleUpdate hold the business rules: first person is always primary, blank optional fields are omitted from the request body, and an invalid email blocks submission. These rules are unit-testable with mocked mutations.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx` around
lines 19 - 48, Extend the PersonSelectionModal tests beyond accessibility to
cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
frontend/src/features/shared/company/PersonSelectionModal.tsx (3)

294-303: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The person card is a clickable div.

The card carries onClick but no role, tabIndex, or key handler. Keyboard users reach the person through the hover-revealed Select button, which group-focus-within exposes, so the flow is not blocked. Removing the card-level onClick and relying on the explicit Select button would remove the duplicated activation path and the event.stopPropagation() calls at Lines 341, 354, and 367.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
294 - 303, The person card in the person-selection rendering should no longer be
clickable via its container. Remove the card-level onClick handler from the div
around person.person_id, rely on the explicit Select button for activation, and
remove the associated event.stopPropagation() calls in the button handlers.

218-220: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse invalidatePeople here.

Lines 218-220 repeat the query-key invalidation that invalidatePeople defines at Lines 118-121. handleUpdate and handleConfirmDelete already call the helper. A future key change would need two edits.

♻️ Proposed fix
- await queryClient.invalidateQueries({- queryKey: companiesPeopleListQueryKey({ path: { company_id: companyId } }),- })+ await invalidatePeople()
As per coding guidelines: "Use one implementation per concept; search before implementing and extend a near-match instead of creating a parallel implementation."
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
218 - 220, Replace the direct companies-people query invalidation in the
relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.

Source: Coding guidelines


534-542: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Handle the returned promise on the submit button.

handleUpdate and handleCreate are async. Passing them directly to onClick returns a floating promise from the event handler. The delete button at Line 272 already wraps its call with void. Use the same form here for consistency and to satisfy @typescript-eslint/no-misused-promises if that rule is enabled.

♻️ Proposed fix
- onClick={editingPerson ? handleUpdate : handleCreate}+ onClick={() => {+ void (editingPerson ? handleUpdate() : handleCreate())+ }}
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
534 - 542, Update the submit button’s onClick handler to explicitly discard the
promise returned by handleUpdate or handleCreate, matching the existing
delete-button pattern. Preserve the editingPerson conditional selection and
button behavior.
frontend/src/features/shared/useDebouncedValue.test.tsx (1)

39-46: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the fake-timer queue on unmount.

clearTimeout is global, so another cleanup can satisfy toHaveBeenCalled(). Since this suite enables fake timers, assert that vi.getTimerCount() is greater than zero after renderHook, then zero after unmount().

🤖 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 `@frontend/src/features/shared/useDebouncedValue.test.tsx` around lines 39 -
46, Update the “clears a pending timer on unmount” test to assert the fake-timer
queue directly: verify vi.getTimerCount() is greater than zero after renderHook
creates the debounced timer, then verify it is zero after unmount(). Remove the
clearTimeout spy assertion.
🤖 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 `@docs/rewrite-status.md`:
- Around line 57-61: Update the frontend rebuild table in the rewrite-status
document to replace references to features/company/PersonSelectionModal.tsx and
features/company/CompanyLookup.tsx with their canonical features/shared/company/
paths, leaving other entries unchanged.
In `@frontend/src/features/crm/CompaniesListPage.tsx`:
- Around line 113-119: Apply the first-load-only error rule at both QueryState
call sites: in frontend/src/features/crm/CompaniesListPage.tsx lines 113-119,
change the ListTable isError value to companies.isError && companies.data ===
undefined; in frontend/src/features/job/JobFinishTab.tsx lines 209-213, use a
loadError derived per query from isError && data === undefined so cached summary
and checklist content remains visible during failed refetches.
In `@frontend/src/features/purchasing/JobSelect.tsx`:
- Around line 113-150: Update JobSelect to support keyboard navigation by
tracking an active option index, moving it with ArrowUp/ArrowDown, and selecting
the active job on Enter. Add role="listbox" to the dropdown and role="option"
with aria-selected to each mapped job row, making options keyboard-accessible
and reusing the shared picker pattern if available.
- Around line 91-111: Update the closing paths in JobSelect’s onBlur timeout and
Escape handler to reset editing and clear the stale search term when the picker
closes without selection, so value returns to the bound job and filtering does
not reuse old input. Store the 150ms blur timeout in a ref and add a useEffect
cleanup that clears it on unmount, while preserving the existing select
behavior.
In `@frontend/src/features/purchasing/PoListPage.tsx`:
- Around line 57-74: Update the purchase-order row in PoListPage’s renderRow
callback to be keyboard accessible: make the tr focusable, expose it as an
interactive control, and handle keyboard activation by navigating to the same
po/$poId destination as onClick. Preserve the existing mouse navigation
behavior.
In `@frontend/src/features/shared/company/CompanyLookup.tsx`:
- Around line 238-242: Update the suggestions empty-state rendering near
companiesSearchRetrieveOptions to check search.isError first and display an
error state when the request fails. Gate the existing “No companies found”
message on a successful, non-error search so rejected requests never appear as
empty results.
- Around line 88-99: Associate quickCreateCompany with an active request token
and call handleCompanyCreated only when the response still matches the current
lookup interaction; otherwise ignore the completion. In
frontend/src/features/shared/company/CompanyLookup.tsx lines 88-99, update
quickCreateCompany accordingly. In
frontend/src/features/shared/company/CreateCompanyModal.tsx lines 49-64, either
prevent dismissal while creation is pending or ignore completion after the modal
closes. Add regression tests covering abandoned quick-create and modal-creation
interactions.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 242-281: Update the form footer submit state in the component’s
submitDisabled logic to also disable submission whenever deleteTarget is
non-null or removeLink.isPending is true. Keep the existing confirmation overlay
behavior and ensure create/edit actions cannot start while deletion is pending
or awaiting confirmation.
In `@frontend/src/features/shared/decimal.ts`:
- Around line 10-16: Update parseDecimalInput to validate cleaned input against
an explicit fixed-point decimal syntax before numeric finiteness checks,
rejecting hex, octal, binary, and other non-decimal forms. Normalize accepted
values such as “.5”, “+5”, and “5.” into the backend-supported decimal
representation, and ensure trimDecimal cannot return invalid syntax verbatim.
In `@frontend/src/features/shared/ItemSelect.tsx`:
- Around line 19-43: Rewrite or remove the comments at
frontend/src/features/shared/ItemSelect.tsx:19-43, replacing implementation,
E2E, and history narration with the rejected API alternative and factual
constraint, or remove them. Apply the same constraint-based approach at
frontend/src/features/shared/SaveFailedBadge.tsx:1-4,
frontend/src/features/shared/decimal.test.ts:33-34 (including the rejected
formatting behavior and exponent constraint),
frontend/src/features/shared/company/CompanyLookup.test.tsx:154-155 (including
the Sonner rendering constraint if retained),
frontend/src/features/shared/company/CompanyLookup.tsx:88-90, and
frontend/src/features/shared/company/CreateCompanyModal.tsx:16-26 and :39
(document only rejected alternatives and current product/state constraints,
otherwise remove the comments).
---
Nitpick comments:
In `@docs/cutover-checklist.md`:
- Around line 115-123: Update the serving-model checklist item to define
executable ASGI acceptance criteria: minimum concurrency, request timeout,
keep-alive, database-connection capacity, and ORM-safety requirements. Link the
checkbox to the deployment configuration or command that enforces these
settings, while retaining the existing WSGI option and cutover context.
In `@frontend/src/features/purchasing/StockPage.tsx`:
- Around line 25-31: Update the search query construction near searchActive in
StockPage so the request and its query key use the trimmed query value, while
preserving the existing minimum-length gating behavior.
- Around line 27-33: Update the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
In `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx`:
- Around line 19-48: Extend the PersonSelectionModal tests beyond accessibility
to cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 294-303: The person card in the person-selection rendering should
no longer be clickable via its container. Remove the card-level onClick handler
from the div around person.person_id, rely on the explicit Select button for
activation, and remove the associated event.stopPropagation() calls in the
button handlers.
- Around line 218-220: Replace the direct companies-people query invalidation in
the relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.
- Around line 534-542: Update the submit button’s onClick handler to explicitly
discard the promise returned by handleUpdate or handleCreate, matching the
existing delete-button pattern. Preserve the editingPerson conditional selection
and button behavior.
In `@frontend/src/features/shared/ListTable.tsx`:
- Line 21: Document the `renderRow` prop contract in `ListTable`:
implementations must return each row with its own stable React `key`, since the
`rows.map(renderRow)` call does not apply one. Add this requirement to the
prop’s existing documentation without changing the rendering logic.
In `@frontend/src/features/shared/useDebouncedValue.test.tsx`:
- Around line 39-46: Update the “clears a pending timer on unmount” test to
assert the fake-timer queue directly: verify vi.getTimerCount() is greater than
zero after renderHook creates the debounced timer, then verify it is zero after
unmount(). Remove the clearTimeout spy assertion.
In `@frontend/tests/e2e/helpers.ts`:
- Around line 444-446: Update createTestPurchaseOrder so supplierName uses a
collision-free identifier instead of Math.random(), preferably
crypto.randomUUID() or a timestamp combined with the Playwright worker index,
while preserving the existing “[TEST] Supplier” naming format.
In `@frontend/tests/e2e/purchasing/stock-search.spec.ts`:
- Around line 117-134: Update the response listener in the stock-search test to
count search responses based on whether they occur after the input is cleared,
rather than excluding URLs containing q=5mm. Start tracking post-clear responses
only immediately before input.fill(''), so the initial search is ignored while
any repeated q=5mm request after clearing is counted and fails the existing
assertion.
🪄 Autofix

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 Plus

Run ID: 66ca2b61-202f-4ca9-a415-b773f6995a0a

📥 Commits

Reviewing files that changed from the base of the PR and between 3030b60 and a3451f2.

📒 Files selected for processing (60)
  • docs/adr/0039-one-implementation-per-concept.md
  • docs/code-quality.md
  • docs/cutover-checklist.md
  • docs/rewrite-status.md
  • frontend/src/api/index.ts
  • frontend/src/api/query-client.ts
  • frontend/src/features/crm/CompaniesListPage.tsx
  • frontend/src/features/crm/CompanyDetailPage.tsx
  • frontend/src/features/job/JobCreatePage.tsx
  • frontend/src/features/job/JobFinishTab.tsx
  • frontend/src/features/job/JobSettingsTab.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/purchasing/JobSelect.test.ts
  • frontend/src/features/purchasing/JobSelect.tsx
  • frontend/src/features/purchasing/PoCreatePage.tsx
  • frontend/src/features/purchasing/PoDetailPage.tsx
  • frontend/src/features/purchasing/PoLinesTable.tsx
  • frontend/src/features/purchasing/PoListPage.tsx
  • frontend/src/features/purchasing/PoSummaryCard.tsx
  • frontend/src/features/purchasing/StockPage.tsx
  • frontend/src/features/purchasing/index.ts
  • frontend/src/features/purchasing/lines.test.ts
  • frontend/src/features/purchasing/lines.ts
  • frontend/src/features/purchasing/usePoLines.ts
  • frontend/src/features/reports/JobMovementReportPage.tsx
  • frontend/src/features/reports/WipReportPage.tsx
  • frontend/src/features/shared/DataTable.tsx
  • frontend/src/features/shared/ItemSelect.tsx
  • frontend/src/features/shared/ListTable.test.tsx
  • frontend/src/features/shared/ListTable.tsx
  • frontend/src/features/shared/QueryState.test.tsx
  • frontend/src/features/shared/QueryState.tsx
  • frontend/src/features/shared/SaveFailedBadge.tsx
  • frontend/src/features/shared/company/CompanyLookup.test.tsx
  • frontend/src/features/shared/company/CompanyLookup.tsx
  • frontend/src/features/shared/company/CreateCompanyModal.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.test.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.tsx
  • frontend/src/features/shared/company/PersonSelector.tsx
  • frontend/src/features/shared/company/create-company.ts
  • frontend/src/features/shared/company/index.ts
  • frontend/src/features/shared/company/xero-contact.ts
  • frontend/src/features/shared/decimal.test.ts
  • frontend/src/features/shared/decimal.ts
  • frontend/src/features/shared/useDebouncedValue.test.tsx
  • frontend/src/features/shared/useDebouncedValue.ts
  • frontend/src/features/timesheet/DailyOverviewPage.tsx
  • frontend/src/features/timesheet/SmartTimesheetTable.tsx
  • frontend/src/features/timesheet/TimesheetEntryPage.tsx
  • frontend/src/routeTree.gen.ts
  • frontend/src/routes/_authed/purchasing/po/$poId.tsx
  • frontend/src/routes/_authed/purchasing/po/create.tsx
  • frontend/src/routes/_authed/purchasing/po/index.tsx
  • frontend/src/routes/_authed/purchasing/stock.tsx
  • frontend/tests/e2e/helpers.ts
  • frontend/tests/e2e/purchasing/create-purchase-order.spec.ts
  • frontend/tests/e2e/purchasing/po-created-by.spec.ts
  • frontend/tests/e2e/purchasing/stock-search.spec.ts
💤 Files with no reviewable changes (2)
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/calc.test.ts
🛑 Comments failed to post (3)
frontend/src/features/shared/company/CompanyLookup.tsx (2)

88-99: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Ignore completion from abandoned company-creation interactions.

Both creation paths apply a completed mutation after the initiating UI state can change. A quick-create response can select an old query after the user continues searching. A modal response can select a company after the user closes the dialog.

  • frontend/src/features/shared/company/CompanyLookup.tsx#L88-L99: associate quick creation with an active request token, and select the returned company only if the request still matches the current interaction.
  • frontend/src/features/shared/company/CreateCompanyModal.tsx#L49-L64: prevent dismissal while creating or ignore a completion after the modal closes. Add regression tests for both paths.
📍 Affects 2 files
  • frontend/src/features/shared/company/CompanyLookup.tsx#L88-L99 (this comment)
  • frontend/src/features/shared/company/CreateCompanyModal.tsx#L49-L64
🤖 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 `@frontend/src/features/shared/company/CompanyLookup.tsx` around lines 88 - 99,
Associate quickCreateCompany with an active request token and call
handleCompanyCreated only when the response still matches the current lookup
interaction; otherwise ignore the completion. In
frontend/src/features/shared/company/CompanyLookup.tsx lines 88-99, update
quickCreateCompany accordingly. In
frontend/src/features/shared/company/CreateCompanyModal.tsx lines 49-64, either
prevent dismissal while creation is pending or ignore completion after the modal
closes. Add regression tests covering abandoned quick-create and modal-creation
interactions.

238-242: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Render a search failure state.

If companiesSearchRetrieveOptions rejects, search.isPending is false and this branch renders No companies found.. The user receives a false empty-result message. Render an error state when search.isError is true, and reserve the empty state for successful empty responses.

🤖 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 `@frontend/src/features/shared/company/CompanyLookup.tsx` around lines 238 -
242, Update the suggestions empty-state rendering near
companiesSearchRetrieveOptions to check search.isError first and display an
error state when the request fails. Gate the existing “No companies found”
message on a successful, non-error search so rejected requests never appear as
empty results.
frontend/src/features/shared/company/PersonSelectionModal.tsx (1)

242-281: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The delete overlay does not block the form column.

The confirmation panel is absolute inset-0 inside the people-list column only. The create/edit form and the submit button stay interactive while the confirmation is open. submitDisabled at Line 226 also ignores removeLink.isPending, so a user can start a create while a delete is in flight. Consider disabling the footer submit while deleteTarget !== null or removeLink.isPending.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
242 - 281, Update the form footer submit state in the component’s submitDisabled
logic to also disable submission whenever deleteTarget is non-null or
removeLink.isPending is true. Keep the existing confirmation overlay behavior
and ensure create/edit actions cannot start while deletion is pending or
awaiting confirmation.

corrinand others added 4 commits August 11, 2026 12:47
- CompaniesListPage/JobFinishTab: QueryState was passed the raw
isError, breaking the first-load-only rule the other converted
sites already follow -- a background refetch failure was
unmounting already-rendered content instead of leaving it on
screen.
- JobSelect: closing the picker without a selection (blur or
Escape) left `editing` true forever, so the input kept showing
the abandoned search term instead of reverting to the bound job;
the blur timer was untracked and could fire a state update after
unmount. Also added keyboard selection (arrow keys + Enter,
role=listbox/option, aria-activedescendant) -- the dropdown was
mouse-only.
- PoListPage: the row was reachable only by mouse click; added a
real Link on the PO number cell, matching CompaniesListPage's
existing pattern.
- decimal.ts: parseDecimalInput accepted anything Number() parses,
including hex/octal/binary literals and bare exponents, and sent
them to the wire verbatim as garbage Decimal strings. Validates
fixed-point syntax explicitly now.
- Stale features/company/* paths in rewrite-status.md's build-order
table, left behind by the features/shared/company move.
- Comments narrating port history or test intent rewritten to state
the rejected alternative and the constraint (ADR 0043) in
ItemSelect, SaveFailedBadge, CreateCompanyModal, CompanyLookup;
two flagged sites (the E2E-repair-loop comment, the sonner test
comment) were already compliant and left as-is.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
PR #54 (main) widened pyproject.toml's django specifier to
>=6.0,<6.2 but never regenerated the matching uv.lock, which still
recorded <6.1 in its requires-dist metadata. Every `uv run` this
session silently self-corrected the lock locally; committing that
fix rather than leaving a lockfile permanently out of sync with the
manifest it's supposed to pin.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
frontend/test-results/ was covered; run_e2e.sh's Playwright
invocation also writes artifacts relative to the repo root in some
invocations, leaving an untracked test-results/ dangling after every
E2E run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
…config
playwright.config.ts resolved .env.test, testDir, outputDir and the
html reporter's folder relative to process.cwd(). Any invocation
whose cwd wasn't frontend/ (npm --prefix from the repo root, a bare
npx playwright test) silently missed .env.test -- dropping
E2E_TEST_USERNAME/PASSWORD with no error -- or wrote artifacts to
the wrong location instead of erroring loudly, which is how a
root-level test-results/ kept reappearing. Anchored every one of
these to import.meta.dirname instead: verified from an unrelated
cwd, .env/.env.test still resolve correctly and no stray directory
gets created anywhere. The root-level gitignore entry from the
previous commit stays as a defensive backstop, but this is the real
fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
@corrin
corrin merged commit f7a8080 into mainAug 11, 2026
3 checks passed
@corrin
corrin deleted the purchasing-po branch August 11, 2026 02:01
Sign up for freeto 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.

1 participant

@corrin
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Purchasing: PO create/list/detail, stock page (29/40 specs) - #65

Merged
corrin merged 14 commits into
mainfrom
purchasing-po
Aug 11, 2026
Merged

Purchasing: PO create/list/detail, stock page (29/40 specs)#65
corrin merged 14 commits into
mainfrom
purchasing-po

Conversation

@corrin

@corrincorrin commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Summary

Ports three of the five purchasing-cluster E2E specs (po-created-by, create-purchase-order, stock-search) with a full purchasing UI: PO list/create/detail pages, an editable PO-lines grid, and a stock page. supplier-alias-search and pickup-address (the two remaining, Google-Places-dependent specs) are next.

Backend was already ported in a prior slice; this is frontend-only.

Architecture work beyond the target specs

Three rounds of adversarial review found this slice was about to leave (or was leaving) real structural debt behind, and each was fixed before merge rather than deferred:

  • features/shared/DataTable.tsx — the one owner of the editable-grid E2E contract (DataTable-row-N, data-grid-*). The new PoLinesTable would otherwise have been a third hand-rolled copy alongside SmartTimesheetTable and CostLineGrid; all three now render through it.
  • features/shared/QueryState.tsx + features/shared/ListTable.tsx — the one owner of the pending/error gate every query-backed page or panel repeats, and the plain-rows-table shell layered over it. PoListPage/StockPage had copied CompaniesListPage's loading/error/retry block verbatim; an exhaustive sweep (three passes, each catching what the last missed) found and converted every genuine instance across the frontend — PoDetailPage, CostLineGrid, JobMovementReportPage, CompanyDetailPage, JobFinishTab, DailyOverviewPage, TimesheetEntryPage. A few sites stay deliberately unconverted (embedded card widgets with richer branching than binary success/fail; one guard-clause-shaped early return matching CLAUDE.md's own preference) — reasoning recorded in rewrite-status.md.
  • features/companyfeatures/shared/company — it had no route of its own and was already cross-imported by job; this slice's PoSummaryCard would have been a third cross-domain import of what was really a shared widget library sitting in a domain-shaped directory.
  • Bug fix: PoLinesTable's item-picker label had no description fallback, so a bound stock item with a null item_code (nullable, v1 parity) misread as unbound.

ADR 0039 was strengthened with the underlying principle: architectural unification is never deferred to a later slice, and shared concepts get shared homes.

Also (unrelated to purchasing, fixed in the same branch at the user's direction): docs/rewrite-status.md and docs/cutover-checklist.md corrected — the SSE/live-updates work (Slice 3) was misfiled as a post-cutover deferral and is actually MUST-before-cutover, and the release-gate section now states both go/no-go criteria (functional parity, architecture quality) rather than only the E2E proxy for the first.

Test plan

  • npm run type-check clean
  • npx vitest run src/features — 197/197 passing
  • Cheap + expensive pre-commit gate tiers green
  • ./scripts/ops/run_e2e.sh (full 88-case suite) green
  • Three rounds of adversarial subagent review, all findings resolved

🤖 Generated with Claude Code

https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L

Summary by CodeRabbit

  • New Features
    • Added purchasing pages for stock browsing and purchase-order creation, listing, and details.
    • Added supplier and job search, purchase-order line editing, autosave, status updates, and draft-line handling.
    • Added company and contact lookup, creation, selection, and editing workflows.
    • Added debounced stock and company searches with cached results when searches are cleared.
  • Bug Fixes
    • Improved loading, error, retry, and save-failure feedback across key screens.
    • Added safer purchase-order updates when concurrent changes occur.
  • Tests
    • Expanded automated coverage for purchasing, shared controls, search, accessibility, and end-to-end workflows.

corrinand others added 10 commits August 10, 2026 22:28
PoCreatePage (CompanyLookup + reference + save -> 201 redirect),
PoListPage, PoDetailPage with PoSummaryCard (created-by input,
reference autosave, status select), usePoLines over the single PATCH
endpoint, and the missing 'po' concurrency invalidator so 412/428
recovery actually refetches. Ports createTestPurchaseOrder +
waitForPoAutosave and the po-created-by spec.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
PoLinesTable as the third grid on the useReactTable + useDraftRows
pattern (phantom row, no add-line button, row-exit draft commit —
unit-cost stays the row's last focusable cell so the spec's Tab exits
the row). ItemSelect generalised for stock-only consumers (optional
jobId/line, label + wrapper overrides; labour-rates query gated on
jobId presence only, since textOnly labels need rate names). Inline
JobSelect over purchasing_all_jobs_retrieve — the unfiltered endpoint
v1's PO page uses, because fresh jobs are draft and the filtered
sibling excludes them. Spec ported with the autosave waiter armed
before the pick/status clicks (v2 saves immediately; v1's debounce is
what made arm-after work).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Spec-lean StockPage: full active-stock list on load, 300ms-debounced
server FTS from 3 characters, enabled-gated so clearing the box
renders the cached list with no /search/ request.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
…ports
The purchasing slice's PoLinesTable was about to become a third
hand-rolled grid emitting the DataTable-row-N/data-grid-* contract
inline. features/shared/DataTable.tsx is now the one owner of that
contract; SmartTimesheetTable, CostLineGrid and PoLinesTable all
render through it.
Auditing further found the same pathology in the plain list pages:
PoListPage and StockPage (both new this slice) had copied
CompaniesListPage's table shell and loading/error/retry block
verbatim, taking an existing duplicate (also in WipReportPage) from
2 instances to 4. features/shared/ListTable.tsx is the one owner of
that block instead -- deliberately separate from DataTable, since it
has no react-table dependency and forcing static lists through
column-def machinery would be indirection, not rigor. A hand-rolled
debounce-into-query-state pattern in CompaniesListPage and StockPage
is now features/shared/useDebouncedValue.ts (KanbanSearchInput keeps
its own URL-driven debounce -- a different concept).
ItemSelect, the decimal helpers and the Save-failed badge move to
features/shared/ because purchasing consumed them cross-domain -- a
domain feature is not a library.
ADR 0039 strengthened: unification is never deferred, shared concepts
get shared homes, the bar is reference quality.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Adversarial review on the branch found the ListTable audit had stopped
short: JobMovementReportPage and CompanyDetailPage hand-rolled the same
loading/error/retry block ListTable was built to own (6 real instances,
only 4 fixed). Split the block itself out as
features/shared/QueryState.tsx -- the pending/error gate alone, no
table -- so it fits pages that show something other than a table too.
ListTable now composes QueryState instead of duplicating it. PoDetailPage,
CostLineGrid, JobMovementReportPage and CompanyDetailPage all render
through it.
Fixed: PoLinesTable's item-picker label read `item_code ?? 'Select Item'`
with no description fallback, so a bound stock item with a null code
(nullable, v1 parity) misread as unbound. Now poLineItemLabel() in
lines.ts, unit-tested.
Moved features/company to features/shared/company: it was already
cross-imported by features/job (JobCreatePage, JobSettingsTab) before
this slice added a third importer (purchasing/PoSummaryCard) -- it never
had a route of its own, a shared widget library in a domain-shaped box.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Reversed 2026-08-11. The 2026-08-10 record said SSE ships with the
production-serving decision, filed under "Post-cutover -- decided,
deliberately NOT before 15 August." That was overturned: racing bad
architecture into production defeats the point of the rewrite
(non-negotiable #3 in the Cutover section), and the interim polling
shape plus the un-runnable apps/xero/sync_stream.py view are exactly
that. Slice 3 -- live updates done properly (serving model fix + SSE
ticker + discard the interim shortcuts) moves to its own MUST-tier
section with a milestone checkbox, and every stale "deferred
post-cutover" cross-reference in the file is corrected. The
purchasing-slice PR is unaffected -- it is unrelated and merges as
planned; this correction only fixes the durable record for the next
session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
QueryState grew optional loadingNode/errorNode override props so a
spinner-based caller keeps its visual shell instead of losing it to
the plain-text default. JobFinishTab, DailyOverviewPage, and both
gates in TimesheetEntryPage's EntryWorkspace now render through it.
XeroQuoteCard, JobInvoiceCard and JobSettingsTab's pay-item field stay
excluded -- richer branching than binary success/fail, not the
page-level gate QueryState owns. TimesheetEntryPage's own outer gate
stays as guard-clause `if` returns, matching CLAUDE.md's stated
preference rather than converting to a shape that would abandon it.
Also: rewrite-status.md's narration of "review round found X, we
fixed Y" replaced with what the outcomes actually are -- which shared
component owns which contract, which sites are deliberately excluded
and why, which constraints a future change must respect. And the
Cutover section now states the actual two-question go/no-go criteria
(functional parity, proxied by MUST-tier E2E; materially better
architecture, judged directly) rather than only the E2E proxy, with
the honest fallback (abort and stay on v1) stated explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
@coderabbitai

coderabbitaiBot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@corrin, you've reached your PR review limit, so we couldn't start this review.

Next review available in:12 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 756f5951-a815-47ee-9870-21c9be6081d2

📥 Commits

Reviewing files that changed from the base of the PR and between a3451f2 and ac028e2.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (14)
  • .gitignore
  • docs/rewrite-status.md
  • frontend/playwright.config.ts
  • frontend/src/features/crm/CompaniesListPage.tsx
  • frontend/src/features/job/JobFinishTab.tsx
  • frontend/src/features/purchasing/JobSelect.test.tsx
  • frontend/src/features/purchasing/JobSelect.tsx
  • frontend/src/features/purchasing/PoListPage.tsx
  • frontend/src/features/shared/ItemSelect.tsx
  • frontend/src/features/shared/SaveFailedBadge.tsx
  • frontend/src/features/shared/company/CompanyLookup.tsx
  • frontend/src/features/shared/company/CreateCompanyModal.tsx
  • frontend/src/features/shared/decimal.test.ts
  • frontend/src/features/shared/decimal.ts
📝 Walkthrough

Walkthrough

The PR adds authenticated purchasing pages for stock and purchase orders, including editable lines, autosave, optimistic updates, and E2E coverage. It also extracts shared query, table, company, decimal, and debounce components and updates architecture and cutover documentation.

Changes

Purchasing frontend

Layer / File(s)Summary
Purchasing contracts and persistence
frontend/src/api/..., frontend/src/features/purchasing/JobSelect.tsx, frontend/src/features/purchasing/lines.ts, frontend/src/features/purchasing/usePoLines.ts
Adds purchasing API exports, job filtering, PO draft utilities, optimistic mutations, concurrency invalidation, and refetch reconciliation.
Purchasing pages and routing
frontend/src/features/purchasing/..., frontend/src/routes/_authed/purchasing/..., frontend/src/routeTree.gen.ts
Adds PO list, create, detail, editable line, summary, and stock-search pages with authenticated routes.
Purchasing validation
frontend/tests/e2e/purchasing/..., frontend/tests/e2e/helpers.ts, frontend/src/features/purchasing/*.test.ts
Adds unit and E2E coverage for PO creation, autosave, status updates, created-by values, job filtering, draft serialization, and stock search.

Shared frontend consolidation

Layer / File(s)Summary
Shared query and grid infrastructure
frontend/src/features/shared/..., frontend/src/features/crm/..., frontend/src/features/job/..., frontend/src/features/reports/..., frontend/src/features/timesheet/...
Adds QueryState, ListTable, DataTable, SaveFailedBadge, decimal utilities, and useDebouncedValue, then adopts them across existing pages.
Shared company and person features
frontend/src/features/shared/company/..., frontend/src/features/job/JobCreatePage.tsx, frontend/src/features/job/JobSettingsTab.tsx
Adds shared company lookup, creation, Xero validation, person selection, and person management components, and updates consumers to use the shared module.

Architecture and cutover documentation

Layer / File(s)Summary
Architecture and release rules
docs/adr/0039-one-implementation-per-concept.md, docs/cutover-checklist.md, docs/rewrite-status.md, docs/code-quality.md
Documents immediate implementation unification, independent functionality and architecture gates, threaded serving requirements, purchasing progress, and updated suppression counts.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 54.24% 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 nameStatusExplanation
Title check✅ PassedThe title clearly identifies the purchasing pages and spec progress covered by the pull request.
Description check✅ PassedThe description explains the scope, architectural changes, remaining work, and validation results in sufficient detail.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch purchasing-po

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.

@coderabbitaicoderabbitaiBot 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: 10

🧹 Nitpick comments (11)
docs/cutover-checklist.md (1)

115-123: 🩺 Stability & Availability | 🔵 Trivial

Define the ASGI acceptance criteria.

or the ASGI equivalent is not an executable serving requirement. Define the minimum concurrency, timeout, keep-alive, database-connection, and ORM-safety requirements. Link this checkbox to the deployment configuration that enforces them.

🤖 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 `@docs/cutover-checklist.md` around lines 115 - 123, Update the serving-model
checklist item to define executable ASGI acceptance criteria: minimum
concurrency, request timeout, keep-alive, database-connection capacity, and
ORM-safety requirements. Link the checkbox to the deployment configuration or
command that enforces these settings, while retaining the existing WSGI option
and cutover context.
frontend/src/features/purchasing/StockPage.tsx (2)

25-31: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Send the trimmed query.

searchActive is computed from query.trim(), but the request sends the untrimmed query. For an input of " abc" the gate opens and q is " abc". Each whitespace variation also produces a distinct query key, which adds duplicate cache entries and duplicate requests for the same search term.

♻️ Proposed fix
- const searchActive = query.trim().length >= MIN_QUERY_LENGTH+ const trimmedQuery = query.trim()+ const searchActive = trimmedQuery.length >= MIN_QUERY_LENGTH
const list = useQuery(purchasingStockListOptions())
const search = useQuery({
- ...purchasingStockSearchRetrieveOptions({ query: { q: query } }),+ ...purchasingStockSearchRetrieveOptions({ query: { q: trimmedQuery } }),
enabled: searchActive,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 25 - 31, Update
the search query construction near searchActive in StockPage so the request and
its query key use the trimmed query value, while preserving the existing
minimum-length gating behavior.

27-33: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Preserve the previous search results while fetching a new query.

Each active search term creates a new query key. search.isPending then replaces the table with Loading stock items.... Use placeholderData: keepPreviousData to keep the current rows visible during the fetch.

♻️ Proposed refactor
-import { useQuery } from '`@tanstack/react-query`'+import { keepPreviousData, useQuery } from '`@tanstack/react-query`'
 const search = useQuery({
...purchasingStockSearchRetrieveOptions({ query: { q: query } }),
enabled: searchActive,
+ placeholderData: keepPreviousData,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 27 - 33, Update
the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
frontend/tests/e2e/helpers.ts (1)

444-446: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace Math.random with a collision-free identifier.

Math.floor(Math.random() * 100000) gives 100,000 possible values. Parallel Playwright workers and repeated local runs can produce the same supplier name. A duplicate [TEST] Supplier N creates a second live Xero contact push with the same name, which makes later lookups ambiguous.

Use crypto.randomUUID() or combine the timestamp with the worker index.

♻️ Proposed change
- const randomSuffix = Math.floor(Math.random() * 100000)- const supplierName = `[TEST] Supplier ${randomSuffix}`+ const randomSuffix = crypto.randomUUID().slice(0, 8)+ const supplierName = `[TEST] Supplier ${randomSuffix}`
🤖 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 `@frontend/tests/e2e/helpers.ts` around lines 444 - 446, Update
createTestPurchaseOrder so supplierName uses a collision-free identifier instead
of Math.random(), preferably crypto.randomUUID() or a timestamp combined with
the Playwright worker index, while preserving the existing “[TEST] Supplier”
naming format.
frontend/tests/e2e/purchasing/stock-search.spec.ts (1)

117-134: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The counter filter can miss a regression.

The listener ignores every /search/ response whose URL contains q=5mm. The exclusion exists to skip the first search, but it also hides the most likely regression: clearing the input re-fires the same q=5mm request. That request is a real post-clear search call, and the assertion at Line 134 still passes.

Gate on time instead of on the query value.

♻️ Proposed change
- // Track whether any further /search/ request fires when we clear the box.+ // Gate on the clear action, not on the query value: a re-fired `q=5mm`+ // request after clearing is still a post-clear search call.
let postClearSearchCalls = 0
+ let cleared = false
page.on('response', (response) => {
- if (- response.url().includes('/api/purchasing/stock/search/') &&- !response.url().includes('q=5mm')- ) {+ if (cleared && response.url().includes('/api/purchasing/stock/search/')) {
postClearSearchCalls += 1
}
})
+ cleared = true
await input.fill('')
🤖 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 `@frontend/tests/e2e/purchasing/stock-search.spec.ts` around lines 117 - 134,
Update the response listener in the stock-search test to count search responses
based on whether they occur after the input is cleared, rather than excluding
URLs containing q=5mm. Start tracking post-clear responses only immediately
before input.fill(''), so the initial search is ignored while any repeated q=5mm
request after clearing is counted and fails the existing assertion.
frontend/src/features/shared/ListTable.tsx (1)

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

Document that renderRow must supply the React key.

Line 66 calls rows.map(renderRow) and applies no key. Every current caller sets key on its own <tr>. That contract is not stated on the prop, so a future caller can omit the key and cause a React key warning plus incorrect row reconciliation.

♻️ Proposed doc addition
+ /** Must set a stable React `key` on the returned row element; ListTable+ maps rows directly and applies no key of its own. */
renderRow: (row: TRow) => ReactNode

Also applies to: 66-66

🤖 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 `@frontend/src/features/shared/ListTable.tsx` at line 21, Document the
`renderRow` prop contract in `ListTable`: implementations must return each row
with its own stable React `key`, since the `rows.map(renderRow)` call does not
apply one. Add this requirement to the prop’s existing documentation without
changing the rendering logic.
frontend/src/features/shared/company/PersonSelectionModal.test.tsx (1)

19-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering the create and update flows.

The single test covers accessibility only. handleCreate and handleUpdate hold the business rules: first person is always primary, blank optional fields are omitted from the request body, and an invalid email blocks submission. These rules are unit-testable with mocked mutations.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx` around
lines 19 - 48, Extend the PersonSelectionModal tests beyond accessibility to
cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
frontend/src/features/shared/company/PersonSelectionModal.tsx (3)

294-303: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The person card is a clickable div.

The card carries onClick but no role, tabIndex, or key handler. Keyboard users reach the person through the hover-revealed Select button, which group-focus-within exposes, so the flow is not blocked. Removing the card-level onClick and relying on the explicit Select button would remove the duplicated activation path and the event.stopPropagation() calls at Lines 341, 354, and 367.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
294 - 303, The person card in the person-selection rendering should no longer be
clickable via its container. Remove the card-level onClick handler from the div
around person.person_id, rely on the explicit Select button for activation, and
remove the associated event.stopPropagation() calls in the button handlers.

218-220: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse invalidatePeople here.

Lines 218-220 repeat the query-key invalidation that invalidatePeople defines at Lines 118-121. handleUpdate and handleConfirmDelete already call the helper. A future key change would need two edits.

♻️ Proposed fix
- await queryClient.invalidateQueries({- queryKey: companiesPeopleListQueryKey({ path: { company_id: companyId } }),- })+ await invalidatePeople()
As per coding guidelines: "Use one implementation per concept; search before implementing and extend a near-match instead of creating a parallel implementation."
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
218 - 220, Replace the direct companies-people query invalidation in the
relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.

Source: Coding guidelines


534-542: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Handle the returned promise on the submit button.

handleUpdate and handleCreate are async. Passing them directly to onClick returns a floating promise from the event handler. The delete button at Line 272 already wraps its call with void. Use the same form here for consistency and to satisfy @typescript-eslint/no-misused-promises if that rule is enabled.

♻️ Proposed fix
- onClick={editingPerson ? handleUpdate : handleCreate}+ onClick={() => {+ void (editingPerson ? handleUpdate() : handleCreate())+ }}
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
534 - 542, Update the submit button’s onClick handler to explicitly discard the
promise returned by handleUpdate or handleCreate, matching the existing
delete-button pattern. Preserve the editingPerson conditional selection and
button behavior.
frontend/src/features/shared/useDebouncedValue.test.tsx (1)

39-46: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the fake-timer queue on unmount.

clearTimeout is global, so another cleanup can satisfy toHaveBeenCalled(). Since this suite enables fake timers, assert that vi.getTimerCount() is greater than zero after renderHook, then zero after unmount().

🤖 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 `@frontend/src/features/shared/useDebouncedValue.test.tsx` around lines 39 -
46, Update the “clears a pending timer on unmount” test to assert the fake-timer
queue directly: verify vi.getTimerCount() is greater than zero after renderHook
creates the debounced timer, then verify it is zero after unmount(). Remove the
clearTimeout spy assertion.
🤖 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 `@docs/rewrite-status.md`:
- Around line 57-61: Update the frontend rebuild table in the rewrite-status
document to replace references to features/company/PersonSelectionModal.tsx and
features/company/CompanyLookup.tsx with their canonical features/shared/company/
paths, leaving other entries unchanged.
In `@frontend/src/features/crm/CompaniesListPage.tsx`:
- Around line 113-119: Apply the first-load-only error rule at both QueryState
call sites: in frontend/src/features/crm/CompaniesListPage.tsx lines 113-119,
change the ListTable isError value to companies.isError && companies.data ===
undefined; in frontend/src/features/job/JobFinishTab.tsx lines 209-213, use a
loadError derived per query from isError && data === undefined so cached summary
and checklist content remains visible during failed refetches.
In `@frontend/src/features/purchasing/JobSelect.tsx`:
- Around line 113-150: Update JobSelect to support keyboard navigation by
tracking an active option index, moving it with ArrowUp/ArrowDown, and selecting
the active job on Enter. Add role="listbox" to the dropdown and role="option"
with aria-selected to each mapped job row, making options keyboard-accessible
and reusing the shared picker pattern if available.
- Around line 91-111: Update the closing paths in JobSelect’s onBlur timeout and
Escape handler to reset editing and clear the stale search term when the picker
closes without selection, so value returns to the bound job and filtering does
not reuse old input. Store the 150ms blur timeout in a ref and add a useEffect
cleanup that clears it on unmount, while preserving the existing select
behavior.
In `@frontend/src/features/purchasing/PoListPage.tsx`:
- Around line 57-74: Update the purchase-order row in PoListPage’s renderRow
callback to be keyboard accessible: make the tr focusable, expose it as an
interactive control, and handle keyboard activation by navigating to the same
po/$poId destination as onClick. Preserve the existing mouse navigation
behavior.
In `@frontend/src/features/shared/company/CompanyLookup.tsx`:
- Around line 238-242: Update the suggestions empty-state rendering near
companiesSearchRetrieveOptions to check search.isError first and display an
error state when the request fails. Gate the existing “No companies found”
message on a successful, non-error search so rejected requests never appear as
empty results.
- Around line 88-99: Associate quickCreateCompany with an active request token
and call handleCompanyCreated only when the response still matches the current
lookup interaction; otherwise ignore the completion. In
frontend/src/features/shared/company/CompanyLookup.tsx lines 88-99, update
quickCreateCompany accordingly. In
frontend/src/features/shared/company/CreateCompanyModal.tsx lines 49-64, either
prevent dismissal while creation is pending or ignore completion after the modal
closes. Add regression tests covering abandoned quick-create and modal-creation
interactions.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 242-281: Update the form footer submit state in the component’s
submitDisabled logic to also disable submission whenever deleteTarget is
non-null or removeLink.isPending is true. Keep the existing confirmation overlay
behavior and ensure create/edit actions cannot start while deletion is pending
or awaiting confirmation.
In `@frontend/src/features/shared/decimal.ts`:
- Around line 10-16: Update parseDecimalInput to validate cleaned input against
an explicit fixed-point decimal syntax before numeric finiteness checks,
rejecting hex, octal, binary, and other non-decimal forms. Normalize accepted
values such as “.5”, “+5”, and “5.” into the backend-supported decimal
representation, and ensure trimDecimal cannot return invalid syntax verbatim.
In `@frontend/src/features/shared/ItemSelect.tsx`:
- Around line 19-43: Rewrite or remove the comments at
frontend/src/features/shared/ItemSelect.tsx:19-43, replacing implementation,
E2E, and history narration with the rejected API alternative and factual
constraint, or remove them. Apply the same constraint-based approach at
frontend/src/features/shared/SaveFailedBadge.tsx:1-4,
frontend/src/features/shared/decimal.test.ts:33-34 (including the rejected
formatting behavior and exponent constraint),
frontend/src/features/shared/company/CompanyLookup.test.tsx:154-155 (including
the Sonner rendering constraint if retained),
frontend/src/features/shared/company/CompanyLookup.tsx:88-90, and
frontend/src/features/shared/company/CreateCompanyModal.tsx:16-26 and :39
(document only rejected alternatives and current product/state constraints,
otherwise remove the comments).
---
Nitpick comments:
In `@docs/cutover-checklist.md`:
- Around line 115-123: Update the serving-model checklist item to define
executable ASGI acceptance criteria: minimum concurrency, request timeout,
keep-alive, database-connection capacity, and ORM-safety requirements. Link the
checkbox to the deployment configuration or command that enforces these
settings, while retaining the existing WSGI option and cutover context.
In `@frontend/src/features/purchasing/StockPage.tsx`:
- Around line 25-31: Update the search query construction near searchActive in
StockPage so the request and its query key use the trimmed query value, while
preserving the existing minimum-length gating behavior.
- Around line 27-33: Update the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
In `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx`:
- Around line 19-48: Extend the PersonSelectionModal tests beyond accessibility
to cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 294-303: The person card in the person-selection rendering should
no longer be clickable via its container. Remove the card-level onClick handler
from the div around person.person_id, rely on the explicit Select button for
activation, and remove the associated event.stopPropagation() calls in the
button handlers.
- Around line 218-220: Replace the direct companies-people query invalidation in
the relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.
- Around line 534-542: Update the submit button’s onClick handler to explicitly
discard the promise returned by handleUpdate or handleCreate, matching the
existing delete-button pattern. Preserve the editingPerson conditional selection
and button behavior.
In `@frontend/src/features/shared/ListTable.tsx`:
- Line 21: Document the `renderRow` prop contract in `ListTable`:
implementations must return each row with its own stable React `key`, since the
`rows.map(renderRow)` call does not apply one. Add this requirement to the
prop’s existing documentation without changing the rendering logic.
In `@frontend/src/features/shared/useDebouncedValue.test.tsx`:
- Around line 39-46: Update the “clears a pending timer on unmount” test to
assert the fake-timer queue directly: verify vi.getTimerCount() is greater than
zero after renderHook creates the debounced timer, then verify it is zero after
unmount(). Remove the clearTimeout spy assertion.
In `@frontend/tests/e2e/helpers.ts`:
- Around line 444-446: Update createTestPurchaseOrder so supplierName uses a
collision-free identifier instead of Math.random(), preferably
crypto.randomUUID() or a timestamp combined with the Playwright worker index,
while preserving the existing “[TEST] Supplier” naming format.
In `@frontend/tests/e2e/purchasing/stock-search.spec.ts`:
- Around line 117-134: Update the response listener in the stock-search test to
count search responses based on whether they occur after the input is cleared,
rather than excluding URLs containing q=5mm. Start tracking post-clear responses
only immediately before input.fill(''), so the initial search is ignored while
any repeated q=5mm request after clearing is counted and fails the existing
assertion.
🪄 Autofix

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 Plus

Run ID: 66ca2b61-202f-4ca9-a415-b773f6995a0a

📥 Commits

Reviewing files that changed from the base of the PR and between 3030b60 and a3451f2.

📒 Files selected for processing (60)
  • docs/adr/0039-one-implementation-per-concept.md
  • docs/code-quality.md
  • docs/cutover-checklist.md
  • docs/rewrite-status.md
  • frontend/src/api/index.ts
  • frontend/src/api/query-client.ts
  • frontend/src/features/crm/CompaniesListPage.tsx
  • frontend/src/features/crm/CompanyDetailPage.tsx
  • frontend/src/features/job/JobCreatePage.tsx
  • frontend/src/features/job/JobFinishTab.tsx
  • frontend/src/features/job/JobSettingsTab.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/purchasing/JobSelect.test.ts
  • frontend/src/features/purchasing/JobSelect.tsx
  • frontend/src/features/purchasing/PoCreatePage.tsx
  • frontend/src/features/purchasing/PoDetailPage.tsx
  • frontend/src/features/purchasing/PoLinesTable.tsx
  • frontend/src/features/purchasing/PoListPage.tsx
  • frontend/src/features/purchasing/PoSummaryCard.tsx
  • frontend/src/features/purchasing/StockPage.tsx
  • frontend/src/features/purchasing/index.ts
  • frontend/src/features/purchasing/lines.test.ts
  • frontend/src/features/purchasing/lines.ts
  • frontend/src/features/purchasing/usePoLines.ts
  • frontend/src/features/reports/JobMovementReportPage.tsx
  • frontend/src/features/reports/WipReportPage.tsx
  • frontend/src/features/shared/DataTable.tsx
  • frontend/src/features/shared/ItemSelect.tsx
  • frontend/src/features/shared/ListTable.test.tsx
  • frontend/src/features/shared/ListTable.tsx
  • frontend/src/features/shared/QueryState.test.tsx
  • frontend/src/features/shared/QueryState.tsx
  • frontend/src/features/shared/SaveFailedBadge.tsx
  • frontend/src/features/shared/company/CompanyLookup.test.tsx
  • frontend/src/features/shared/company/CompanyLookup.tsx
  • frontend/src/features/shared/company/CreateCompanyModal.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.test.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.tsx
  • frontend/src/features/shared/company/PersonSelector.tsx
  • frontend/src/features/shared/company/create-company.ts
  • frontend/src/features/shared/company/index.ts
  • frontend/src/features/shared/company/xero-contact.ts
  • frontend/src/features/shared/decimal.test.ts
  • frontend/src/features/shared/decimal.ts
  • frontend/src/features/shared/useDebouncedValue.test.tsx
  • frontend/src/features/shared/useDebouncedValue.ts
  • frontend/src/features/timesheet/DailyOverviewPage.tsx
  • frontend/src/features/timesheet/SmartTimesheetTable.tsx
  • frontend/src/features/timesheet/TimesheetEntryPage.tsx
  • frontend/src/routeTree.gen.ts
  • frontend/src/routes/_authed/purchasing/po/$poId.tsx
  • frontend/src/routes/_authed/purchasing/po/create.tsx
  • frontend/src/routes/_authed/purchasing/po/index.tsx
  • frontend/src/routes/_authed/purchasing/stock.tsx
  • frontend/tests/e2e/helpers.ts
  • frontend/tests/e2e/purchasing/create-purchase-order.spec.ts
  • frontend/tests/e2e/purchasing/po-created-by.spec.ts
  • frontend/tests/e2e/purchasing/stock-search.spec.ts
💤 Files with no reviewable changes (2)
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/calc.test.ts

Comment threaddocs/rewrite-status.md
Comment threadfrontend/src/features/crm/CompaniesListPage.tsx
Comment threadfrontend/src/features/purchasing/JobSelect.tsx
Comment threadfrontend/src/features/purchasing/JobSelect.tsx
Comment threadfrontend/src/features/purchasing/PoListPage.tsx
Comment threadfrontend/src/features/shared/decimal.ts
Comment threadfrontend/src/features/shared/ItemSelect.tsx

@coderabbitaicoderabbitaiBot 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.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 10

🧹 Nitpick comments (11)
docs/cutover-checklist.md (1)

115-123: 🩺 Stability & Availability | 🔵 Trivial

Define the ASGI acceptance criteria.

or the ASGI equivalent is not an executable serving requirement. Define the minimum concurrency, timeout, keep-alive, database-connection, and ORM-safety requirements. Link this checkbox to the deployment configuration that enforces them.

🤖 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 `@docs/cutover-checklist.md` around lines 115 - 123, Update the serving-model
checklist item to define executable ASGI acceptance criteria: minimum
concurrency, request timeout, keep-alive, database-connection capacity, and
ORM-safety requirements. Link the checkbox to the deployment configuration or
command that enforces these settings, while retaining the existing WSGI option
and cutover context.
frontend/src/features/purchasing/StockPage.tsx (2)

25-31: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Send the trimmed query.

searchActive is computed from query.trim(), but the request sends the untrimmed query. For an input of " abc" the gate opens and q is " abc". Each whitespace variation also produces a distinct query key, which adds duplicate cache entries and duplicate requests for the same search term.

♻️ Proposed fix
- const searchActive = query.trim().length >= MIN_QUERY_LENGTH+ const trimmedQuery = query.trim()+ const searchActive = trimmedQuery.length >= MIN_QUERY_LENGTH
const list = useQuery(purchasingStockListOptions())
const search = useQuery({
- ...purchasingStockSearchRetrieveOptions({ query: { q: query } }),+ ...purchasingStockSearchRetrieveOptions({ query: { q: trimmedQuery } }),
enabled: searchActive,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 25 - 31, Update
the search query construction near searchActive in StockPage so the request and
its query key use the trimmed query value, while preserving the existing
minimum-length gating behavior.

27-33: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Preserve the previous search results while fetching a new query.

Each active search term creates a new query key. search.isPending then replaces the table with Loading stock items.... Use placeholderData: keepPreviousData to keep the current rows visible during the fetch.

♻️ Proposed refactor
-import { useQuery } from '`@tanstack/react-query`'+import { keepPreviousData, useQuery } from '`@tanstack/react-query`'
 const search = useQuery({
...purchasingStockSearchRetrieveOptions({ query: { q: query } }),
enabled: searchActive,
+ placeholderData: keepPreviousData,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 27 - 33, Update
the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
frontend/tests/e2e/helpers.ts (1)

444-446: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace Math.random with a collision-free identifier.

Math.floor(Math.random() * 100000) gives 100,000 possible values. Parallel Playwright workers and repeated local runs can produce the same supplier name. A duplicate [TEST] Supplier N creates a second live Xero contact push with the same name, which makes later lookups ambiguous.

Use crypto.randomUUID() or combine the timestamp with the worker index.

♻️ Proposed change
- const randomSuffix = Math.floor(Math.random() * 100000)- const supplierName = `[TEST] Supplier ${randomSuffix}`+ const randomSuffix = crypto.randomUUID().slice(0, 8)+ const supplierName = `[TEST] Supplier ${randomSuffix}`
🤖 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 `@frontend/tests/e2e/helpers.ts` around lines 444 - 446, Update
createTestPurchaseOrder so supplierName uses a collision-free identifier instead
of Math.random(), preferably crypto.randomUUID() or a timestamp combined with
the Playwright worker index, while preserving the existing “[TEST] Supplier”
naming format.
frontend/tests/e2e/purchasing/stock-search.spec.ts (1)

117-134: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The counter filter can miss a regression.

The listener ignores every /search/ response whose URL contains q=5mm. The exclusion exists to skip the first search, but it also hides the most likely regression: clearing the input re-fires the same q=5mm request. That request is a real post-clear search call, and the assertion at Line 134 still passes.

Gate on time instead of on the query value.

♻️ Proposed change
- // Track whether any further /search/ request fires when we clear the box.+ // Gate on the clear action, not on the query value: a re-fired `q=5mm`+ // request after clearing is still a post-clear search call.
let postClearSearchCalls = 0
+ let cleared = false
page.on('response', (response) => {
- if (- response.url().includes('/api/purchasing/stock/search/') &&- !response.url().includes('q=5mm')- ) {+ if (cleared && response.url().includes('/api/purchasing/stock/search/')) {
postClearSearchCalls += 1
}
})
+ cleared = true
await input.fill('')
🤖 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 `@frontend/tests/e2e/purchasing/stock-search.spec.ts` around lines 117 - 134,
Update the response listener in the stock-search test to count search responses
based on whether they occur after the input is cleared, rather than excluding
URLs containing q=5mm. Start tracking post-clear responses only immediately
before input.fill(''), so the initial search is ignored while any repeated q=5mm
request after clearing is counted and fails the existing assertion.
frontend/src/features/shared/ListTable.tsx (1)

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

Document that renderRow must supply the React key.

Line 66 calls rows.map(renderRow) and applies no key. Every current caller sets key on its own <tr>. That contract is not stated on the prop, so a future caller can omit the key and cause a React key warning plus incorrect row reconciliation.

♻️ Proposed doc addition
+ /** Must set a stable React `key` on the returned row element; ListTable+ maps rows directly and applies no key of its own. */
renderRow: (row: TRow) => ReactNode

Also applies to: 66-66

🤖 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 `@frontend/src/features/shared/ListTable.tsx` at line 21, Document the
`renderRow` prop contract in `ListTable`: implementations must return each row
with its own stable React `key`, since the `rows.map(renderRow)` call does not
apply one. Add this requirement to the prop’s existing documentation without
changing the rendering logic.
frontend/src/features/shared/company/PersonSelectionModal.test.tsx (1)

19-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering the create and update flows.

The single test covers accessibility only. handleCreate and handleUpdate hold the business rules: first person is always primary, blank optional fields are omitted from the request body, and an invalid email blocks submission. These rules are unit-testable with mocked mutations.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx` around
lines 19 - 48, Extend the PersonSelectionModal tests beyond accessibility to
cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
frontend/src/features/shared/company/PersonSelectionModal.tsx (3)

294-303: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The person card is a clickable div.

The card carries onClick but no role, tabIndex, or key handler. Keyboard users reach the person through the hover-revealed Select button, which group-focus-within exposes, so the flow is not blocked. Removing the card-level onClick and relying on the explicit Select button would remove the duplicated activation path and the event.stopPropagation() calls at Lines 341, 354, and 367.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
294 - 303, The person card in the person-selection rendering should no longer be
clickable via its container. Remove the card-level onClick handler from the div
around person.person_id, rely on the explicit Select button for activation, and
remove the associated event.stopPropagation() calls in the button handlers.

218-220: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse invalidatePeople here.

Lines 218-220 repeat the query-key invalidation that invalidatePeople defines at Lines 118-121. handleUpdate and handleConfirmDelete already call the helper. A future key change would need two edits.

♻️ Proposed fix
- await queryClient.invalidateQueries({- queryKey: companiesPeopleListQueryKey({ path: { company_id: companyId } }),- })+ await invalidatePeople()
As per coding guidelines: "Use one implementation per concept; search before implementing and extend a near-match instead of creating a parallel implementation."
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
218 - 220, Replace the direct companies-people query invalidation in the
relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.

Source: Coding guidelines


534-542: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Handle the returned promise on the submit button.

handleUpdate and handleCreate are async. Passing them directly to onClick returns a floating promise from the event handler. The delete button at Line 272 already wraps its call with void. Use the same form here for consistency and to satisfy @typescript-eslint/no-misused-promises if that rule is enabled.

♻️ Proposed fix
- onClick={editingPerson ? handleUpdate : handleCreate}+ onClick={() => {+ void (editingPerson ? handleUpdate() : handleCreate())+ }}
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
534 - 542, Update the submit button’s onClick handler to explicitly discard the
promise returned by handleUpdate or handleCreate, matching the existing
delete-button pattern. Preserve the editingPerson conditional selection and
button behavior.
frontend/src/features/shared/useDebouncedValue.test.tsx (1)

39-46: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the fake-timer queue on unmount.

clearTimeout is global, so another cleanup can satisfy toHaveBeenCalled(). Since this suite enables fake timers, assert that vi.getTimerCount() is greater than zero after renderHook, then zero after unmount().

🤖 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 `@frontend/src/features/shared/useDebouncedValue.test.tsx` around lines 39 -
46, Update the “clears a pending timer on unmount” test to assert the fake-timer
queue directly: verify vi.getTimerCount() is greater than zero after renderHook
creates the debounced timer, then verify it is zero after unmount(). Remove the
clearTimeout spy assertion.
🤖 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 `@docs/rewrite-status.md`:
- Around line 57-61: Update the frontend rebuild table in the rewrite-status
document to replace references to features/company/PersonSelectionModal.tsx and
features/company/CompanyLookup.tsx with their canonical features/shared/company/
paths, leaving other entries unchanged.
In `@frontend/src/features/crm/CompaniesListPage.tsx`:
- Around line 113-119: Apply the first-load-only error rule at both QueryState
call sites: in frontend/src/features/crm/CompaniesListPage.tsx lines 113-119,
change the ListTable isError value to companies.isError && companies.data ===
undefined; in frontend/src/features/job/JobFinishTab.tsx lines 209-213, use a
loadError derived per query from isError && data === undefined so cached summary
and checklist content remains visible during failed refetches.
In `@frontend/src/features/purchasing/JobSelect.tsx`:
- Around line 113-150: Update JobSelect to support keyboard navigation by
tracking an active option index, moving it with ArrowUp/ArrowDown, and selecting
the active job on Enter. Add role="listbox" to the dropdown and role="option"
with aria-selected to each mapped job row, making options keyboard-accessible
and reusing the shared picker pattern if available.
- Around line 91-111: Update the closing paths in JobSelect’s onBlur timeout and
Escape handler to reset editing and clear the stale search term when the picker
closes without selection, so value returns to the bound job and filtering does
not reuse old input. Store the 150ms blur timeout in a ref and add a useEffect
cleanup that clears it on unmount, while preserving the existing select
behavior.
In `@frontend/src/features/purchasing/PoListPage.tsx`:
- Around line 57-74: Update the purchase-order row in PoListPage’s renderRow
callback to be keyboard accessible: make the tr focusable, expose it as an
interactive control, and handle keyboard activation by navigating to the same
po/$poId destination as onClick. Preserve the existing mouse navigation
behavior.
In `@frontend/src/features/shared/company/CompanyLookup.tsx`:
- Around line 238-242: Update the suggestions empty-state rendering near
companiesSearchRetrieveOptions to check search.isError first and display an
error state when the request fails. Gate the existing “No companies found”
message on a successful, non-error search so rejected requests never appear as
empty results.
- Around line 88-99: Associate quickCreateCompany with an active request token
and call handleCompanyCreated only when the response still matches the current
lookup interaction; otherwise ignore the completion. In
frontend/src/features/shared/company/CompanyLookup.tsx lines 88-99, update
quickCreateCompany accordingly. In
frontend/src/features/shared/company/CreateCompanyModal.tsx lines 49-64, either
prevent dismissal while creation is pending or ignore completion after the modal
closes. Add regression tests covering abandoned quick-create and modal-creation
interactions.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 242-281: Update the form footer submit state in the component’s
submitDisabled logic to also disable submission whenever deleteTarget is
non-null or removeLink.isPending is true. Keep the existing confirmation overlay
behavior and ensure create/edit actions cannot start while deletion is pending
or awaiting confirmation.
In `@frontend/src/features/shared/decimal.ts`:
- Around line 10-16: Update parseDecimalInput to validate cleaned input against
an explicit fixed-point decimal syntax before numeric finiteness checks,
rejecting hex, octal, binary, and other non-decimal forms. Normalize accepted
values such as “.5”, “+5”, and “5.” into the backend-supported decimal
representation, and ensure trimDecimal cannot return invalid syntax verbatim.
In `@frontend/src/features/shared/ItemSelect.tsx`:
- Around line 19-43: Rewrite or remove the comments at
frontend/src/features/shared/ItemSelect.tsx:19-43, replacing implementation,
E2E, and history narration with the rejected API alternative and factual
constraint, or remove them. Apply the same constraint-based approach at
frontend/src/features/shared/SaveFailedBadge.tsx:1-4,
frontend/src/features/shared/decimal.test.ts:33-34 (including the rejected
formatting behavior and exponent constraint),
frontend/src/features/shared/company/CompanyLookup.test.tsx:154-155 (including
the Sonner rendering constraint if retained),
frontend/src/features/shared/company/CompanyLookup.tsx:88-90, and
frontend/src/features/shared/company/CreateCompanyModal.tsx:16-26 and :39
(document only rejected alternatives and current product/state constraints,
otherwise remove the comments).
---
Nitpick comments:
In `@docs/cutover-checklist.md`:
- Around line 115-123: Update the serving-model checklist item to define
executable ASGI acceptance criteria: minimum concurrency, request timeout,
keep-alive, database-connection capacity, and ORM-safety requirements. Link the
checkbox to the deployment configuration or command that enforces these
settings, while retaining the existing WSGI option and cutover context.
In `@frontend/src/features/purchasing/StockPage.tsx`:
- Around line 25-31: Update the search query construction near searchActive in
StockPage so the request and its query key use the trimmed query value, while
preserving the existing minimum-length gating behavior.
- Around line 27-33: Update the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
In `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx`:
- Around line 19-48: Extend the PersonSelectionModal tests beyond accessibility
to cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 294-303: The person card in the person-selection rendering should
no longer be clickable via its container. Remove the card-level onClick handler
from the div around person.person_id, rely on the explicit Select button for
activation, and remove the associated event.stopPropagation() calls in the
button handlers.
- Around line 218-220: Replace the direct companies-people query invalidation in
the relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.
- Around line 534-542: Update the submit button’s onClick handler to explicitly
discard the promise returned by handleUpdate or handleCreate, matching the
existing delete-button pattern. Preserve the editingPerson conditional selection
and button behavior.
In `@frontend/src/features/shared/ListTable.tsx`:
- Line 21: Document the `renderRow` prop contract in `ListTable`:
implementations must return each row with its own stable React `key`, since the
`rows.map(renderRow)` call does not apply one. Add this requirement to the
prop’s existing documentation without changing the rendering logic.
In `@frontend/src/features/shared/useDebouncedValue.test.tsx`:
- Around line 39-46: Update the “clears a pending timer on unmount” test to
assert the fake-timer queue directly: verify vi.getTimerCount() is greater than
zero after renderHook creates the debounced timer, then verify it is zero after
unmount(). Remove the clearTimeout spy assertion.
In `@frontend/tests/e2e/helpers.ts`:
- Around line 444-446: Update createTestPurchaseOrder so supplierName uses a
collision-free identifier instead of Math.random(), preferably
crypto.randomUUID() or a timestamp combined with the Playwright worker index,
while preserving the existing “[TEST] Supplier” naming format.
In `@frontend/tests/e2e/purchasing/stock-search.spec.ts`:
- Around line 117-134: Update the response listener in the stock-search test to
count search responses based on whether they occur after the input is cleared,
rather than excluding URLs containing q=5mm. Start tracking post-clear responses
only immediately before input.fill(''), so the initial search is ignored while
any repeated q=5mm request after clearing is counted and fails the existing
assertion.
🪄 Autofix

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 Plus

Run ID: 66ca2b61-202f-4ca9-a415-b773f6995a0a

📥 Commits

Reviewing files that changed from the base of the PR and between 3030b60 and a3451f2.

📒 Files selected for processing (60)
  • docs/adr/0039-one-implementation-per-concept.md
  • docs/code-quality.md
  • docs/cutover-checklist.md
  • docs/rewrite-status.md
  • frontend/src/api/index.ts
  • frontend/src/api/query-client.ts
  • frontend/src/features/crm/CompaniesListPage.tsx
  • frontend/src/features/crm/CompanyDetailPage.tsx
  • frontend/src/features/job/JobCreatePage.tsx
  • frontend/src/features/job/JobFinishTab.tsx
  • frontend/src/features/job/JobSettingsTab.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/purchasing/JobSelect.test.ts
  • frontend/src/features/purchasing/JobSelect.tsx
  • frontend/src/features/purchasing/PoCreatePage.tsx
  • frontend/src/features/purchasing/PoDetailPage.tsx
  • frontend/src/features/purchasing/PoLinesTable.tsx
  • frontend/src/features/purchasing/PoListPage.tsx
  • frontend/src/features/purchasing/PoSummaryCard.tsx
  • frontend/src/features/purchasing/StockPage.tsx
  • frontend/src/features/purchasing/index.ts
  • frontend/src/features/purchasing/lines.test.ts
  • frontend/src/features/purchasing/lines.ts
  • frontend/src/features/purchasing/usePoLines.ts
  • frontend/src/features/reports/JobMovementReportPage.tsx
  • frontend/src/features/reports/WipReportPage.tsx
  • frontend/src/features/shared/DataTable.tsx
  • frontend/src/features/shared/ItemSelect.tsx
  • frontend/src/features/shared/ListTable.test.tsx
  • frontend/src/features/shared/ListTable.tsx
  • frontend/src/features/shared/QueryState.test.tsx
  • frontend/src/features/shared/QueryState.tsx
  • frontend/src/features/shared/SaveFailedBadge.tsx
  • frontend/src/features/shared/company/CompanyLookup.test.tsx
  • frontend/src/features/shared/company/CompanyLookup.tsx
  • frontend/src/features/shared/company/CreateCompanyModal.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.test.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.tsx
  • frontend/src/features/shared/company/PersonSelector.tsx
  • frontend/src/features/shared/company/create-company.ts
  • frontend/src/features/shared/company/index.ts
  • frontend/src/features/shared/company/xero-contact.ts
  • frontend/src/features/shared/decimal.test.ts
  • frontend/src/features/shared/decimal.ts
  • frontend/src/features/shared/useDebouncedValue.test.tsx
  • frontend/src/features/shared/useDebouncedValue.ts
  • frontend/src/features/timesheet/DailyOverviewPage.tsx
  • frontend/src/features/timesheet/SmartTimesheetTable.tsx
  • frontend/src/features/timesheet/TimesheetEntryPage.tsx
  • frontend/src/routeTree.gen.ts
  • frontend/src/routes/_authed/purchasing/po/$poId.tsx
  • frontend/src/routes/_authed/purchasing/po/create.tsx
  • frontend/src/routes/_authed/purchasing/po/index.tsx
  • frontend/src/routes/_authed/purchasing/stock.tsx
  • frontend/tests/e2e/helpers.ts
  • frontend/tests/e2e/purchasing/create-purchase-order.spec.ts
  • frontend/tests/e2e/purchasing/po-created-by.spec.ts
  • frontend/tests/e2e/purchasing/stock-search.spec.ts
💤 Files with no reviewable changes (2)
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/calc.test.ts
🛑 Comments failed to post (3)
frontend/src/features/shared/company/CompanyLookup.tsx (2)

88-99: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Ignore completion from abandoned company-creation interactions.

Both creation paths apply a completed mutation after the initiating UI state can change. A quick-create response can select an old query after the user continues searching. A modal response can select a company after the user closes the dialog.

  • frontend/src/features/shared/company/CompanyLookup.tsx#L88-L99: associate quick creation with an active request token, and select the returned company only if the request still matches the current interaction.
  • frontend/src/features/shared/company/CreateCompanyModal.tsx#L49-L64: prevent dismissal while creating or ignore a completion after the modal closes. Add regression tests for both paths.
📍 Affects 2 files
  • frontend/src/features/shared/company/CompanyLookup.tsx#L88-L99 (this comment)
  • frontend/src/features/shared/company/CreateCompanyModal.tsx#L49-L64
🤖 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 `@frontend/src/features/shared/company/CompanyLookup.tsx` around lines 88 - 99,
Associate quickCreateCompany with an active request token and call
handleCompanyCreated only when the response still matches the current lookup
interaction; otherwise ignore the completion. In
frontend/src/features/shared/company/CompanyLookup.tsx lines 88-99, update
quickCreateCompany accordingly. In
frontend/src/features/shared/company/CreateCompanyModal.tsx lines 49-64, either
prevent dismissal while creation is pending or ignore completion after the modal
closes. Add regression tests covering abandoned quick-create and modal-creation
interactions.

238-242: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Render a search failure state.

If companiesSearchRetrieveOptions rejects, search.isPending is false and this branch renders No companies found.. The user receives a false empty-result message. Render an error state when search.isError is true, and reserve the empty state for successful empty responses.

🤖 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 `@frontend/src/features/shared/company/CompanyLookup.tsx` around lines 238 -
242, Update the suggestions empty-state rendering near
companiesSearchRetrieveOptions to check search.isError first and display an
error state when the request fails. Gate the existing “No companies found”
message on a successful, non-error search so rejected requests never appear as
empty results.
frontend/src/features/shared/company/PersonSelectionModal.tsx (1)

242-281: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The delete overlay does not block the form column.

The confirmation panel is absolute inset-0 inside the people-list column only. The create/edit form and the submit button stay interactive while the confirmation is open. submitDisabled at Line 226 also ignores removeLink.isPending, so a user can start a create while a delete is in flight. Consider disabling the footer submit while deleteTarget !== null or removeLink.isPending.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
242 - 281, Update the form footer submit state in the component’s submitDisabled
logic to also disable submission whenever deleteTarget is non-null or
removeLink.isPending is true. Keep the existing confirmation overlay behavior
and ensure create/edit actions cannot start while deletion is pending or
awaiting confirmation.

corrinand others added 4 commits August 11, 2026 12:47
- CompaniesListPage/JobFinishTab: QueryState was passed the raw
isError, breaking the first-load-only rule the other converted
sites already follow -- a background refetch failure was
unmounting already-rendered content instead of leaving it on
screen.
- JobSelect: closing the picker without a selection (blur or
Escape) left `editing` true forever, so the input kept showing
the abandoned search term instead of reverting to the bound job;
the blur timer was untracked and could fire a state update after
unmount. Also added keyboard selection (arrow keys + Enter,
role=listbox/option, aria-activedescendant) -- the dropdown was
mouse-only.
- PoListPage: the row was reachable only by mouse click; added a
real Link on the PO number cell, matching CompaniesListPage's
existing pattern.
- decimal.ts: parseDecimalInput accepted anything Number() parses,
including hex/octal/binary literals and bare exponents, and sent
them to the wire verbatim as garbage Decimal strings. Validates
fixed-point syntax explicitly now.
- Stale features/company/* paths in rewrite-status.md's build-order
table, left behind by the features/shared/company move.
- Comments narrating port history or test intent rewritten to state
the rejected alternative and the constraint (ADR 0043) in
ItemSelect, SaveFailedBadge, CreateCompanyModal, CompanyLookup;
two flagged sites (the E2E-repair-loop comment, the sonner test
comment) were already compliant and left as-is.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
PR #54 (main) widened pyproject.toml's django specifier to
>=6.0,<6.2 but never regenerated the matching uv.lock, which still
recorded <6.1 in its requires-dist metadata. Every `uv run` this
session silently self-corrected the lock locally; committing that
fix rather than leaving a lockfile permanently out of sync with the
manifest it's supposed to pin.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
frontend/test-results/ was covered; run_e2e.sh's Playwright
invocation also writes artifacts relative to the repo root in some
invocations, leaving an untracked test-results/ dangling after every
E2E run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
…config
playwright.config.ts resolved .env.test, testDir, outputDir and the
html reporter's folder relative to process.cwd(). Any invocation
whose cwd wasn't frontend/ (npm --prefix from the repo root, a bare
npx playwright test) silently missed .env.test -- dropping
E2E_TEST_USERNAME/PASSWORD with no error -- or wrote artifacts to
the wrong location instead of erroring loudly, which is how a
root-level test-results/ kept reappearing. Anchored every one of
these to import.meta.dirname instead: verified from an unrelated
cwd, .env/.env.test still resolve correctly and no stray directory
gets created anywhere. The root-level gitignore entry from the
previous commit stays as a defensive backstop, but this is the real
fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
@corrin
corrin merged commit f7a8080 into mainAug 11, 2026
3 checks passed
@corrin
corrin deleted the purchasing-po branch August 11, 2026 02:01
Sign up for freeto 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.

1 participant

@corrin
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Purchasing: PO create/list/detail, stock page (29/40 specs) - #65

Merged
corrin merged 14 commits into
mainfrom
purchasing-po
Aug 11, 2026
Merged

Purchasing: PO create/list/detail, stock page (29/40 specs)#65
corrin merged 14 commits into
mainfrom
purchasing-po

Conversation

@corrin

@corrincorrin commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Summary

Ports three of the five purchasing-cluster E2E specs (po-created-by, create-purchase-order, stock-search) with a full purchasing UI: PO list/create/detail pages, an editable PO-lines grid, and a stock page. supplier-alias-search and pickup-address (the two remaining, Google-Places-dependent specs) are next.

Backend was already ported in a prior slice; this is frontend-only.

Architecture work beyond the target specs

Three rounds of adversarial review found this slice was about to leave (or was leaving) real structural debt behind, and each was fixed before merge rather than deferred:

  • features/shared/DataTable.tsx — the one owner of the editable-grid E2E contract (DataTable-row-N, data-grid-*). The new PoLinesTable would otherwise have been a third hand-rolled copy alongside SmartTimesheetTable and CostLineGrid; all three now render through it.
  • features/shared/QueryState.tsx + features/shared/ListTable.tsx — the one owner of the pending/error gate every query-backed page or panel repeats, and the plain-rows-table shell layered over it. PoListPage/StockPage had copied CompaniesListPage's loading/error/retry block verbatim; an exhaustive sweep (three passes, each catching what the last missed) found and converted every genuine instance across the frontend — PoDetailPage, CostLineGrid, JobMovementReportPage, CompanyDetailPage, JobFinishTab, DailyOverviewPage, TimesheetEntryPage. A few sites stay deliberately unconverted (embedded card widgets with richer branching than binary success/fail; one guard-clause-shaped early return matching CLAUDE.md's own preference) — reasoning recorded in rewrite-status.md.
  • features/companyfeatures/shared/company — it had no route of its own and was already cross-imported by job; this slice's PoSummaryCard would have been a third cross-domain import of what was really a shared widget library sitting in a domain-shaped directory.
  • Bug fix: PoLinesTable's item-picker label had no description fallback, so a bound stock item with a null item_code (nullable, v1 parity) misread as unbound.

ADR 0039 was strengthened with the underlying principle: architectural unification is never deferred to a later slice, and shared concepts get shared homes.

Also (unrelated to purchasing, fixed in the same branch at the user's direction): docs/rewrite-status.md and docs/cutover-checklist.md corrected — the SSE/live-updates work (Slice 3) was misfiled as a post-cutover deferral and is actually MUST-before-cutover, and the release-gate section now states both go/no-go criteria (functional parity, architecture quality) rather than only the E2E proxy for the first.

Test plan

  • npm run type-check clean
  • npx vitest run src/features — 197/197 passing
  • Cheap + expensive pre-commit gate tiers green
  • ./scripts/ops/run_e2e.sh (full 88-case suite) green
  • Three rounds of adversarial subagent review, all findings resolved

🤖 Generated with Claude Code

https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L

Summary by CodeRabbit

  • New Features
    • Added purchasing pages for stock browsing and purchase-order creation, listing, and details.
    • Added supplier and job search, purchase-order line editing, autosave, status updates, and draft-line handling.
    • Added company and contact lookup, creation, selection, and editing workflows.
    • Added debounced stock and company searches with cached results when searches are cleared.
  • Bug Fixes
    • Improved loading, error, retry, and save-failure feedback across key screens.
    • Added safer purchase-order updates when concurrent changes occur.
  • Tests
    • Expanded automated coverage for purchasing, shared controls, search, accessibility, and end-to-end workflows.

corrinand others added 10 commits August 10, 2026 22:28
PoCreatePage (CompanyLookup + reference + save -> 201 redirect),
PoListPage, PoDetailPage with PoSummaryCard (created-by input,
reference autosave, status select), usePoLines over the single PATCH
endpoint, and the missing 'po' concurrency invalidator so 412/428
recovery actually refetches. Ports createTestPurchaseOrder +
waitForPoAutosave and the po-created-by spec.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
PoLinesTable as the third grid on the useReactTable + useDraftRows
pattern (phantom row, no add-line button, row-exit draft commit —
unit-cost stays the row's last focusable cell so the spec's Tab exits
the row). ItemSelect generalised for stock-only consumers (optional
jobId/line, label + wrapper overrides; labour-rates query gated on
jobId presence only, since textOnly labels need rate names). Inline
JobSelect over purchasing_all_jobs_retrieve — the unfiltered endpoint
v1's PO page uses, because fresh jobs are draft and the filtered
sibling excludes them. Spec ported with the autosave waiter armed
before the pick/status clicks (v2 saves immediately; v1's debounce is
what made arm-after work).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Spec-lean StockPage: full active-stock list on load, 300ms-debounced
server FTS from 3 characters, enabled-gated so clearing the box
renders the cached list with no /search/ request.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
…ports
The purchasing slice's PoLinesTable was about to become a third
hand-rolled grid emitting the DataTable-row-N/data-grid-* contract
inline. features/shared/DataTable.tsx is now the one owner of that
contract; SmartTimesheetTable, CostLineGrid and PoLinesTable all
render through it.
Auditing further found the same pathology in the plain list pages:
PoListPage and StockPage (both new this slice) had copied
CompaniesListPage's table shell and loading/error/retry block
verbatim, taking an existing duplicate (also in WipReportPage) from
2 instances to 4. features/shared/ListTable.tsx is the one owner of
that block instead -- deliberately separate from DataTable, since it
has no react-table dependency and forcing static lists through
column-def machinery would be indirection, not rigor. A hand-rolled
debounce-into-query-state pattern in CompaniesListPage and StockPage
is now features/shared/useDebouncedValue.ts (KanbanSearchInput keeps
its own URL-driven debounce -- a different concept).
ItemSelect, the decimal helpers and the Save-failed badge move to
features/shared/ because purchasing consumed them cross-domain -- a
domain feature is not a library.
ADR 0039 strengthened: unification is never deferred, shared concepts
get shared homes, the bar is reference quality.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Adversarial review on the branch found the ListTable audit had stopped
short: JobMovementReportPage and CompanyDetailPage hand-rolled the same
loading/error/retry block ListTable was built to own (6 real instances,
only 4 fixed). Split the block itself out as
features/shared/QueryState.tsx -- the pending/error gate alone, no
table -- so it fits pages that show something other than a table too.
ListTable now composes QueryState instead of duplicating it. PoDetailPage,
CostLineGrid, JobMovementReportPage and CompanyDetailPage all render
through it.
Fixed: PoLinesTable's item-picker label read `item_code ?? 'Select Item'`
with no description fallback, so a bound stock item with a null code
(nullable, v1 parity) misread as unbound. Now poLineItemLabel() in
lines.ts, unit-tested.
Moved features/company to features/shared/company: it was already
cross-imported by features/job (JobCreatePage, JobSettingsTab) before
this slice added a third importer (purchasing/PoSummaryCard) -- it never
had a route of its own, a shared widget library in a domain-shaped box.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Reversed 2026-08-11. The 2026-08-10 record said SSE ships with the
production-serving decision, filed under "Post-cutover -- decided,
deliberately NOT before 15 August." That was overturned: racing bad
architecture into production defeats the point of the rewrite
(non-negotiable #3 in the Cutover section), and the interim polling
shape plus the un-runnable apps/xero/sync_stream.py view are exactly
that. Slice 3 -- live updates done properly (serving model fix + SSE
ticker + discard the interim shortcuts) moves to its own MUST-tier
section with a milestone checkbox, and every stale "deferred
post-cutover" cross-reference in the file is corrected. The
purchasing-slice PR is unaffected -- it is unrelated and merges as
planned; this correction only fixes the durable record for the next
session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
QueryState grew optional loadingNode/errorNode override props so a
spinner-based caller keeps its visual shell instead of losing it to
the plain-text default. JobFinishTab, DailyOverviewPage, and both
gates in TimesheetEntryPage's EntryWorkspace now render through it.
XeroQuoteCard, JobInvoiceCard and JobSettingsTab's pay-item field stay
excluded -- richer branching than binary success/fail, not the
page-level gate QueryState owns. TimesheetEntryPage's own outer gate
stays as guard-clause `if` returns, matching CLAUDE.md's stated
preference rather than converting to a shape that would abandon it.
Also: rewrite-status.md's narration of "review round found X, we
fixed Y" replaced with what the outcomes actually are -- which shared
component owns which contract, which sites are deliberately excluded
and why, which constraints a future change must respect. And the
Cutover section now states the actual two-question go/no-go criteria
(functional parity, proxied by MUST-tier E2E; materially better
architecture, judged directly) rather than only the E2E proxy, with
the honest fallback (abort and stay on v1) stated explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
@coderabbitai

coderabbitaiBot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@corrin, you've reached your PR review limit, so we couldn't start this review.

Next review available in:12 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 756f5951-a815-47ee-9870-21c9be6081d2

📥 Commits

Reviewing files that changed from the base of the PR and between a3451f2 and ac028e2.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (14)
  • .gitignore
  • docs/rewrite-status.md
  • frontend/playwright.config.ts
  • frontend/src/features/crm/CompaniesListPage.tsx
  • frontend/src/features/job/JobFinishTab.tsx
  • frontend/src/features/purchasing/JobSelect.test.tsx
  • frontend/src/features/purchasing/JobSelect.tsx
  • frontend/src/features/purchasing/PoListPage.tsx
  • frontend/src/features/shared/ItemSelect.tsx
  • frontend/src/features/shared/SaveFailedBadge.tsx
  • frontend/src/features/shared/company/CompanyLookup.tsx
  • frontend/src/features/shared/company/CreateCompanyModal.tsx
  • frontend/src/features/shared/decimal.test.ts
  • frontend/src/features/shared/decimal.ts
📝 Walkthrough

Walkthrough

The PR adds authenticated purchasing pages for stock and purchase orders, including editable lines, autosave, optimistic updates, and E2E coverage. It also extracts shared query, table, company, decimal, and debounce components and updates architecture and cutover documentation.

Changes

Purchasing frontend

Layer / File(s)Summary
Purchasing contracts and persistence
frontend/src/api/..., frontend/src/features/purchasing/JobSelect.tsx, frontend/src/features/purchasing/lines.ts, frontend/src/features/purchasing/usePoLines.ts
Adds purchasing API exports, job filtering, PO draft utilities, optimistic mutations, concurrency invalidation, and refetch reconciliation.
Purchasing pages and routing
frontend/src/features/purchasing/..., frontend/src/routes/_authed/purchasing/..., frontend/src/routeTree.gen.ts
Adds PO list, create, detail, editable line, summary, and stock-search pages with authenticated routes.
Purchasing validation
frontend/tests/e2e/purchasing/..., frontend/tests/e2e/helpers.ts, frontend/src/features/purchasing/*.test.ts
Adds unit and E2E coverage for PO creation, autosave, status updates, created-by values, job filtering, draft serialization, and stock search.

Shared frontend consolidation

Layer / File(s)Summary
Shared query and grid infrastructure
frontend/src/features/shared/..., frontend/src/features/crm/..., frontend/src/features/job/..., frontend/src/features/reports/..., frontend/src/features/timesheet/...
Adds QueryState, ListTable, DataTable, SaveFailedBadge, decimal utilities, and useDebouncedValue, then adopts them across existing pages.
Shared company and person features
frontend/src/features/shared/company/..., frontend/src/features/job/JobCreatePage.tsx, frontend/src/features/job/JobSettingsTab.tsx
Adds shared company lookup, creation, Xero validation, person selection, and person management components, and updates consumers to use the shared module.

Architecture and cutover documentation

Layer / File(s)Summary
Architecture and release rules
docs/adr/0039-one-implementation-per-concept.md, docs/cutover-checklist.md, docs/rewrite-status.md, docs/code-quality.md
Documents immediate implementation unification, independent functionality and architecture gates, threaded serving requirements, purchasing progress, and updated suppression counts.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 54.24% 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 nameStatusExplanation
Title check✅ PassedThe title clearly identifies the purchasing pages and spec progress covered by the pull request.
Description check✅ PassedThe description explains the scope, architectural changes, remaining work, and validation results in sufficient detail.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch purchasing-po

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.

@coderabbitaicoderabbitaiBot 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: 10

🧹 Nitpick comments (11)
docs/cutover-checklist.md (1)

115-123: 🩺 Stability & Availability | 🔵 Trivial

Define the ASGI acceptance criteria.

or the ASGI equivalent is not an executable serving requirement. Define the minimum concurrency, timeout, keep-alive, database-connection, and ORM-safety requirements. Link this checkbox to the deployment configuration that enforces them.

🤖 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 `@docs/cutover-checklist.md` around lines 115 - 123, Update the serving-model
checklist item to define executable ASGI acceptance criteria: minimum
concurrency, request timeout, keep-alive, database-connection capacity, and
ORM-safety requirements. Link the checkbox to the deployment configuration or
command that enforces these settings, while retaining the existing WSGI option
and cutover context.
frontend/src/features/purchasing/StockPage.tsx (2)

25-31: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Send the trimmed query.

searchActive is computed from query.trim(), but the request sends the untrimmed query. For an input of " abc" the gate opens and q is " abc". Each whitespace variation also produces a distinct query key, which adds duplicate cache entries and duplicate requests for the same search term.

♻️ Proposed fix
- const searchActive = query.trim().length >= MIN_QUERY_LENGTH+ const trimmedQuery = query.trim()+ const searchActive = trimmedQuery.length >= MIN_QUERY_LENGTH
const list = useQuery(purchasingStockListOptions())
const search = useQuery({
- ...purchasingStockSearchRetrieveOptions({ query: { q: query } }),+ ...purchasingStockSearchRetrieveOptions({ query: { q: trimmedQuery } }),
enabled: searchActive,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 25 - 31, Update
the search query construction near searchActive in StockPage so the request and
its query key use the trimmed query value, while preserving the existing
minimum-length gating behavior.

27-33: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Preserve the previous search results while fetching a new query.

Each active search term creates a new query key. search.isPending then replaces the table with Loading stock items.... Use placeholderData: keepPreviousData to keep the current rows visible during the fetch.

♻️ Proposed refactor
-import { useQuery } from '`@tanstack/react-query`'+import { keepPreviousData, useQuery } from '`@tanstack/react-query`'
 const search = useQuery({
...purchasingStockSearchRetrieveOptions({ query: { q: query } }),
enabled: searchActive,
+ placeholderData: keepPreviousData,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 27 - 33, Update
the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
frontend/tests/e2e/helpers.ts (1)

444-446: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace Math.random with a collision-free identifier.

Math.floor(Math.random() * 100000) gives 100,000 possible values. Parallel Playwright workers and repeated local runs can produce the same supplier name. A duplicate [TEST] Supplier N creates a second live Xero contact push with the same name, which makes later lookups ambiguous.

Use crypto.randomUUID() or combine the timestamp with the worker index.

♻️ Proposed change
- const randomSuffix = Math.floor(Math.random() * 100000)- const supplierName = `[TEST] Supplier ${randomSuffix}`+ const randomSuffix = crypto.randomUUID().slice(0, 8)+ const supplierName = `[TEST] Supplier ${randomSuffix}`
🤖 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 `@frontend/tests/e2e/helpers.ts` around lines 444 - 446, Update
createTestPurchaseOrder so supplierName uses a collision-free identifier instead
of Math.random(), preferably crypto.randomUUID() or a timestamp combined with
the Playwright worker index, while preserving the existing “[TEST] Supplier”
naming format.
frontend/tests/e2e/purchasing/stock-search.spec.ts (1)

117-134: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The counter filter can miss a regression.

The listener ignores every /search/ response whose URL contains q=5mm. The exclusion exists to skip the first search, but it also hides the most likely regression: clearing the input re-fires the same q=5mm request. That request is a real post-clear search call, and the assertion at Line 134 still passes.

Gate on time instead of on the query value.

♻️ Proposed change
- // Track whether any further /search/ request fires when we clear the box.+ // Gate on the clear action, not on the query value: a re-fired `q=5mm`+ // request after clearing is still a post-clear search call.
let postClearSearchCalls = 0
+ let cleared = false
page.on('response', (response) => {
- if (- response.url().includes('/api/purchasing/stock/search/') &&- !response.url().includes('q=5mm')- ) {+ if (cleared && response.url().includes('/api/purchasing/stock/search/')) {
postClearSearchCalls += 1
}
})
+ cleared = true
await input.fill('')
🤖 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 `@frontend/tests/e2e/purchasing/stock-search.spec.ts` around lines 117 - 134,
Update the response listener in the stock-search test to count search responses
based on whether they occur after the input is cleared, rather than excluding
URLs containing q=5mm. Start tracking post-clear responses only immediately
before input.fill(''), so the initial search is ignored while any repeated q=5mm
request after clearing is counted and fails the existing assertion.
frontend/src/features/shared/ListTable.tsx (1)

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

Document that renderRow must supply the React key.

Line 66 calls rows.map(renderRow) and applies no key. Every current caller sets key on its own <tr>. That contract is not stated on the prop, so a future caller can omit the key and cause a React key warning plus incorrect row reconciliation.

♻️ Proposed doc addition
+ /** Must set a stable React `key` on the returned row element; ListTable+ maps rows directly and applies no key of its own. */
renderRow: (row: TRow) => ReactNode

Also applies to: 66-66

🤖 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 `@frontend/src/features/shared/ListTable.tsx` at line 21, Document the
`renderRow` prop contract in `ListTable`: implementations must return each row
with its own stable React `key`, since the `rows.map(renderRow)` call does not
apply one. Add this requirement to the prop’s existing documentation without
changing the rendering logic.
frontend/src/features/shared/company/PersonSelectionModal.test.tsx (1)

19-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering the create and update flows.

The single test covers accessibility only. handleCreate and handleUpdate hold the business rules: first person is always primary, blank optional fields are omitted from the request body, and an invalid email blocks submission. These rules are unit-testable with mocked mutations.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx` around
lines 19 - 48, Extend the PersonSelectionModal tests beyond accessibility to
cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
frontend/src/features/shared/company/PersonSelectionModal.tsx (3)

294-303: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The person card is a clickable div.

The card carries onClick but no role, tabIndex, or key handler. Keyboard users reach the person through the hover-revealed Select button, which group-focus-within exposes, so the flow is not blocked. Removing the card-level onClick and relying on the explicit Select button would remove the duplicated activation path and the event.stopPropagation() calls at Lines 341, 354, and 367.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
294 - 303, The person card in the person-selection rendering should no longer be
clickable via its container. Remove the card-level onClick handler from the div
around person.person_id, rely on the explicit Select button for activation, and
remove the associated event.stopPropagation() calls in the button handlers.

218-220: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse invalidatePeople here.

Lines 218-220 repeat the query-key invalidation that invalidatePeople defines at Lines 118-121. handleUpdate and handleConfirmDelete already call the helper. A future key change would need two edits.

♻️ Proposed fix
- await queryClient.invalidateQueries({- queryKey: companiesPeopleListQueryKey({ path: { company_id: companyId } }),- })+ await invalidatePeople()
As per coding guidelines: "Use one implementation per concept; search before implementing and extend a near-match instead of creating a parallel implementation."
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
218 - 220, Replace the direct companies-people query invalidation in the
relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.

Source: Coding guidelines


534-542: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Handle the returned promise on the submit button.

handleUpdate and handleCreate are async. Passing them directly to onClick returns a floating promise from the event handler. The delete button at Line 272 already wraps its call with void. Use the same form here for consistency and to satisfy @typescript-eslint/no-misused-promises if that rule is enabled.

♻️ Proposed fix
- onClick={editingPerson ? handleUpdate : handleCreate}+ onClick={() => {+ void (editingPerson ? handleUpdate() : handleCreate())+ }}
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
534 - 542, Update the submit button’s onClick handler to explicitly discard the
promise returned by handleUpdate or handleCreate, matching the existing
delete-button pattern. Preserve the editingPerson conditional selection and
button behavior.
frontend/src/features/shared/useDebouncedValue.test.tsx (1)

39-46: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the fake-timer queue on unmount.

clearTimeout is global, so another cleanup can satisfy toHaveBeenCalled(). Since this suite enables fake timers, assert that vi.getTimerCount() is greater than zero after renderHook, then zero after unmount().

🤖 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 `@frontend/src/features/shared/useDebouncedValue.test.tsx` around lines 39 -
46, Update the “clears a pending timer on unmount” test to assert the fake-timer
queue directly: verify vi.getTimerCount() is greater than zero after renderHook
creates the debounced timer, then verify it is zero after unmount(). Remove the
clearTimeout spy assertion.
🤖 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 `@docs/rewrite-status.md`:
- Around line 57-61: Update the frontend rebuild table in the rewrite-status
document to replace references to features/company/PersonSelectionModal.tsx and
features/company/CompanyLookup.tsx with their canonical features/shared/company/
paths, leaving other entries unchanged.
In `@frontend/src/features/crm/CompaniesListPage.tsx`:
- Around line 113-119: Apply the first-load-only error rule at both QueryState
call sites: in frontend/src/features/crm/CompaniesListPage.tsx lines 113-119,
change the ListTable isError value to companies.isError && companies.data ===
undefined; in frontend/src/features/job/JobFinishTab.tsx lines 209-213, use a
loadError derived per query from isError && data === undefined so cached summary
and checklist content remains visible during failed refetches.
In `@frontend/src/features/purchasing/JobSelect.tsx`:
- Around line 113-150: Update JobSelect to support keyboard navigation by
tracking an active option index, moving it with ArrowUp/ArrowDown, and selecting
the active job on Enter. Add role="listbox" to the dropdown and role="option"
with aria-selected to each mapped job row, making options keyboard-accessible
and reusing the shared picker pattern if available.
- Around line 91-111: Update the closing paths in JobSelect’s onBlur timeout and
Escape handler to reset editing and clear the stale search term when the picker
closes without selection, so value returns to the bound job and filtering does
not reuse old input. Store the 150ms blur timeout in a ref and add a useEffect
cleanup that clears it on unmount, while preserving the existing select
behavior.
In `@frontend/src/features/purchasing/PoListPage.tsx`:
- Around line 57-74: Update the purchase-order row in PoListPage’s renderRow
callback to be keyboard accessible: make the tr focusable, expose it as an
interactive control, and handle keyboard activation by navigating to the same
po/$poId destination as onClick. Preserve the existing mouse navigation
behavior.
In `@frontend/src/features/shared/company/CompanyLookup.tsx`:
- Around line 238-242: Update the suggestions empty-state rendering near
companiesSearchRetrieveOptions to check search.isError first and display an
error state when the request fails. Gate the existing “No companies found”
message on a successful, non-error search so rejected requests never appear as
empty results.
- Around line 88-99: Associate quickCreateCompany with an active request token
and call handleCompanyCreated only when the response still matches the current
lookup interaction; otherwise ignore the completion. In
frontend/src/features/shared/company/CompanyLookup.tsx lines 88-99, update
quickCreateCompany accordingly. In
frontend/src/features/shared/company/CreateCompanyModal.tsx lines 49-64, either
prevent dismissal while creation is pending or ignore completion after the modal
closes. Add regression tests covering abandoned quick-create and modal-creation
interactions.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 242-281: Update the form footer submit state in the component’s
submitDisabled logic to also disable submission whenever deleteTarget is
non-null or removeLink.isPending is true. Keep the existing confirmation overlay
behavior and ensure create/edit actions cannot start while deletion is pending
or awaiting confirmation.
In `@frontend/src/features/shared/decimal.ts`:
- Around line 10-16: Update parseDecimalInput to validate cleaned input against
an explicit fixed-point decimal syntax before numeric finiteness checks,
rejecting hex, octal, binary, and other non-decimal forms. Normalize accepted
values such as “.5”, “+5”, and “5.” into the backend-supported decimal
representation, and ensure trimDecimal cannot return invalid syntax verbatim.
In `@frontend/src/features/shared/ItemSelect.tsx`:
- Around line 19-43: Rewrite or remove the comments at
frontend/src/features/shared/ItemSelect.tsx:19-43, replacing implementation,
E2E, and history narration with the rejected API alternative and factual
constraint, or remove them. Apply the same constraint-based approach at
frontend/src/features/shared/SaveFailedBadge.tsx:1-4,
frontend/src/features/shared/decimal.test.ts:33-34 (including the rejected
formatting behavior and exponent constraint),
frontend/src/features/shared/company/CompanyLookup.test.tsx:154-155 (including
the Sonner rendering constraint if retained),
frontend/src/features/shared/company/CompanyLookup.tsx:88-90, and
frontend/src/features/shared/company/CreateCompanyModal.tsx:16-26 and :39
(document only rejected alternatives and current product/state constraints,
otherwise remove the comments).
---
Nitpick comments:
In `@docs/cutover-checklist.md`:
- Around line 115-123: Update the serving-model checklist item to define
executable ASGI acceptance criteria: minimum concurrency, request timeout,
keep-alive, database-connection capacity, and ORM-safety requirements. Link the
checkbox to the deployment configuration or command that enforces these
settings, while retaining the existing WSGI option and cutover context.
In `@frontend/src/features/purchasing/StockPage.tsx`:
- Around line 25-31: Update the search query construction near searchActive in
StockPage so the request and its query key use the trimmed query value, while
preserving the existing minimum-length gating behavior.
- Around line 27-33: Update the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
In `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx`:
- Around line 19-48: Extend the PersonSelectionModal tests beyond accessibility
to cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 294-303: The person card in the person-selection rendering should
no longer be clickable via its container. Remove the card-level onClick handler
from the div around person.person_id, rely on the explicit Select button for
activation, and remove the associated event.stopPropagation() calls in the
button handlers.
- Around line 218-220: Replace the direct companies-people query invalidation in
the relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.
- Around line 534-542: Update the submit button’s onClick handler to explicitly
discard the promise returned by handleUpdate or handleCreate, matching the
existing delete-button pattern. Preserve the editingPerson conditional selection
and button behavior.
In `@frontend/src/features/shared/ListTable.tsx`:
- Line 21: Document the `renderRow` prop contract in `ListTable`:
implementations must return each row with its own stable React `key`, since the
`rows.map(renderRow)` call does not apply one. Add this requirement to the
prop’s existing documentation without changing the rendering logic.
In `@frontend/src/features/shared/useDebouncedValue.test.tsx`:
- Around line 39-46: Update the “clears a pending timer on unmount” test to
assert the fake-timer queue directly: verify vi.getTimerCount() is greater than
zero after renderHook creates the debounced timer, then verify it is zero after
unmount(). Remove the clearTimeout spy assertion.
In `@frontend/tests/e2e/helpers.ts`:
- Around line 444-446: Update createTestPurchaseOrder so supplierName uses a
collision-free identifier instead of Math.random(), preferably
crypto.randomUUID() or a timestamp combined with the Playwright worker index,
while preserving the existing “[TEST] Supplier” naming format.
In `@frontend/tests/e2e/purchasing/stock-search.spec.ts`:
- Around line 117-134: Update the response listener in the stock-search test to
count search responses based on whether they occur after the input is cleared,
rather than excluding URLs containing q=5mm. Start tracking post-clear responses
only immediately before input.fill(''), so the initial search is ignored while
any repeated q=5mm request after clearing is counted and fails the existing
assertion.
🪄 Autofix

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 Plus

Run ID: 66ca2b61-202f-4ca9-a415-b773f6995a0a

📥 Commits

Reviewing files that changed from the base of the PR and between 3030b60 and a3451f2.

📒 Files selected for processing (60)
  • docs/adr/0039-one-implementation-per-concept.md
  • docs/code-quality.md
  • docs/cutover-checklist.md
  • docs/rewrite-status.md
  • frontend/src/api/index.ts
  • frontend/src/api/query-client.ts
  • frontend/src/features/crm/CompaniesListPage.tsx
  • frontend/src/features/crm/CompanyDetailPage.tsx
  • frontend/src/features/job/JobCreatePage.tsx
  • frontend/src/features/job/JobFinishTab.tsx
  • frontend/src/features/job/JobSettingsTab.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/purchasing/JobSelect.test.ts
  • frontend/src/features/purchasing/JobSelect.tsx
  • frontend/src/features/purchasing/PoCreatePage.tsx
  • frontend/src/features/purchasing/PoDetailPage.tsx
  • frontend/src/features/purchasing/PoLinesTable.tsx
  • frontend/src/features/purchasing/PoListPage.tsx
  • frontend/src/features/purchasing/PoSummaryCard.tsx
  • frontend/src/features/purchasing/StockPage.tsx
  • frontend/src/features/purchasing/index.ts
  • frontend/src/features/purchasing/lines.test.ts
  • frontend/src/features/purchasing/lines.ts
  • frontend/src/features/purchasing/usePoLines.ts
  • frontend/src/features/reports/JobMovementReportPage.tsx
  • frontend/src/features/reports/WipReportPage.tsx
  • frontend/src/features/shared/DataTable.tsx
  • frontend/src/features/shared/ItemSelect.tsx
  • frontend/src/features/shared/ListTable.test.tsx
  • frontend/src/features/shared/ListTable.tsx
  • frontend/src/features/shared/QueryState.test.tsx
  • frontend/src/features/shared/QueryState.tsx
  • frontend/src/features/shared/SaveFailedBadge.tsx
  • frontend/src/features/shared/company/CompanyLookup.test.tsx
  • frontend/src/features/shared/company/CompanyLookup.tsx
  • frontend/src/features/shared/company/CreateCompanyModal.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.test.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.tsx
  • frontend/src/features/shared/company/PersonSelector.tsx
  • frontend/src/features/shared/company/create-company.ts
  • frontend/src/features/shared/company/index.ts
  • frontend/src/features/shared/company/xero-contact.ts
  • frontend/src/features/shared/decimal.test.ts
  • frontend/src/features/shared/decimal.ts
  • frontend/src/features/shared/useDebouncedValue.test.tsx
  • frontend/src/features/shared/useDebouncedValue.ts
  • frontend/src/features/timesheet/DailyOverviewPage.tsx
  • frontend/src/features/timesheet/SmartTimesheetTable.tsx
  • frontend/src/features/timesheet/TimesheetEntryPage.tsx
  • frontend/src/routeTree.gen.ts
  • frontend/src/routes/_authed/purchasing/po/$poId.tsx
  • frontend/src/routes/_authed/purchasing/po/create.tsx
  • frontend/src/routes/_authed/purchasing/po/index.tsx
  • frontend/src/routes/_authed/purchasing/stock.tsx
  • frontend/tests/e2e/helpers.ts
  • frontend/tests/e2e/purchasing/create-purchase-order.spec.ts
  • frontend/tests/e2e/purchasing/po-created-by.spec.ts
  • frontend/tests/e2e/purchasing/stock-search.spec.ts
💤 Files with no reviewable changes (2)
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/calc.test.ts

Comment threaddocs/rewrite-status.md
Comment threadfrontend/src/features/crm/CompaniesListPage.tsx
Comment threadfrontend/src/features/purchasing/JobSelect.tsx
Comment threadfrontend/src/features/purchasing/JobSelect.tsx
Comment threadfrontend/src/features/purchasing/PoListPage.tsx
Comment threadfrontend/src/features/shared/decimal.ts
Comment threadfrontend/src/features/shared/ItemSelect.tsx

@coderabbitaicoderabbitaiBot 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.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 10

🧹 Nitpick comments (11)
docs/cutover-checklist.md (1)

115-123: 🩺 Stability & Availability | 🔵 Trivial

Define the ASGI acceptance criteria.

or the ASGI equivalent is not an executable serving requirement. Define the minimum concurrency, timeout, keep-alive, database-connection, and ORM-safety requirements. Link this checkbox to the deployment configuration that enforces them.

🤖 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 `@docs/cutover-checklist.md` around lines 115 - 123, Update the serving-model
checklist item to define executable ASGI acceptance criteria: minimum
concurrency, request timeout, keep-alive, database-connection capacity, and
ORM-safety requirements. Link the checkbox to the deployment configuration or
command that enforces these settings, while retaining the existing WSGI option
and cutover context.
frontend/src/features/purchasing/StockPage.tsx (2)

25-31: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Send the trimmed query.

searchActive is computed from query.trim(), but the request sends the untrimmed query. For an input of " abc" the gate opens and q is " abc". Each whitespace variation also produces a distinct query key, which adds duplicate cache entries and duplicate requests for the same search term.

♻️ Proposed fix
- const searchActive = query.trim().length >= MIN_QUERY_LENGTH+ const trimmedQuery = query.trim()+ const searchActive = trimmedQuery.length >= MIN_QUERY_LENGTH
const list = useQuery(purchasingStockListOptions())
const search = useQuery({
- ...purchasingStockSearchRetrieveOptions({ query: { q: query } }),+ ...purchasingStockSearchRetrieveOptions({ query: { q: trimmedQuery } }),
enabled: searchActive,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 25 - 31, Update
the search query construction near searchActive in StockPage so the request and
its query key use the trimmed query value, while preserving the existing
minimum-length gating behavior.

27-33: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Preserve the previous search results while fetching a new query.

Each active search term creates a new query key. search.isPending then replaces the table with Loading stock items.... Use placeholderData: keepPreviousData to keep the current rows visible during the fetch.

♻️ Proposed refactor
-import { useQuery } from '`@tanstack/react-query`'+import { keepPreviousData, useQuery } from '`@tanstack/react-query`'
 const search = useQuery({
...purchasingStockSearchRetrieveOptions({ query: { q: query } }),
enabled: searchActive,
+ placeholderData: keepPreviousData,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 27 - 33, Update
the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
frontend/tests/e2e/helpers.ts (1)

444-446: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace Math.random with a collision-free identifier.

Math.floor(Math.random() * 100000) gives 100,000 possible values. Parallel Playwright workers and repeated local runs can produce the same supplier name. A duplicate [TEST] Supplier N creates a second live Xero contact push with the same name, which makes later lookups ambiguous.

Use crypto.randomUUID() or combine the timestamp with the worker index.

♻️ Proposed change
- const randomSuffix = Math.floor(Math.random() * 100000)- const supplierName = `[TEST] Supplier ${randomSuffix}`+ const randomSuffix = crypto.randomUUID().slice(0, 8)+ const supplierName = `[TEST] Supplier ${randomSuffix}`
🤖 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 `@frontend/tests/e2e/helpers.ts` around lines 444 - 446, Update
createTestPurchaseOrder so supplierName uses a collision-free identifier instead
of Math.random(), preferably crypto.randomUUID() or a timestamp combined with
the Playwright worker index, while preserving the existing “[TEST] Supplier”
naming format.
frontend/tests/e2e/purchasing/stock-search.spec.ts (1)

117-134: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The counter filter can miss a regression.

The listener ignores every /search/ response whose URL contains q=5mm. The exclusion exists to skip the first search, but it also hides the most likely regression: clearing the input re-fires the same q=5mm request. That request is a real post-clear search call, and the assertion at Line 134 still passes.

Gate on time instead of on the query value.

♻️ Proposed change
- // Track whether any further /search/ request fires when we clear the box.+ // Gate on the clear action, not on the query value: a re-fired `q=5mm`+ // request after clearing is still a post-clear search call.
let postClearSearchCalls = 0
+ let cleared = false
page.on('response', (response) => {
- if (- response.url().includes('/api/purchasing/stock/search/') &&- !response.url().includes('q=5mm')- ) {+ if (cleared && response.url().includes('/api/purchasing/stock/search/')) {
postClearSearchCalls += 1
}
})
+ cleared = true
await input.fill('')
🤖 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 `@frontend/tests/e2e/purchasing/stock-search.spec.ts` around lines 117 - 134,
Update the response listener in the stock-search test to count search responses
based on whether they occur after the input is cleared, rather than excluding
URLs containing q=5mm. Start tracking post-clear responses only immediately
before input.fill(''), so the initial search is ignored while any repeated q=5mm
request after clearing is counted and fails the existing assertion.
frontend/src/features/shared/ListTable.tsx (1)

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

Document that renderRow must supply the React key.

Line 66 calls rows.map(renderRow) and applies no key. Every current caller sets key on its own <tr>. That contract is not stated on the prop, so a future caller can omit the key and cause a React key warning plus incorrect row reconciliation.

♻️ Proposed doc addition
+ /** Must set a stable React `key` on the returned row element; ListTable+ maps rows directly and applies no key of its own. */
renderRow: (row: TRow) => ReactNode

Also applies to: 66-66

🤖 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 `@frontend/src/features/shared/ListTable.tsx` at line 21, Document the
`renderRow` prop contract in `ListTable`: implementations must return each row
with its own stable React `key`, since the `rows.map(renderRow)` call does not
apply one. Add this requirement to the prop’s existing documentation without
changing the rendering logic.
frontend/src/features/shared/company/PersonSelectionModal.test.tsx (1)

19-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering the create and update flows.

The single test covers accessibility only. handleCreate and handleUpdate hold the business rules: first person is always primary, blank optional fields are omitted from the request body, and an invalid email blocks submission. These rules are unit-testable with mocked mutations.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx` around
lines 19 - 48, Extend the PersonSelectionModal tests beyond accessibility to
cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
frontend/src/features/shared/company/PersonSelectionModal.tsx (3)

294-303: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The person card is a clickable div.

The card carries onClick but no role, tabIndex, or key handler. Keyboard users reach the person through the hover-revealed Select button, which group-focus-within exposes, so the flow is not blocked. Removing the card-level onClick and relying on the explicit Select button would remove the duplicated activation path and the event.stopPropagation() calls at Lines 341, 354, and 367.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
294 - 303, The person card in the person-selection rendering should no longer be
clickable via its container. Remove the card-level onClick handler from the div
around person.person_id, rely on the explicit Select button for activation, and
remove the associated event.stopPropagation() calls in the button handlers.

218-220: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse invalidatePeople here.

Lines 218-220 repeat the query-key invalidation that invalidatePeople defines at Lines 118-121. handleUpdate and handleConfirmDelete already call the helper. A future key change would need two edits.

♻️ Proposed fix
- await queryClient.invalidateQueries({- queryKey: companiesPeopleListQueryKey({ path: { company_id: companyId } }),- })+ await invalidatePeople()
As per coding guidelines: "Use one implementation per concept; search before implementing and extend a near-match instead of creating a parallel implementation."
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
218 - 220, Replace the direct companies-people query invalidation in the
relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.

Source: Coding guidelines


534-542: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Handle the returned promise on the submit button.

handleUpdate and handleCreate are async. Passing them directly to onClick returns a floating promise from the event handler. The delete button at Line 272 already wraps its call with void. Use the same form here for consistency and to satisfy @typescript-eslint/no-misused-promises if that rule is enabled.

♻️ Proposed fix
- onClick={editingPerson ? handleUpdate : handleCreate}+ onClick={() => {+ void (editingPerson ? handleUpdate() : handleCreate())+ }}
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
534 - 542, Update the submit button’s onClick handler to explicitly discard the
promise returned by handleUpdate or handleCreate, matching the existing
delete-button pattern. Preserve the editingPerson conditional selection and
button behavior.
frontend/src/features/shared/useDebouncedValue.test.tsx (1)

39-46: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the fake-timer queue on unmount.

clearTimeout is global, so another cleanup can satisfy toHaveBeenCalled(). Since this suite enables fake timers, assert that vi.getTimerCount() is greater than zero after renderHook, then zero after unmount().

🤖 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 `@frontend/src/features/shared/useDebouncedValue.test.tsx` around lines 39 -
46, Update the “clears a pending timer on unmount” test to assert the fake-timer
queue directly: verify vi.getTimerCount() is greater than zero after renderHook
creates the debounced timer, then verify it is zero after unmount(). Remove the
clearTimeout spy assertion.
🤖 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 `@docs/rewrite-status.md`:
- Around line 57-61: Update the frontend rebuild table in the rewrite-status
document to replace references to features/company/PersonSelectionModal.tsx and
features/company/CompanyLookup.tsx with their canonical features/shared/company/
paths, leaving other entries unchanged.
In `@frontend/src/features/crm/CompaniesListPage.tsx`:
- Around line 113-119: Apply the first-load-only error rule at both QueryState
call sites: in frontend/src/features/crm/CompaniesListPage.tsx lines 113-119,
change the ListTable isError value to companies.isError && companies.data ===
undefined; in frontend/src/features/job/JobFinishTab.tsx lines 209-213, use a
loadError derived per query from isError && data === undefined so cached summary
and checklist content remains visible during failed refetches.
In `@frontend/src/features/purchasing/JobSelect.tsx`:
- Around line 113-150: Update JobSelect to support keyboard navigation by
tracking an active option index, moving it with ArrowUp/ArrowDown, and selecting
the active job on Enter. Add role="listbox" to the dropdown and role="option"
with aria-selected to each mapped job row, making options keyboard-accessible
and reusing the shared picker pattern if available.
- Around line 91-111: Update the closing paths in JobSelect’s onBlur timeout and
Escape handler to reset editing and clear the stale search term when the picker
closes without selection, so value returns to the bound job and filtering does
not reuse old input. Store the 150ms blur timeout in a ref and add a useEffect
cleanup that clears it on unmount, while preserving the existing select
behavior.
In `@frontend/src/features/purchasing/PoListPage.tsx`:
- Around line 57-74: Update the purchase-order row in PoListPage’s renderRow
callback to be keyboard accessible: make the tr focusable, expose it as an
interactive control, and handle keyboard activation by navigating to the same
po/$poId destination as onClick. Preserve the existing mouse navigation
behavior.
In `@frontend/src/features/shared/company/CompanyLookup.tsx`:
- Around line 238-242: Update the suggestions empty-state rendering near
companiesSearchRetrieveOptions to check search.isError first and display an
error state when the request fails. Gate the existing “No companies found”
message on a successful, non-error search so rejected requests never appear as
empty results.
- Around line 88-99: Associate quickCreateCompany with an active request token
and call handleCompanyCreated only when the response still matches the current
lookup interaction; otherwise ignore the completion. In
frontend/src/features/shared/company/CompanyLookup.tsx lines 88-99, update
quickCreateCompany accordingly. In
frontend/src/features/shared/company/CreateCompanyModal.tsx lines 49-64, either
prevent dismissal while creation is pending or ignore completion after the modal
closes. Add regression tests covering abandoned quick-create and modal-creation
interactions.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 242-281: Update the form footer submit state in the component’s
submitDisabled logic to also disable submission whenever deleteTarget is
non-null or removeLink.isPending is true. Keep the existing confirmation overlay
behavior and ensure create/edit actions cannot start while deletion is pending
or awaiting confirmation.
In `@frontend/src/features/shared/decimal.ts`:
- Around line 10-16: Update parseDecimalInput to validate cleaned input against
an explicit fixed-point decimal syntax before numeric finiteness checks,
rejecting hex, octal, binary, and other non-decimal forms. Normalize accepted
values such as “.5”, “+5”, and “5.” into the backend-supported decimal
representation, and ensure trimDecimal cannot return invalid syntax verbatim.
In `@frontend/src/features/shared/ItemSelect.tsx`:
- Around line 19-43: Rewrite or remove the comments at
frontend/src/features/shared/ItemSelect.tsx:19-43, replacing implementation,
E2E, and history narration with the rejected API alternative and factual
constraint, or remove them. Apply the same constraint-based approach at
frontend/src/features/shared/SaveFailedBadge.tsx:1-4,
frontend/src/features/shared/decimal.test.ts:33-34 (including the rejected
formatting behavior and exponent constraint),
frontend/src/features/shared/company/CompanyLookup.test.tsx:154-155 (including
the Sonner rendering constraint if retained),
frontend/src/features/shared/company/CompanyLookup.tsx:88-90, and
frontend/src/features/shared/company/CreateCompanyModal.tsx:16-26 and :39
(document only rejected alternatives and current product/state constraints,
otherwise remove the comments).
---
Nitpick comments:
In `@docs/cutover-checklist.md`:
- Around line 115-123: Update the serving-model checklist item to define
executable ASGI acceptance criteria: minimum concurrency, request timeout,
keep-alive, database-connection capacity, and ORM-safety requirements. Link the
checkbox to the deployment configuration or command that enforces these
settings, while retaining the existing WSGI option and cutover context.
In `@frontend/src/features/purchasing/StockPage.tsx`:
- Around line 25-31: Update the search query construction near searchActive in
StockPage so the request and its query key use the trimmed query value, while
preserving the existing minimum-length gating behavior.
- Around line 27-33: Update the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
In `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx`:
- Around line 19-48: Extend the PersonSelectionModal tests beyond accessibility
to cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 294-303: The person card in the person-selection rendering should
no longer be clickable via its container. Remove the card-level onClick handler
from the div around person.person_id, rely on the explicit Select button for
activation, and remove the associated event.stopPropagation() calls in the
button handlers.
- Around line 218-220: Replace the direct companies-people query invalidation in
the relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.
- Around line 534-542: Update the submit button’s onClick handler to explicitly
discard the promise returned by handleUpdate or handleCreate, matching the
existing delete-button pattern. Preserve the editingPerson conditional selection
and button behavior.
In `@frontend/src/features/shared/ListTable.tsx`:
- Line 21: Document the `renderRow` prop contract in `ListTable`:
implementations must return each row with its own stable React `key`, since the
`rows.map(renderRow)` call does not apply one. Add this requirement to the
prop’s existing documentation without changing the rendering logic.
In `@frontend/src/features/shared/useDebouncedValue.test.tsx`:
- Around line 39-46: Update the “clears a pending timer on unmount” test to
assert the fake-timer queue directly: verify vi.getTimerCount() is greater than
zero after renderHook creates the debounced timer, then verify it is zero after
unmount(). Remove the clearTimeout spy assertion.
In `@frontend/tests/e2e/helpers.ts`:
- Around line 444-446: Update createTestPurchaseOrder so supplierName uses a
collision-free identifier instead of Math.random(), preferably
crypto.randomUUID() or a timestamp combined with the Playwright worker index,
while preserving the existing “[TEST] Supplier” naming format.
In `@frontend/tests/e2e/purchasing/stock-search.spec.ts`:
- Around line 117-134: Update the response listener in the stock-search test to
count search responses based on whether they occur after the input is cleared,
rather than excluding URLs containing q=5mm. Start tracking post-clear responses
only immediately before input.fill(''), so the initial search is ignored while
any repeated q=5mm request after clearing is counted and fails the existing
assertion.
🪄 Autofix

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 Plus

Run ID: 66ca2b61-202f-4ca9-a415-b773f6995a0a

📥 Commits

Reviewing files that changed from the base of the PR and between 3030b60 and a3451f2.

📒 Files selected for processing (60)
  • docs/adr/0039-one-implementation-per-concept.md
  • docs/code-quality.md
  • docs/cutover-checklist.md
  • docs/rewrite-status.md
  • frontend/src/api/index.ts
  • frontend/src/api/query-client.ts
  • frontend/src/features/crm/CompaniesListPage.tsx
  • frontend/src/features/crm/CompanyDetailPage.tsx
  • frontend/src/features/job/JobCreatePage.tsx
  • frontend/src/features/job/JobFinishTab.tsx
  • frontend/src/features/job/JobSettingsTab.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/purchasing/JobSelect.test.ts
  • frontend/src/features/purchasing/JobSelect.tsx
  • frontend/src/features/purchasing/PoCreatePage.tsx
  • frontend/src/features/purchasing/PoDetailPage.tsx
  • frontend/src/features/purchasing/PoLinesTable.tsx
  • frontend/src/features/purchasing/PoListPage.tsx
  • frontend/src/features/purchasing/PoSummaryCard.tsx
  • frontend/src/features/purchasing/StockPage.tsx
  • frontend/src/features/purchasing/index.ts
  • frontend/src/features/purchasing/lines.test.ts
  • frontend/src/features/purchasing/lines.ts
  • frontend/src/features/purchasing/usePoLines.ts
  • frontend/src/features/reports/JobMovementReportPage.tsx
  • frontend/src/features/reports/WipReportPage.tsx
  • frontend/src/features/shared/DataTable.tsx
  • frontend/src/features/shared/ItemSelect.tsx
  • frontend/src/features/shared/ListTable.test.tsx
  • frontend/src/features/shared/ListTable.tsx
  • frontend/src/features/shared/QueryState.test.tsx
  • frontend/src/features/shared/QueryState.tsx
  • frontend/src/features/shared/SaveFailedBadge.tsx
  • frontend/src/features/shared/company/CompanyLookup.test.tsx
  • frontend/src/features/shared/company/CompanyLookup.tsx
  • frontend/src/features/shared/company/CreateCompanyModal.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.test.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.tsx
  • frontend/src/features/shared/company/PersonSelector.tsx
  • frontend/src/features/shared/company/create-company.ts
  • frontend/src/features/shared/company/index.ts
  • frontend/src/features/shared/company/xero-contact.ts
  • frontend/src/features/shared/decimal.test.ts
  • frontend/src/features/shared/decimal.ts
  • frontend/src/features/shared/useDebouncedValue.test.tsx
  • frontend/src/features/shared/useDebouncedValue.ts
  • frontend/src/features/timesheet/DailyOverviewPage.tsx
  • frontend/src/features/timesheet/SmartTimesheetTable.tsx
  • frontend/src/features/timesheet/TimesheetEntryPage.tsx
  • frontend/src/routeTree.gen.ts
  • frontend/src/routes/_authed/purchasing/po/$poId.tsx
  • frontend/src/routes/_authed/purchasing/po/create.tsx
  • frontend/src/routes/_authed/purchasing/po/index.tsx
  • frontend/src/routes/_authed/purchasing/stock.tsx
  • frontend/tests/e2e/helpers.ts
  • frontend/tests/e2e/purchasing/create-purchase-order.spec.ts
  • frontend/tests/e2e/purchasing/po-created-by.spec.ts
  • frontend/tests/e2e/purchasing/stock-search.spec.ts
💤 Files with no reviewable changes (2)
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/calc.test.ts
🛑 Comments failed to post (3)
frontend/src/features/shared/company/CompanyLookup.tsx (2)

88-99: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Ignore completion from abandoned company-creation interactions.

Both creation paths apply a completed mutation after the initiating UI state can change. A quick-create response can select an old query after the user continues searching. A modal response can select a company after the user closes the dialog.

  • frontend/src/features/shared/company/CompanyLookup.tsx#L88-L99: associate quick creation with an active request token, and select the returned company only if the request still matches the current interaction.
  • frontend/src/features/shared/company/CreateCompanyModal.tsx#L49-L64: prevent dismissal while creating or ignore a completion after the modal closes. Add regression tests for both paths.
📍 Affects 2 files
  • frontend/src/features/shared/company/CompanyLookup.tsx#L88-L99 (this comment)
  • frontend/src/features/shared/company/CreateCompanyModal.tsx#L49-L64
🤖 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 `@frontend/src/features/shared/company/CompanyLookup.tsx` around lines 88 - 99,
Associate quickCreateCompany with an active request token and call
handleCompanyCreated only when the response still matches the current lookup
interaction; otherwise ignore the completion. In
frontend/src/features/shared/company/CompanyLookup.tsx lines 88-99, update
quickCreateCompany accordingly. In
frontend/src/features/shared/company/CreateCompanyModal.tsx lines 49-64, either
prevent dismissal while creation is pending or ignore completion after the modal
closes. Add regression tests covering abandoned quick-create and modal-creation
interactions.

238-242: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Render a search failure state.

If companiesSearchRetrieveOptions rejects, search.isPending is false and this branch renders No companies found.. The user receives a false empty-result message. Render an error state when search.isError is true, and reserve the empty state for successful empty responses.

🤖 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 `@frontend/src/features/shared/company/CompanyLookup.tsx` around lines 238 -
242, Update the suggestions empty-state rendering near
companiesSearchRetrieveOptions to check search.isError first and display an
error state when the request fails. Gate the existing “No companies found”
message on a successful, non-error search so rejected requests never appear as
empty results.
frontend/src/features/shared/company/PersonSelectionModal.tsx (1)

242-281: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The delete overlay does not block the form column.

The confirmation panel is absolute inset-0 inside the people-list column only. The create/edit form and the submit button stay interactive while the confirmation is open. submitDisabled at Line 226 also ignores removeLink.isPending, so a user can start a create while a delete is in flight. Consider disabling the footer submit while deleteTarget !== null or removeLink.isPending.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
242 - 281, Update the form footer submit state in the component’s submitDisabled
logic to also disable submission whenever deleteTarget is non-null or
removeLink.isPending is true. Keep the existing confirmation overlay behavior
and ensure create/edit actions cannot start while deletion is pending or
awaiting confirmation.

corrinand others added 4 commits August 11, 2026 12:47
- CompaniesListPage/JobFinishTab: QueryState was passed the raw
isError, breaking the first-load-only rule the other converted
sites already follow -- a background refetch failure was
unmounting already-rendered content instead of leaving it on
screen.
- JobSelect: closing the picker without a selection (blur or
Escape) left `editing` true forever, so the input kept showing
the abandoned search term instead of reverting to the bound job;
the blur timer was untracked and could fire a state update after
unmount. Also added keyboard selection (arrow keys + Enter,
role=listbox/option, aria-activedescendant) -- the dropdown was
mouse-only.
- PoListPage: the row was reachable only by mouse click; added a
real Link on the PO number cell, matching CompaniesListPage's
existing pattern.
- decimal.ts: parseDecimalInput accepted anything Number() parses,
including hex/octal/binary literals and bare exponents, and sent
them to the wire verbatim as garbage Decimal strings. Validates
fixed-point syntax explicitly now.
- Stale features/company/* paths in rewrite-status.md's build-order
table, left behind by the features/shared/company move.
- Comments narrating port history or test intent rewritten to state
the rejected alternative and the constraint (ADR 0043) in
ItemSelect, SaveFailedBadge, CreateCompanyModal, CompanyLookup;
two flagged sites (the E2E-repair-loop comment, the sonner test
comment) were already compliant and left as-is.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
PR #54 (main) widened pyproject.toml's django specifier to
>=6.0,<6.2 but never regenerated the matching uv.lock, which still
recorded <6.1 in its requires-dist metadata. Every `uv run` this
session silently self-corrected the lock locally; committing that
fix rather than leaving a lockfile permanently out of sync with the
manifest it's supposed to pin.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
frontend/test-results/ was covered; run_e2e.sh's Playwright
invocation also writes artifacts relative to the repo root in some
invocations, leaving an untracked test-results/ dangling after every
E2E run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
…config
playwright.config.ts resolved .env.test, testDir, outputDir and the
html reporter's folder relative to process.cwd(). Any invocation
whose cwd wasn't frontend/ (npm --prefix from the repo root, a bare
npx playwright test) silently missed .env.test -- dropping
E2E_TEST_USERNAME/PASSWORD with no error -- or wrote artifacts to
the wrong location instead of erroring loudly, which is how a
root-level test-results/ kept reappearing. Anchored every one of
these to import.meta.dirname instead: verified from an unrelated
cwd, .env/.env.test still resolve correctly and no stray directory
gets created anywhere. The root-level gitignore entry from the
previous commit stays as a defensive backstop, but this is the real
fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
@corrin
corrin merged commit f7a8080 into mainAug 11, 2026
3 checks passed
@corrin
corrin deleted the purchasing-po branch August 11, 2026 02:01
Sign up for freeto 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.

1 participant

@corrin
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Purchasing: PO create/list/detail, stock page (29/40 specs) - #65

Merged
corrin merged 14 commits into
mainfrom
purchasing-po
Aug 11, 2026
Merged

Purchasing: PO create/list/detail, stock page (29/40 specs)#65
corrin merged 14 commits into
mainfrom
purchasing-po

Conversation

@corrin

@corrincorrin commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Summary

Ports three of the five purchasing-cluster E2E specs (po-created-by, create-purchase-order, stock-search) with a full purchasing UI: PO list/create/detail pages, an editable PO-lines grid, and a stock page. supplier-alias-search and pickup-address (the two remaining, Google-Places-dependent specs) are next.

Backend was already ported in a prior slice; this is frontend-only.

Architecture work beyond the target specs

Three rounds of adversarial review found this slice was about to leave (or was leaving) real structural debt behind, and each was fixed before merge rather than deferred:

  • features/shared/DataTable.tsx — the one owner of the editable-grid E2E contract (DataTable-row-N, data-grid-*). The new PoLinesTable would otherwise have been a third hand-rolled copy alongside SmartTimesheetTable and CostLineGrid; all three now render through it.
  • features/shared/QueryState.tsx + features/shared/ListTable.tsx — the one owner of the pending/error gate every query-backed page or panel repeats, and the plain-rows-table shell layered over it. PoListPage/StockPage had copied CompaniesListPage's loading/error/retry block verbatim; an exhaustive sweep (three passes, each catching what the last missed) found and converted every genuine instance across the frontend — PoDetailPage, CostLineGrid, JobMovementReportPage, CompanyDetailPage, JobFinishTab, DailyOverviewPage, TimesheetEntryPage. A few sites stay deliberately unconverted (embedded card widgets with richer branching than binary success/fail; one guard-clause-shaped early return matching CLAUDE.md's own preference) — reasoning recorded in rewrite-status.md.
  • features/companyfeatures/shared/company — it had no route of its own and was already cross-imported by job; this slice's PoSummaryCard would have been a third cross-domain import of what was really a shared widget library sitting in a domain-shaped directory.
  • Bug fix: PoLinesTable's item-picker label had no description fallback, so a bound stock item with a null item_code (nullable, v1 parity) misread as unbound.

ADR 0039 was strengthened with the underlying principle: architectural unification is never deferred to a later slice, and shared concepts get shared homes.

Also (unrelated to purchasing, fixed in the same branch at the user's direction): docs/rewrite-status.md and docs/cutover-checklist.md corrected — the SSE/live-updates work (Slice 3) was misfiled as a post-cutover deferral and is actually MUST-before-cutover, and the release-gate section now states both go/no-go criteria (functional parity, architecture quality) rather than only the E2E proxy for the first.

Test plan

  • npm run type-check clean
  • npx vitest run src/features — 197/197 passing
  • Cheap + expensive pre-commit gate tiers green
  • ./scripts/ops/run_e2e.sh (full 88-case suite) green
  • Three rounds of adversarial subagent review, all findings resolved

🤖 Generated with Claude Code

https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L

Summary by CodeRabbit

  • New Features
    • Added purchasing pages for stock browsing and purchase-order creation, listing, and details.
    • Added supplier and job search, purchase-order line editing, autosave, status updates, and draft-line handling.
    • Added company and contact lookup, creation, selection, and editing workflows.
    • Added debounced stock and company searches with cached results when searches are cleared.
  • Bug Fixes
    • Improved loading, error, retry, and save-failure feedback across key screens.
    • Added safer purchase-order updates when concurrent changes occur.
  • Tests
    • Expanded automated coverage for purchasing, shared controls, search, accessibility, and end-to-end workflows.

corrinand others added 10 commits August 10, 2026 22:28
PoCreatePage (CompanyLookup + reference + save -> 201 redirect),
PoListPage, PoDetailPage with PoSummaryCard (created-by input,
reference autosave, status select), usePoLines over the single PATCH
endpoint, and the missing 'po' concurrency invalidator so 412/428
recovery actually refetches. Ports createTestPurchaseOrder +
waitForPoAutosave and the po-created-by spec.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
PoLinesTable as the third grid on the useReactTable + useDraftRows
pattern (phantom row, no add-line button, row-exit draft commit —
unit-cost stays the row's last focusable cell so the spec's Tab exits
the row). ItemSelect generalised for stock-only consumers (optional
jobId/line, label + wrapper overrides; labour-rates query gated on
jobId presence only, since textOnly labels need rate names). Inline
JobSelect over purchasing_all_jobs_retrieve — the unfiltered endpoint
v1's PO page uses, because fresh jobs are draft and the filtered
sibling excludes them. Spec ported with the autosave waiter armed
before the pick/status clicks (v2 saves immediately; v1's debounce is
what made arm-after work).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Spec-lean StockPage: full active-stock list on load, 300ms-debounced
server FTS from 3 characters, enabled-gated so clearing the box
renders the cached list with no /search/ request.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
…ports
The purchasing slice's PoLinesTable was about to become a third
hand-rolled grid emitting the DataTable-row-N/data-grid-* contract
inline. features/shared/DataTable.tsx is now the one owner of that
contract; SmartTimesheetTable, CostLineGrid and PoLinesTable all
render through it.
Auditing further found the same pathology in the plain list pages:
PoListPage and StockPage (both new this slice) had copied
CompaniesListPage's table shell and loading/error/retry block
verbatim, taking an existing duplicate (also in WipReportPage) from
2 instances to 4. features/shared/ListTable.tsx is the one owner of
that block instead -- deliberately separate from DataTable, since it
has no react-table dependency and forcing static lists through
column-def machinery would be indirection, not rigor. A hand-rolled
debounce-into-query-state pattern in CompaniesListPage and StockPage
is now features/shared/useDebouncedValue.ts (KanbanSearchInput keeps
its own URL-driven debounce -- a different concept).
ItemSelect, the decimal helpers and the Save-failed badge move to
features/shared/ because purchasing consumed them cross-domain -- a
domain feature is not a library.
ADR 0039 strengthened: unification is never deferred, shared concepts
get shared homes, the bar is reference quality.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Adversarial review on the branch found the ListTable audit had stopped
short: JobMovementReportPage and CompanyDetailPage hand-rolled the same
loading/error/retry block ListTable was built to own (6 real instances,
only 4 fixed). Split the block itself out as
features/shared/QueryState.tsx -- the pending/error gate alone, no
table -- so it fits pages that show something other than a table too.
ListTable now composes QueryState instead of duplicating it. PoDetailPage,
CostLineGrid, JobMovementReportPage and CompanyDetailPage all render
through it.
Fixed: PoLinesTable's item-picker label read `item_code ?? 'Select Item'`
with no description fallback, so a bound stock item with a null code
(nullable, v1 parity) misread as unbound. Now poLineItemLabel() in
lines.ts, unit-tested.
Moved features/company to features/shared/company: it was already
cross-imported by features/job (JobCreatePage, JobSettingsTab) before
this slice added a third importer (purchasing/PoSummaryCard) -- it never
had a route of its own, a shared widget library in a domain-shaped box.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Reversed 2026-08-11. The 2026-08-10 record said SSE ships with the
production-serving decision, filed under "Post-cutover -- decided,
deliberately NOT before 15 August." That was overturned: racing bad
architecture into production defeats the point of the rewrite
(non-negotiable #3 in the Cutover section), and the interim polling
shape plus the un-runnable apps/xero/sync_stream.py view are exactly
that. Slice 3 -- live updates done properly (serving model fix + SSE
ticker + discard the interim shortcuts) moves to its own MUST-tier
section with a milestone checkbox, and every stale "deferred
post-cutover" cross-reference in the file is corrected. The
purchasing-slice PR is unaffected -- it is unrelated and merges as
planned; this correction only fixes the durable record for the next
session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
QueryState grew optional loadingNode/errorNode override props so a
spinner-based caller keeps its visual shell instead of losing it to
the plain-text default. JobFinishTab, DailyOverviewPage, and both
gates in TimesheetEntryPage's EntryWorkspace now render through it.
XeroQuoteCard, JobInvoiceCard and JobSettingsTab's pay-item field stay
excluded -- richer branching than binary success/fail, not the
page-level gate QueryState owns. TimesheetEntryPage's own outer gate
stays as guard-clause `if` returns, matching CLAUDE.md's stated
preference rather than converting to a shape that would abandon it.
Also: rewrite-status.md's narration of "review round found X, we
fixed Y" replaced with what the outcomes actually are -- which shared
component owns which contract, which sites are deliberately excluded
and why, which constraints a future change must respect. And the
Cutover section now states the actual two-question go/no-go criteria
(functional parity, proxied by MUST-tier E2E; materially better
architecture, judged directly) rather than only the E2E proxy, with
the honest fallback (abort and stay on v1) stated explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
@coderabbitai

coderabbitaiBot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@corrin, you've reached your PR review limit, so we couldn't start this review.

Next review available in:12 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 756f5951-a815-47ee-9870-21c9be6081d2

📥 Commits

Reviewing files that changed from the base of the PR and between a3451f2 and ac028e2.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (14)
  • .gitignore
  • docs/rewrite-status.md
  • frontend/playwright.config.ts
  • frontend/src/features/crm/CompaniesListPage.tsx
  • frontend/src/features/job/JobFinishTab.tsx
  • frontend/src/features/purchasing/JobSelect.test.tsx
  • frontend/src/features/purchasing/JobSelect.tsx
  • frontend/src/features/purchasing/PoListPage.tsx
  • frontend/src/features/shared/ItemSelect.tsx
  • frontend/src/features/shared/SaveFailedBadge.tsx
  • frontend/src/features/shared/company/CompanyLookup.tsx
  • frontend/src/features/shared/company/CreateCompanyModal.tsx
  • frontend/src/features/shared/decimal.test.ts
  • frontend/src/features/shared/decimal.ts
📝 Walkthrough

Walkthrough

The PR adds authenticated purchasing pages for stock and purchase orders, including editable lines, autosave, optimistic updates, and E2E coverage. It also extracts shared query, table, company, decimal, and debounce components and updates architecture and cutover documentation.

Changes

Purchasing frontend

Layer / File(s)Summary
Purchasing contracts and persistence
frontend/src/api/..., frontend/src/features/purchasing/JobSelect.tsx, frontend/src/features/purchasing/lines.ts, frontend/src/features/purchasing/usePoLines.ts
Adds purchasing API exports, job filtering, PO draft utilities, optimistic mutations, concurrency invalidation, and refetch reconciliation.
Purchasing pages and routing
frontend/src/features/purchasing/..., frontend/src/routes/_authed/purchasing/..., frontend/src/routeTree.gen.ts
Adds PO list, create, detail, editable line, summary, and stock-search pages with authenticated routes.
Purchasing validation
frontend/tests/e2e/purchasing/..., frontend/tests/e2e/helpers.ts, frontend/src/features/purchasing/*.test.ts
Adds unit and E2E coverage for PO creation, autosave, status updates, created-by values, job filtering, draft serialization, and stock search.

Shared frontend consolidation

Layer / File(s)Summary
Shared query and grid infrastructure
frontend/src/features/shared/..., frontend/src/features/crm/..., frontend/src/features/job/..., frontend/src/features/reports/..., frontend/src/features/timesheet/...
Adds QueryState, ListTable, DataTable, SaveFailedBadge, decimal utilities, and useDebouncedValue, then adopts them across existing pages.
Shared company and person features
frontend/src/features/shared/company/..., frontend/src/features/job/JobCreatePage.tsx, frontend/src/features/job/JobSettingsTab.tsx
Adds shared company lookup, creation, Xero validation, person selection, and person management components, and updates consumers to use the shared module.

Architecture and cutover documentation

Layer / File(s)Summary
Architecture and release rules
docs/adr/0039-one-implementation-per-concept.md, docs/cutover-checklist.md, docs/rewrite-status.md, docs/code-quality.md
Documents immediate implementation unification, independent functionality and architecture gates, threaded serving requirements, purchasing progress, and updated suppression counts.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 54.24% 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 nameStatusExplanation
Title check✅ PassedThe title clearly identifies the purchasing pages and spec progress covered by the pull request.
Description check✅ PassedThe description explains the scope, architectural changes, remaining work, and validation results in sufficient detail.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch purchasing-po

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.

@coderabbitaicoderabbitaiBot 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: 10

🧹 Nitpick comments (11)
docs/cutover-checklist.md (1)

115-123: 🩺 Stability & Availability | 🔵 Trivial

Define the ASGI acceptance criteria.

or the ASGI equivalent is not an executable serving requirement. Define the minimum concurrency, timeout, keep-alive, database-connection, and ORM-safety requirements. Link this checkbox to the deployment configuration that enforces them.

🤖 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 `@docs/cutover-checklist.md` around lines 115 - 123, Update the serving-model
checklist item to define executable ASGI acceptance criteria: minimum
concurrency, request timeout, keep-alive, database-connection capacity, and
ORM-safety requirements. Link the checkbox to the deployment configuration or
command that enforces these settings, while retaining the existing WSGI option
and cutover context.
frontend/src/features/purchasing/StockPage.tsx (2)

25-31: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Send the trimmed query.

searchActive is computed from query.trim(), but the request sends the untrimmed query. For an input of " abc" the gate opens and q is " abc". Each whitespace variation also produces a distinct query key, which adds duplicate cache entries and duplicate requests for the same search term.

♻️ Proposed fix
- const searchActive = query.trim().length >= MIN_QUERY_LENGTH+ const trimmedQuery = query.trim()+ const searchActive = trimmedQuery.length >= MIN_QUERY_LENGTH
const list = useQuery(purchasingStockListOptions())
const search = useQuery({
- ...purchasingStockSearchRetrieveOptions({ query: { q: query } }),+ ...purchasingStockSearchRetrieveOptions({ query: { q: trimmedQuery } }),
enabled: searchActive,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 25 - 31, Update
the search query construction near searchActive in StockPage so the request and
its query key use the trimmed query value, while preserving the existing
minimum-length gating behavior.

27-33: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Preserve the previous search results while fetching a new query.

Each active search term creates a new query key. search.isPending then replaces the table with Loading stock items.... Use placeholderData: keepPreviousData to keep the current rows visible during the fetch.

♻️ Proposed refactor
-import { useQuery } from '`@tanstack/react-query`'+import { keepPreviousData, useQuery } from '`@tanstack/react-query`'
 const search = useQuery({
...purchasingStockSearchRetrieveOptions({ query: { q: query } }),
enabled: searchActive,
+ placeholderData: keepPreviousData,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 27 - 33, Update
the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
frontend/tests/e2e/helpers.ts (1)

444-446: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace Math.random with a collision-free identifier.

Math.floor(Math.random() * 100000) gives 100,000 possible values. Parallel Playwright workers and repeated local runs can produce the same supplier name. A duplicate [TEST] Supplier N creates a second live Xero contact push with the same name, which makes later lookups ambiguous.

Use crypto.randomUUID() or combine the timestamp with the worker index.

♻️ Proposed change
- const randomSuffix = Math.floor(Math.random() * 100000)- const supplierName = `[TEST] Supplier ${randomSuffix}`+ const randomSuffix = crypto.randomUUID().slice(0, 8)+ const supplierName = `[TEST] Supplier ${randomSuffix}`
🤖 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 `@frontend/tests/e2e/helpers.ts` around lines 444 - 446, Update
createTestPurchaseOrder so supplierName uses a collision-free identifier instead
of Math.random(), preferably crypto.randomUUID() or a timestamp combined with
the Playwright worker index, while preserving the existing “[TEST] Supplier”
naming format.
frontend/tests/e2e/purchasing/stock-search.spec.ts (1)

117-134: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The counter filter can miss a regression.

The listener ignores every /search/ response whose URL contains q=5mm. The exclusion exists to skip the first search, but it also hides the most likely regression: clearing the input re-fires the same q=5mm request. That request is a real post-clear search call, and the assertion at Line 134 still passes.

Gate on time instead of on the query value.

♻️ Proposed change
- // Track whether any further /search/ request fires when we clear the box.+ // Gate on the clear action, not on the query value: a re-fired `q=5mm`+ // request after clearing is still a post-clear search call.
let postClearSearchCalls = 0
+ let cleared = false
page.on('response', (response) => {
- if (- response.url().includes('/api/purchasing/stock/search/') &&- !response.url().includes('q=5mm')- ) {+ if (cleared && response.url().includes('/api/purchasing/stock/search/')) {
postClearSearchCalls += 1
}
})
+ cleared = true
await input.fill('')
🤖 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 `@frontend/tests/e2e/purchasing/stock-search.spec.ts` around lines 117 - 134,
Update the response listener in the stock-search test to count search responses
based on whether they occur after the input is cleared, rather than excluding
URLs containing q=5mm. Start tracking post-clear responses only immediately
before input.fill(''), so the initial search is ignored while any repeated q=5mm
request after clearing is counted and fails the existing assertion.
frontend/src/features/shared/ListTable.tsx (1)

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

Document that renderRow must supply the React key.

Line 66 calls rows.map(renderRow) and applies no key. Every current caller sets key on its own <tr>. That contract is not stated on the prop, so a future caller can omit the key and cause a React key warning plus incorrect row reconciliation.

♻️ Proposed doc addition
+ /** Must set a stable React `key` on the returned row element; ListTable+ maps rows directly and applies no key of its own. */
renderRow: (row: TRow) => ReactNode

Also applies to: 66-66

🤖 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 `@frontend/src/features/shared/ListTable.tsx` at line 21, Document the
`renderRow` prop contract in `ListTable`: implementations must return each row
with its own stable React `key`, since the `rows.map(renderRow)` call does not
apply one. Add this requirement to the prop’s existing documentation without
changing the rendering logic.
frontend/src/features/shared/company/PersonSelectionModal.test.tsx (1)

19-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering the create and update flows.

The single test covers accessibility only. handleCreate and handleUpdate hold the business rules: first person is always primary, blank optional fields are omitted from the request body, and an invalid email blocks submission. These rules are unit-testable with mocked mutations.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx` around
lines 19 - 48, Extend the PersonSelectionModal tests beyond accessibility to
cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
frontend/src/features/shared/company/PersonSelectionModal.tsx (3)

294-303: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The person card is a clickable div.

The card carries onClick but no role, tabIndex, or key handler. Keyboard users reach the person through the hover-revealed Select button, which group-focus-within exposes, so the flow is not blocked. Removing the card-level onClick and relying on the explicit Select button would remove the duplicated activation path and the event.stopPropagation() calls at Lines 341, 354, and 367.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
294 - 303, The person card in the person-selection rendering should no longer be
clickable via its container. Remove the card-level onClick handler from the div
around person.person_id, rely on the explicit Select button for activation, and
remove the associated event.stopPropagation() calls in the button handlers.

218-220: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse invalidatePeople here.

Lines 218-220 repeat the query-key invalidation that invalidatePeople defines at Lines 118-121. handleUpdate and handleConfirmDelete already call the helper. A future key change would need two edits.

♻️ Proposed fix
- await queryClient.invalidateQueries({- queryKey: companiesPeopleListQueryKey({ path: { company_id: companyId } }),- })+ await invalidatePeople()
As per coding guidelines: "Use one implementation per concept; search before implementing and extend a near-match instead of creating a parallel implementation."
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
218 - 220, Replace the direct companies-people query invalidation in the
relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.

Source: Coding guidelines


534-542: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Handle the returned promise on the submit button.

handleUpdate and handleCreate are async. Passing them directly to onClick returns a floating promise from the event handler. The delete button at Line 272 already wraps its call with void. Use the same form here for consistency and to satisfy @typescript-eslint/no-misused-promises if that rule is enabled.

♻️ Proposed fix
- onClick={editingPerson ? handleUpdate : handleCreate}+ onClick={() => {+ void (editingPerson ? handleUpdate() : handleCreate())+ }}
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
534 - 542, Update the submit button’s onClick handler to explicitly discard the
promise returned by handleUpdate or handleCreate, matching the existing
delete-button pattern. Preserve the editingPerson conditional selection and
button behavior.
frontend/src/features/shared/useDebouncedValue.test.tsx (1)

39-46: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the fake-timer queue on unmount.

clearTimeout is global, so another cleanup can satisfy toHaveBeenCalled(). Since this suite enables fake timers, assert that vi.getTimerCount() is greater than zero after renderHook, then zero after unmount().

🤖 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 `@frontend/src/features/shared/useDebouncedValue.test.tsx` around lines 39 -
46, Update the “clears a pending timer on unmount” test to assert the fake-timer
queue directly: verify vi.getTimerCount() is greater than zero after renderHook
creates the debounced timer, then verify it is zero after unmount(). Remove the
clearTimeout spy assertion.
🤖 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 `@docs/rewrite-status.md`:
- Around line 57-61: Update the frontend rebuild table in the rewrite-status
document to replace references to features/company/PersonSelectionModal.tsx and
features/company/CompanyLookup.tsx with their canonical features/shared/company/
paths, leaving other entries unchanged.
In `@frontend/src/features/crm/CompaniesListPage.tsx`:
- Around line 113-119: Apply the first-load-only error rule at both QueryState
call sites: in frontend/src/features/crm/CompaniesListPage.tsx lines 113-119,
change the ListTable isError value to companies.isError && companies.data ===
undefined; in frontend/src/features/job/JobFinishTab.tsx lines 209-213, use a
loadError derived per query from isError && data === undefined so cached summary
and checklist content remains visible during failed refetches.
In `@frontend/src/features/purchasing/JobSelect.tsx`:
- Around line 113-150: Update JobSelect to support keyboard navigation by
tracking an active option index, moving it with ArrowUp/ArrowDown, and selecting
the active job on Enter. Add role="listbox" to the dropdown and role="option"
with aria-selected to each mapped job row, making options keyboard-accessible
and reusing the shared picker pattern if available.
- Around line 91-111: Update the closing paths in JobSelect’s onBlur timeout and
Escape handler to reset editing and clear the stale search term when the picker
closes without selection, so value returns to the bound job and filtering does
not reuse old input. Store the 150ms blur timeout in a ref and add a useEffect
cleanup that clears it on unmount, while preserving the existing select
behavior.
In `@frontend/src/features/purchasing/PoListPage.tsx`:
- Around line 57-74: Update the purchase-order row in PoListPage’s renderRow
callback to be keyboard accessible: make the tr focusable, expose it as an
interactive control, and handle keyboard activation by navigating to the same
po/$poId destination as onClick. Preserve the existing mouse navigation
behavior.
In `@frontend/src/features/shared/company/CompanyLookup.tsx`:
- Around line 238-242: Update the suggestions empty-state rendering near
companiesSearchRetrieveOptions to check search.isError first and display an
error state when the request fails. Gate the existing “No companies found”
message on a successful, non-error search so rejected requests never appear as
empty results.
- Around line 88-99: Associate quickCreateCompany with an active request token
and call handleCompanyCreated only when the response still matches the current
lookup interaction; otherwise ignore the completion. In
frontend/src/features/shared/company/CompanyLookup.tsx lines 88-99, update
quickCreateCompany accordingly. In
frontend/src/features/shared/company/CreateCompanyModal.tsx lines 49-64, either
prevent dismissal while creation is pending or ignore completion after the modal
closes. Add regression tests covering abandoned quick-create and modal-creation
interactions.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 242-281: Update the form footer submit state in the component’s
submitDisabled logic to also disable submission whenever deleteTarget is
non-null or removeLink.isPending is true. Keep the existing confirmation overlay
behavior and ensure create/edit actions cannot start while deletion is pending
or awaiting confirmation.
In `@frontend/src/features/shared/decimal.ts`:
- Around line 10-16: Update parseDecimalInput to validate cleaned input against
an explicit fixed-point decimal syntax before numeric finiteness checks,
rejecting hex, octal, binary, and other non-decimal forms. Normalize accepted
values such as “.5”, “+5”, and “5.” into the backend-supported decimal
representation, and ensure trimDecimal cannot return invalid syntax verbatim.
In `@frontend/src/features/shared/ItemSelect.tsx`:
- Around line 19-43: Rewrite or remove the comments at
frontend/src/features/shared/ItemSelect.tsx:19-43, replacing implementation,
E2E, and history narration with the rejected API alternative and factual
constraint, or remove them. Apply the same constraint-based approach at
frontend/src/features/shared/SaveFailedBadge.tsx:1-4,
frontend/src/features/shared/decimal.test.ts:33-34 (including the rejected
formatting behavior and exponent constraint),
frontend/src/features/shared/company/CompanyLookup.test.tsx:154-155 (including
the Sonner rendering constraint if retained),
frontend/src/features/shared/company/CompanyLookup.tsx:88-90, and
frontend/src/features/shared/company/CreateCompanyModal.tsx:16-26 and :39
(document only rejected alternatives and current product/state constraints,
otherwise remove the comments).
---
Nitpick comments:
In `@docs/cutover-checklist.md`:
- Around line 115-123: Update the serving-model checklist item to define
executable ASGI acceptance criteria: minimum concurrency, request timeout,
keep-alive, database-connection capacity, and ORM-safety requirements. Link the
checkbox to the deployment configuration or command that enforces these
settings, while retaining the existing WSGI option and cutover context.
In `@frontend/src/features/purchasing/StockPage.tsx`:
- Around line 25-31: Update the search query construction near searchActive in
StockPage so the request and its query key use the trimmed query value, while
preserving the existing minimum-length gating behavior.
- Around line 27-33: Update the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
In `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx`:
- Around line 19-48: Extend the PersonSelectionModal tests beyond accessibility
to cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 294-303: The person card in the person-selection rendering should
no longer be clickable via its container. Remove the card-level onClick handler
from the div around person.person_id, rely on the explicit Select button for
activation, and remove the associated event.stopPropagation() calls in the
button handlers.
- Around line 218-220: Replace the direct companies-people query invalidation in
the relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.
- Around line 534-542: Update the submit button’s onClick handler to explicitly
discard the promise returned by handleUpdate or handleCreate, matching the
existing delete-button pattern. Preserve the editingPerson conditional selection
and button behavior.
In `@frontend/src/features/shared/ListTable.tsx`:
- Line 21: Document the `renderRow` prop contract in `ListTable`:
implementations must return each row with its own stable React `key`, since the
`rows.map(renderRow)` call does not apply one. Add this requirement to the
prop’s existing documentation without changing the rendering logic.
In `@frontend/src/features/shared/useDebouncedValue.test.tsx`:
- Around line 39-46: Update the “clears a pending timer on unmount” test to
assert the fake-timer queue directly: verify vi.getTimerCount() is greater than
zero after renderHook creates the debounced timer, then verify it is zero after
unmount(). Remove the clearTimeout spy assertion.
In `@frontend/tests/e2e/helpers.ts`:
- Around line 444-446: Update createTestPurchaseOrder so supplierName uses a
collision-free identifier instead of Math.random(), preferably
crypto.randomUUID() or a timestamp combined with the Playwright worker index,
while preserving the existing “[TEST] Supplier” naming format.
In `@frontend/tests/e2e/purchasing/stock-search.spec.ts`:
- Around line 117-134: Update the response listener in the stock-search test to
count search responses based on whether they occur after the input is cleared,
rather than excluding URLs containing q=5mm. Start tracking post-clear responses
only immediately before input.fill(''), so the initial search is ignored while
any repeated q=5mm request after clearing is counted and fails the existing
assertion.
🪄 Autofix

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 Plus

Run ID: 66ca2b61-202f-4ca9-a415-b773f6995a0a

📥 Commits

Reviewing files that changed from the base of the PR and between 3030b60 and a3451f2.

📒 Files selected for processing (60)
  • docs/adr/0039-one-implementation-per-concept.md
  • docs/code-quality.md
  • docs/cutover-checklist.md
  • docs/rewrite-status.md
  • frontend/src/api/index.ts
  • frontend/src/api/query-client.ts
  • frontend/src/features/crm/CompaniesListPage.tsx
  • frontend/src/features/crm/CompanyDetailPage.tsx
  • frontend/src/features/job/JobCreatePage.tsx
  • frontend/src/features/job/JobFinishTab.tsx
  • frontend/src/features/job/JobSettingsTab.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/purchasing/JobSelect.test.ts
  • frontend/src/features/purchasing/JobSelect.tsx
  • frontend/src/features/purchasing/PoCreatePage.tsx
  • frontend/src/features/purchasing/PoDetailPage.tsx
  • frontend/src/features/purchasing/PoLinesTable.tsx
  • frontend/src/features/purchasing/PoListPage.tsx
  • frontend/src/features/purchasing/PoSummaryCard.tsx
  • frontend/src/features/purchasing/StockPage.tsx
  • frontend/src/features/purchasing/index.ts
  • frontend/src/features/purchasing/lines.test.ts
  • frontend/src/features/purchasing/lines.ts
  • frontend/src/features/purchasing/usePoLines.ts
  • frontend/src/features/reports/JobMovementReportPage.tsx
  • frontend/src/features/reports/WipReportPage.tsx
  • frontend/src/features/shared/DataTable.tsx
  • frontend/src/features/shared/ItemSelect.tsx
  • frontend/src/features/shared/ListTable.test.tsx
  • frontend/src/features/shared/ListTable.tsx
  • frontend/src/features/shared/QueryState.test.tsx
  • frontend/src/features/shared/QueryState.tsx
  • frontend/src/features/shared/SaveFailedBadge.tsx
  • frontend/src/features/shared/company/CompanyLookup.test.tsx
  • frontend/src/features/shared/company/CompanyLookup.tsx
  • frontend/src/features/shared/company/CreateCompanyModal.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.test.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.tsx
  • frontend/src/features/shared/company/PersonSelector.tsx
  • frontend/src/features/shared/company/create-company.ts
  • frontend/src/features/shared/company/index.ts
  • frontend/src/features/shared/company/xero-contact.ts
  • frontend/src/features/shared/decimal.test.ts
  • frontend/src/features/shared/decimal.ts
  • frontend/src/features/shared/useDebouncedValue.test.tsx
  • frontend/src/features/shared/useDebouncedValue.ts
  • frontend/src/features/timesheet/DailyOverviewPage.tsx
  • frontend/src/features/timesheet/SmartTimesheetTable.tsx
  • frontend/src/features/timesheet/TimesheetEntryPage.tsx
  • frontend/src/routeTree.gen.ts
  • frontend/src/routes/_authed/purchasing/po/$poId.tsx
  • frontend/src/routes/_authed/purchasing/po/create.tsx
  • frontend/src/routes/_authed/purchasing/po/index.tsx
  • frontend/src/routes/_authed/purchasing/stock.tsx
  • frontend/tests/e2e/helpers.ts
  • frontend/tests/e2e/purchasing/create-purchase-order.spec.ts
  • frontend/tests/e2e/purchasing/po-created-by.spec.ts
  • frontend/tests/e2e/purchasing/stock-search.spec.ts
💤 Files with no reviewable changes (2)
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/calc.test.ts

Comment threaddocs/rewrite-status.md
Comment threadfrontend/src/features/crm/CompaniesListPage.tsx
Comment threadfrontend/src/features/purchasing/JobSelect.tsx
Comment threadfrontend/src/features/purchasing/JobSelect.tsx
Comment threadfrontend/src/features/purchasing/PoListPage.tsx
Comment threadfrontend/src/features/shared/decimal.ts
Comment threadfrontend/src/features/shared/ItemSelect.tsx

@coderabbitaicoderabbitaiBot 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.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 10

🧹 Nitpick comments (11)
docs/cutover-checklist.md (1)

115-123: 🩺 Stability & Availability | 🔵 Trivial

Define the ASGI acceptance criteria.

or the ASGI equivalent is not an executable serving requirement. Define the minimum concurrency, timeout, keep-alive, database-connection, and ORM-safety requirements. Link this checkbox to the deployment configuration that enforces them.

🤖 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 `@docs/cutover-checklist.md` around lines 115 - 123, Update the serving-model
checklist item to define executable ASGI acceptance criteria: minimum
concurrency, request timeout, keep-alive, database-connection capacity, and
ORM-safety requirements. Link the checkbox to the deployment configuration or
command that enforces these settings, while retaining the existing WSGI option
and cutover context.
frontend/src/features/purchasing/StockPage.tsx (2)

25-31: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Send the trimmed query.

searchActive is computed from query.trim(), but the request sends the untrimmed query. For an input of " abc" the gate opens and q is " abc". Each whitespace variation also produces a distinct query key, which adds duplicate cache entries and duplicate requests for the same search term.

♻️ Proposed fix
- const searchActive = query.trim().length >= MIN_QUERY_LENGTH+ const trimmedQuery = query.trim()+ const searchActive = trimmedQuery.length >= MIN_QUERY_LENGTH
const list = useQuery(purchasingStockListOptions())
const search = useQuery({
- ...purchasingStockSearchRetrieveOptions({ query: { q: query } }),+ ...purchasingStockSearchRetrieveOptions({ query: { q: trimmedQuery } }),
enabled: searchActive,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 25 - 31, Update
the search query construction near searchActive in StockPage so the request and
its query key use the trimmed query value, while preserving the existing
minimum-length gating behavior.

27-33: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Preserve the previous search results while fetching a new query.

Each active search term creates a new query key. search.isPending then replaces the table with Loading stock items.... Use placeholderData: keepPreviousData to keep the current rows visible during the fetch.

♻️ Proposed refactor
-import { useQuery } from '`@tanstack/react-query`'+import { keepPreviousData, useQuery } from '`@tanstack/react-query`'
 const search = useQuery({
...purchasingStockSearchRetrieveOptions({ query: { q: query } }),
enabled: searchActive,
+ placeholderData: keepPreviousData,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 27 - 33, Update
the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
frontend/tests/e2e/helpers.ts (1)

444-446: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace Math.random with a collision-free identifier.

Math.floor(Math.random() * 100000) gives 100,000 possible values. Parallel Playwright workers and repeated local runs can produce the same supplier name. A duplicate [TEST] Supplier N creates a second live Xero contact push with the same name, which makes later lookups ambiguous.

Use crypto.randomUUID() or combine the timestamp with the worker index.

♻️ Proposed change
- const randomSuffix = Math.floor(Math.random() * 100000)- const supplierName = `[TEST] Supplier ${randomSuffix}`+ const randomSuffix = crypto.randomUUID().slice(0, 8)+ const supplierName = `[TEST] Supplier ${randomSuffix}`
🤖 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 `@frontend/tests/e2e/helpers.ts` around lines 444 - 446, Update
createTestPurchaseOrder so supplierName uses a collision-free identifier instead
of Math.random(), preferably crypto.randomUUID() or a timestamp combined with
the Playwright worker index, while preserving the existing “[TEST] Supplier”
naming format.
frontend/tests/e2e/purchasing/stock-search.spec.ts (1)

117-134: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The counter filter can miss a regression.

The listener ignores every /search/ response whose URL contains q=5mm. The exclusion exists to skip the first search, but it also hides the most likely regression: clearing the input re-fires the same q=5mm request. That request is a real post-clear search call, and the assertion at Line 134 still passes.

Gate on time instead of on the query value.

♻️ Proposed change
- // Track whether any further /search/ request fires when we clear the box.+ // Gate on the clear action, not on the query value: a re-fired `q=5mm`+ // request after clearing is still a post-clear search call.
let postClearSearchCalls = 0
+ let cleared = false
page.on('response', (response) => {
- if (- response.url().includes('/api/purchasing/stock/search/') &&- !response.url().includes('q=5mm')- ) {+ if (cleared && response.url().includes('/api/purchasing/stock/search/')) {
postClearSearchCalls += 1
}
})
+ cleared = true
await input.fill('')
🤖 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 `@frontend/tests/e2e/purchasing/stock-search.spec.ts` around lines 117 - 134,
Update the response listener in the stock-search test to count search responses
based on whether they occur after the input is cleared, rather than excluding
URLs containing q=5mm. Start tracking post-clear responses only immediately
before input.fill(''), so the initial search is ignored while any repeated q=5mm
request after clearing is counted and fails the existing assertion.
frontend/src/features/shared/ListTable.tsx (1)

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

Document that renderRow must supply the React key.

Line 66 calls rows.map(renderRow) and applies no key. Every current caller sets key on its own <tr>. That contract is not stated on the prop, so a future caller can omit the key and cause a React key warning plus incorrect row reconciliation.

♻️ Proposed doc addition
+ /** Must set a stable React `key` on the returned row element; ListTable+ maps rows directly and applies no key of its own. */
renderRow: (row: TRow) => ReactNode

Also applies to: 66-66

🤖 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 `@frontend/src/features/shared/ListTable.tsx` at line 21, Document the
`renderRow` prop contract in `ListTable`: implementations must return each row
with its own stable React `key`, since the `rows.map(renderRow)` call does not
apply one. Add this requirement to the prop’s existing documentation without
changing the rendering logic.
frontend/src/features/shared/company/PersonSelectionModal.test.tsx (1)

19-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering the create and update flows.

The single test covers accessibility only. handleCreate and handleUpdate hold the business rules: first person is always primary, blank optional fields are omitted from the request body, and an invalid email blocks submission. These rules are unit-testable with mocked mutations.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx` around
lines 19 - 48, Extend the PersonSelectionModal tests beyond accessibility to
cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
frontend/src/features/shared/company/PersonSelectionModal.tsx (3)

294-303: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The person card is a clickable div.

The card carries onClick but no role, tabIndex, or key handler. Keyboard users reach the person through the hover-revealed Select button, which group-focus-within exposes, so the flow is not blocked. Removing the card-level onClick and relying on the explicit Select button would remove the duplicated activation path and the event.stopPropagation() calls at Lines 341, 354, and 367.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
294 - 303, The person card in the person-selection rendering should no longer be
clickable via its container. Remove the card-level onClick handler from the div
around person.person_id, rely on the explicit Select button for activation, and
remove the associated event.stopPropagation() calls in the button handlers.

218-220: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse invalidatePeople here.

Lines 218-220 repeat the query-key invalidation that invalidatePeople defines at Lines 118-121. handleUpdate and handleConfirmDelete already call the helper. A future key change would need two edits.

♻️ Proposed fix
- await queryClient.invalidateQueries({- queryKey: companiesPeopleListQueryKey({ path: { company_id: companyId } }),- })+ await invalidatePeople()
As per coding guidelines: "Use one implementation per concept; search before implementing and extend a near-match instead of creating a parallel implementation."
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
218 - 220, Replace the direct companies-people query invalidation in the
relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.

Source: Coding guidelines


534-542: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Handle the returned promise on the submit button.

handleUpdate and handleCreate are async. Passing them directly to onClick returns a floating promise from the event handler. The delete button at Line 272 already wraps its call with void. Use the same form here for consistency and to satisfy @typescript-eslint/no-misused-promises if that rule is enabled.

♻️ Proposed fix
- onClick={editingPerson ? handleUpdate : handleCreate}+ onClick={() => {+ void (editingPerson ? handleUpdate() : handleCreate())+ }}
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
534 - 542, Update the submit button’s onClick handler to explicitly discard the
promise returned by handleUpdate or handleCreate, matching the existing
delete-button pattern. Preserve the editingPerson conditional selection and
button behavior.
frontend/src/features/shared/useDebouncedValue.test.tsx (1)

39-46: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the fake-timer queue on unmount.

clearTimeout is global, so another cleanup can satisfy toHaveBeenCalled(). Since this suite enables fake timers, assert that vi.getTimerCount() is greater than zero after renderHook, then zero after unmount().

🤖 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 `@frontend/src/features/shared/useDebouncedValue.test.tsx` around lines 39 -
46, Update the “clears a pending timer on unmount” test to assert the fake-timer
queue directly: verify vi.getTimerCount() is greater than zero after renderHook
creates the debounced timer, then verify it is zero after unmount(). Remove the
clearTimeout spy assertion.
🤖 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 `@docs/rewrite-status.md`:
- Around line 57-61: Update the frontend rebuild table in the rewrite-status
document to replace references to features/company/PersonSelectionModal.tsx and
features/company/CompanyLookup.tsx with their canonical features/shared/company/
paths, leaving other entries unchanged.
In `@frontend/src/features/crm/CompaniesListPage.tsx`:
- Around line 113-119: Apply the first-load-only error rule at both QueryState
call sites: in frontend/src/features/crm/CompaniesListPage.tsx lines 113-119,
change the ListTable isError value to companies.isError && companies.data ===
undefined; in frontend/src/features/job/JobFinishTab.tsx lines 209-213, use a
loadError derived per query from isError && data === undefined so cached summary
and checklist content remains visible during failed refetches.
In `@frontend/src/features/purchasing/JobSelect.tsx`:
- Around line 113-150: Update JobSelect to support keyboard navigation by
tracking an active option index, moving it with ArrowUp/ArrowDown, and selecting
the active job on Enter. Add role="listbox" to the dropdown and role="option"
with aria-selected to each mapped job row, making options keyboard-accessible
and reusing the shared picker pattern if available.
- Around line 91-111: Update the closing paths in JobSelect’s onBlur timeout and
Escape handler to reset editing and clear the stale search term when the picker
closes without selection, so value returns to the bound job and filtering does
not reuse old input. Store the 150ms blur timeout in a ref and add a useEffect
cleanup that clears it on unmount, while preserving the existing select
behavior.
In `@frontend/src/features/purchasing/PoListPage.tsx`:
- Around line 57-74: Update the purchase-order row in PoListPage’s renderRow
callback to be keyboard accessible: make the tr focusable, expose it as an
interactive control, and handle keyboard activation by navigating to the same
po/$poId destination as onClick. Preserve the existing mouse navigation
behavior.
In `@frontend/src/features/shared/company/CompanyLookup.tsx`:
- Around line 238-242: Update the suggestions empty-state rendering near
companiesSearchRetrieveOptions to check search.isError first and display an
error state when the request fails. Gate the existing “No companies found”
message on a successful, non-error search so rejected requests never appear as
empty results.
- Around line 88-99: Associate quickCreateCompany with an active request token
and call handleCompanyCreated only when the response still matches the current
lookup interaction; otherwise ignore the completion. In
frontend/src/features/shared/company/CompanyLookup.tsx lines 88-99, update
quickCreateCompany accordingly. In
frontend/src/features/shared/company/CreateCompanyModal.tsx lines 49-64, either
prevent dismissal while creation is pending or ignore completion after the modal
closes. Add regression tests covering abandoned quick-create and modal-creation
interactions.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 242-281: Update the form footer submit state in the component’s
submitDisabled logic to also disable submission whenever deleteTarget is
non-null or removeLink.isPending is true. Keep the existing confirmation overlay
behavior and ensure create/edit actions cannot start while deletion is pending
or awaiting confirmation.
In `@frontend/src/features/shared/decimal.ts`:
- Around line 10-16: Update parseDecimalInput to validate cleaned input against
an explicit fixed-point decimal syntax before numeric finiteness checks,
rejecting hex, octal, binary, and other non-decimal forms. Normalize accepted
values such as “.5”, “+5”, and “5.” into the backend-supported decimal
representation, and ensure trimDecimal cannot return invalid syntax verbatim.
In `@frontend/src/features/shared/ItemSelect.tsx`:
- Around line 19-43: Rewrite or remove the comments at
frontend/src/features/shared/ItemSelect.tsx:19-43, replacing implementation,
E2E, and history narration with the rejected API alternative and factual
constraint, or remove them. Apply the same constraint-based approach at
frontend/src/features/shared/SaveFailedBadge.tsx:1-4,
frontend/src/features/shared/decimal.test.ts:33-34 (including the rejected
formatting behavior and exponent constraint),
frontend/src/features/shared/company/CompanyLookup.test.tsx:154-155 (including
the Sonner rendering constraint if retained),
frontend/src/features/shared/company/CompanyLookup.tsx:88-90, and
frontend/src/features/shared/company/CreateCompanyModal.tsx:16-26 and :39
(document only rejected alternatives and current product/state constraints,
otherwise remove the comments).
---
Nitpick comments:
In `@docs/cutover-checklist.md`:
- Around line 115-123: Update the serving-model checklist item to define
executable ASGI acceptance criteria: minimum concurrency, request timeout,
keep-alive, database-connection capacity, and ORM-safety requirements. Link the
checkbox to the deployment configuration or command that enforces these
settings, while retaining the existing WSGI option and cutover context.
In `@frontend/src/features/purchasing/StockPage.tsx`:
- Around line 25-31: Update the search query construction near searchActive in
StockPage so the request and its query key use the trimmed query value, while
preserving the existing minimum-length gating behavior.
- Around line 27-33: Update the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
In `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx`:
- Around line 19-48: Extend the PersonSelectionModal tests beyond accessibility
to cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 294-303: The person card in the person-selection rendering should
no longer be clickable via its container. Remove the card-level onClick handler
from the div around person.person_id, rely on the explicit Select button for
activation, and remove the associated event.stopPropagation() calls in the
button handlers.
- Around line 218-220: Replace the direct companies-people query invalidation in
the relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.
- Around line 534-542: Update the submit button’s onClick handler to explicitly
discard the promise returned by handleUpdate or handleCreate, matching the
existing delete-button pattern. Preserve the editingPerson conditional selection
and button behavior.
In `@frontend/src/features/shared/ListTable.tsx`:
- Line 21: Document the `renderRow` prop contract in `ListTable`:
implementations must return each row with its own stable React `key`, since the
`rows.map(renderRow)` call does not apply one. Add this requirement to the
prop’s existing documentation without changing the rendering logic.
In `@frontend/src/features/shared/useDebouncedValue.test.tsx`:
- Around line 39-46: Update the “clears a pending timer on unmount” test to
assert the fake-timer queue directly: verify vi.getTimerCount() is greater than
zero after renderHook creates the debounced timer, then verify it is zero after
unmount(). Remove the clearTimeout spy assertion.
In `@frontend/tests/e2e/helpers.ts`:
- Around line 444-446: Update createTestPurchaseOrder so supplierName uses a
collision-free identifier instead of Math.random(), preferably
crypto.randomUUID() or a timestamp combined with the Playwright worker index,
while preserving the existing “[TEST] Supplier” naming format.
In `@frontend/tests/e2e/purchasing/stock-search.spec.ts`:
- Around line 117-134: Update the response listener in the stock-search test to
count search responses based on whether they occur after the input is cleared,
rather than excluding URLs containing q=5mm. Start tracking post-clear responses
only immediately before input.fill(''), so the initial search is ignored while
any repeated q=5mm request after clearing is counted and fails the existing
assertion.
🪄 Autofix

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 Plus

Run ID: 66ca2b61-202f-4ca9-a415-b773f6995a0a

📥 Commits

Reviewing files that changed from the base of the PR and between 3030b60 and a3451f2.

📒 Files selected for processing (60)
  • docs/adr/0039-one-implementation-per-concept.md
  • docs/code-quality.md
  • docs/cutover-checklist.md
  • docs/rewrite-status.md
  • frontend/src/api/index.ts
  • frontend/src/api/query-client.ts
  • frontend/src/features/crm/CompaniesListPage.tsx
  • frontend/src/features/crm/CompanyDetailPage.tsx
  • frontend/src/features/job/JobCreatePage.tsx
  • frontend/src/features/job/JobFinishTab.tsx
  • frontend/src/features/job/JobSettingsTab.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/purchasing/JobSelect.test.ts
  • frontend/src/features/purchasing/JobSelect.tsx
  • frontend/src/features/purchasing/PoCreatePage.tsx
  • frontend/src/features/purchasing/PoDetailPage.tsx
  • frontend/src/features/purchasing/PoLinesTable.tsx
  • frontend/src/features/purchasing/PoListPage.tsx
  • frontend/src/features/purchasing/PoSummaryCard.tsx
  • frontend/src/features/purchasing/StockPage.tsx
  • frontend/src/features/purchasing/index.ts
  • frontend/src/features/purchasing/lines.test.ts
  • frontend/src/features/purchasing/lines.ts
  • frontend/src/features/purchasing/usePoLines.ts
  • frontend/src/features/reports/JobMovementReportPage.tsx
  • frontend/src/features/reports/WipReportPage.tsx
  • frontend/src/features/shared/DataTable.tsx
  • frontend/src/features/shared/ItemSelect.tsx
  • frontend/src/features/shared/ListTable.test.tsx
  • frontend/src/features/shared/ListTable.tsx
  • frontend/src/features/shared/QueryState.test.tsx
  • frontend/src/features/shared/QueryState.tsx
  • frontend/src/features/shared/SaveFailedBadge.tsx
  • frontend/src/features/shared/company/CompanyLookup.test.tsx
  • frontend/src/features/shared/company/CompanyLookup.tsx
  • frontend/src/features/shared/company/CreateCompanyModal.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.test.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.tsx
  • frontend/src/features/shared/company/PersonSelector.tsx
  • frontend/src/features/shared/company/create-company.ts
  • frontend/src/features/shared/company/index.ts
  • frontend/src/features/shared/company/xero-contact.ts
  • frontend/src/features/shared/decimal.test.ts
  • frontend/src/features/shared/decimal.ts
  • frontend/src/features/shared/useDebouncedValue.test.tsx
  • frontend/src/features/shared/useDebouncedValue.ts
  • frontend/src/features/timesheet/DailyOverviewPage.tsx
  • frontend/src/features/timesheet/SmartTimesheetTable.tsx
  • frontend/src/features/timesheet/TimesheetEntryPage.tsx
  • frontend/src/routeTree.gen.ts
  • frontend/src/routes/_authed/purchasing/po/$poId.tsx
  • frontend/src/routes/_authed/purchasing/po/create.tsx
  • frontend/src/routes/_authed/purchasing/po/index.tsx
  • frontend/src/routes/_authed/purchasing/stock.tsx
  • frontend/tests/e2e/helpers.ts
  • frontend/tests/e2e/purchasing/create-purchase-order.spec.ts
  • frontend/tests/e2e/purchasing/po-created-by.spec.ts
  • frontend/tests/e2e/purchasing/stock-search.spec.ts
💤 Files with no reviewable changes (2)
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/calc.test.ts
🛑 Comments failed to post (3)
frontend/src/features/shared/company/CompanyLookup.tsx (2)

88-99: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Ignore completion from abandoned company-creation interactions.

Both creation paths apply a completed mutation after the initiating UI state can change. A quick-create response can select an old query after the user continues searching. A modal response can select a company after the user closes the dialog.

  • frontend/src/features/shared/company/CompanyLookup.tsx#L88-L99: associate quick creation with an active request token, and select the returned company only if the request still matches the current interaction.
  • frontend/src/features/shared/company/CreateCompanyModal.tsx#L49-L64: prevent dismissal while creating or ignore a completion after the modal closes. Add regression tests for both paths.
📍 Affects 2 files
  • frontend/src/features/shared/company/CompanyLookup.tsx#L88-L99 (this comment)
  • frontend/src/features/shared/company/CreateCompanyModal.tsx#L49-L64
🤖 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 `@frontend/src/features/shared/company/CompanyLookup.tsx` around lines 88 - 99,
Associate quickCreateCompany with an active request token and call
handleCompanyCreated only when the response still matches the current lookup
interaction; otherwise ignore the completion. In
frontend/src/features/shared/company/CompanyLookup.tsx lines 88-99, update
quickCreateCompany accordingly. In
frontend/src/features/shared/company/CreateCompanyModal.tsx lines 49-64, either
prevent dismissal while creation is pending or ignore completion after the modal
closes. Add regression tests covering abandoned quick-create and modal-creation
interactions.

238-242: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Render a search failure state.

If companiesSearchRetrieveOptions rejects, search.isPending is false and this branch renders No companies found.. The user receives a false empty-result message. Render an error state when search.isError is true, and reserve the empty state for successful empty responses.

🤖 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 `@frontend/src/features/shared/company/CompanyLookup.tsx` around lines 238 -
242, Update the suggestions empty-state rendering near
companiesSearchRetrieveOptions to check search.isError first and display an
error state when the request fails. Gate the existing “No companies found”
message on a successful, non-error search so rejected requests never appear as
empty results.
frontend/src/features/shared/company/PersonSelectionModal.tsx (1)

242-281: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The delete overlay does not block the form column.

The confirmation panel is absolute inset-0 inside the people-list column only. The create/edit form and the submit button stay interactive while the confirmation is open. submitDisabled at Line 226 also ignores removeLink.isPending, so a user can start a create while a delete is in flight. Consider disabling the footer submit while deleteTarget !== null or removeLink.isPending.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
242 - 281, Update the form footer submit state in the component’s submitDisabled
logic to also disable submission whenever deleteTarget is non-null or
removeLink.isPending is true. Keep the existing confirmation overlay behavior
and ensure create/edit actions cannot start while deletion is pending or
awaiting confirmation.

corrinand others added 4 commits August 11, 2026 12:47
- CompaniesListPage/JobFinishTab: QueryState was passed the raw
isError, breaking the first-load-only rule the other converted
sites already follow -- a background refetch failure was
unmounting already-rendered content instead of leaving it on
screen.
- JobSelect: closing the picker without a selection (blur or
Escape) left `editing` true forever, so the input kept showing
the abandoned search term instead of reverting to the bound job;
the blur timer was untracked and could fire a state update after
unmount. Also added keyboard selection (arrow keys + Enter,
role=listbox/option, aria-activedescendant) -- the dropdown was
mouse-only.
- PoListPage: the row was reachable only by mouse click; added a
real Link on the PO number cell, matching CompaniesListPage's
existing pattern.
- decimal.ts: parseDecimalInput accepted anything Number() parses,
including hex/octal/binary literals and bare exponents, and sent
them to the wire verbatim as garbage Decimal strings. Validates
fixed-point syntax explicitly now.
- Stale features/company/* paths in rewrite-status.md's build-order
table, left behind by the features/shared/company move.
- Comments narrating port history or test intent rewritten to state
the rejected alternative and the constraint (ADR 0043) in
ItemSelect, SaveFailedBadge, CreateCompanyModal, CompanyLookup;
two flagged sites (the E2E-repair-loop comment, the sonner test
comment) were already compliant and left as-is.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
PR #54 (main) widened pyproject.toml's django specifier to
>=6.0,<6.2 but never regenerated the matching uv.lock, which still
recorded <6.1 in its requires-dist metadata. Every `uv run` this
session silently self-corrected the lock locally; committing that
fix rather than leaving a lockfile permanently out of sync with the
manifest it's supposed to pin.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
frontend/test-results/ was covered; run_e2e.sh's Playwright
invocation also writes artifacts relative to the repo root in some
invocations, leaving an untracked test-results/ dangling after every
E2E run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
…config
playwright.config.ts resolved .env.test, testDir, outputDir and the
html reporter's folder relative to process.cwd(). Any invocation
whose cwd wasn't frontend/ (npm --prefix from the repo root, a bare
npx playwright test) silently missed .env.test -- dropping
E2E_TEST_USERNAME/PASSWORD with no error -- or wrote artifacts to
the wrong location instead of erroring loudly, which is how a
root-level test-results/ kept reappearing. Anchored every one of
these to import.meta.dirname instead: verified from an unrelated
cwd, .env/.env.test still resolve correctly and no stray directory
gets created anywhere. The root-level gitignore entry from the
previous commit stays as a defensive backstop, but this is the real
fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
@corrin
corrin merged commit f7a8080 into mainAug 11, 2026
3 checks passed
@corrin
corrin deleted the purchasing-po branch August 11, 2026 02:01
Sign up for freeto 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.

1 participant

@corrin
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Purchasing: PO create/list/detail, stock page (29/40 specs) - #65

Merged
corrin merged 14 commits into
mainfrom
purchasing-po
Aug 11, 2026
Merged

Purchasing: PO create/list/detail, stock page (29/40 specs)#65
corrin merged 14 commits into
mainfrom
purchasing-po

Conversation

@corrin

@corrincorrin commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Summary

Ports three of the five purchasing-cluster E2E specs (po-created-by, create-purchase-order, stock-search) with a full purchasing UI: PO list/create/detail pages, an editable PO-lines grid, and a stock page. supplier-alias-search and pickup-address (the two remaining, Google-Places-dependent specs) are next.

Backend was already ported in a prior slice; this is frontend-only.

Architecture work beyond the target specs

Three rounds of adversarial review found this slice was about to leave (or was leaving) real structural debt behind, and each was fixed before merge rather than deferred:

  • features/shared/DataTable.tsx — the one owner of the editable-grid E2E contract (DataTable-row-N, data-grid-*). The new PoLinesTable would otherwise have been a third hand-rolled copy alongside SmartTimesheetTable and CostLineGrid; all three now render through it.
  • features/shared/QueryState.tsx + features/shared/ListTable.tsx — the one owner of the pending/error gate every query-backed page or panel repeats, and the plain-rows-table shell layered over it. PoListPage/StockPage had copied CompaniesListPage's loading/error/retry block verbatim; an exhaustive sweep (three passes, each catching what the last missed) found and converted every genuine instance across the frontend — PoDetailPage, CostLineGrid, JobMovementReportPage, CompanyDetailPage, JobFinishTab, DailyOverviewPage, TimesheetEntryPage. A few sites stay deliberately unconverted (embedded card widgets with richer branching than binary success/fail; one guard-clause-shaped early return matching CLAUDE.md's own preference) — reasoning recorded in rewrite-status.md.
  • features/companyfeatures/shared/company — it had no route of its own and was already cross-imported by job; this slice's PoSummaryCard would have been a third cross-domain import of what was really a shared widget library sitting in a domain-shaped directory.
  • Bug fix: PoLinesTable's item-picker label had no description fallback, so a bound stock item with a null item_code (nullable, v1 parity) misread as unbound.

ADR 0039 was strengthened with the underlying principle: architectural unification is never deferred to a later slice, and shared concepts get shared homes.

Also (unrelated to purchasing, fixed in the same branch at the user's direction): docs/rewrite-status.md and docs/cutover-checklist.md corrected — the SSE/live-updates work (Slice 3) was misfiled as a post-cutover deferral and is actually MUST-before-cutover, and the release-gate section now states both go/no-go criteria (functional parity, architecture quality) rather than only the E2E proxy for the first.

Test plan

  • npm run type-check clean
  • npx vitest run src/features — 197/197 passing
  • Cheap + expensive pre-commit gate tiers green
  • ./scripts/ops/run_e2e.sh (full 88-case suite) green
  • Three rounds of adversarial subagent review, all findings resolved

🤖 Generated with Claude Code

https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L

Summary by CodeRabbit

  • New Features
    • Added purchasing pages for stock browsing and purchase-order creation, listing, and details.
    • Added supplier and job search, purchase-order line editing, autosave, status updates, and draft-line handling.
    • Added company and contact lookup, creation, selection, and editing workflows.
    • Added debounced stock and company searches with cached results when searches are cleared.
  • Bug Fixes
    • Improved loading, error, retry, and save-failure feedback across key screens.
    • Added safer purchase-order updates when concurrent changes occur.
  • Tests
    • Expanded automated coverage for purchasing, shared controls, search, accessibility, and end-to-end workflows.

corrinand others added 10 commits August 10, 2026 22:28
PoCreatePage (CompanyLookup + reference + save -> 201 redirect),
PoListPage, PoDetailPage with PoSummaryCard (created-by input,
reference autosave, status select), usePoLines over the single PATCH
endpoint, and the missing 'po' concurrency invalidator so 412/428
recovery actually refetches. Ports createTestPurchaseOrder +
waitForPoAutosave and the po-created-by spec.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
PoLinesTable as the third grid on the useReactTable + useDraftRows
pattern (phantom row, no add-line button, row-exit draft commit —
unit-cost stays the row's last focusable cell so the spec's Tab exits
the row). ItemSelect generalised for stock-only consumers (optional
jobId/line, label + wrapper overrides; labour-rates query gated on
jobId presence only, since textOnly labels need rate names). Inline
JobSelect over purchasing_all_jobs_retrieve — the unfiltered endpoint
v1's PO page uses, because fresh jobs are draft and the filtered
sibling excludes them. Spec ported with the autosave waiter armed
before the pick/status clicks (v2 saves immediately; v1's debounce is
what made arm-after work).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Spec-lean StockPage: full active-stock list on load, 300ms-debounced
server FTS from 3 characters, enabled-gated so clearing the box
renders the cached list with no /search/ request.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
…ports
The purchasing slice's PoLinesTable was about to become a third
hand-rolled grid emitting the DataTable-row-N/data-grid-* contract
inline. features/shared/DataTable.tsx is now the one owner of that
contract; SmartTimesheetTable, CostLineGrid and PoLinesTable all
render through it.
Auditing further found the same pathology in the plain list pages:
PoListPage and StockPage (both new this slice) had copied
CompaniesListPage's table shell and loading/error/retry block
verbatim, taking an existing duplicate (also in WipReportPage) from
2 instances to 4. features/shared/ListTable.tsx is the one owner of
that block instead -- deliberately separate from DataTable, since it
has no react-table dependency and forcing static lists through
column-def machinery would be indirection, not rigor. A hand-rolled
debounce-into-query-state pattern in CompaniesListPage and StockPage
is now features/shared/useDebouncedValue.ts (KanbanSearchInput keeps
its own URL-driven debounce -- a different concept).
ItemSelect, the decimal helpers and the Save-failed badge move to
features/shared/ because purchasing consumed them cross-domain -- a
domain feature is not a library.
ADR 0039 strengthened: unification is never deferred, shared concepts
get shared homes, the bar is reference quality.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Adversarial review on the branch found the ListTable audit had stopped
short: JobMovementReportPage and CompanyDetailPage hand-rolled the same
loading/error/retry block ListTable was built to own (6 real instances,
only 4 fixed). Split the block itself out as
features/shared/QueryState.tsx -- the pending/error gate alone, no
table -- so it fits pages that show something other than a table too.
ListTable now composes QueryState instead of duplicating it. PoDetailPage,
CostLineGrid, JobMovementReportPage and CompanyDetailPage all render
through it.
Fixed: PoLinesTable's item-picker label read `item_code ?? 'Select Item'`
with no description fallback, so a bound stock item with a null code
(nullable, v1 parity) misread as unbound. Now poLineItemLabel() in
lines.ts, unit-tested.
Moved features/company to features/shared/company: it was already
cross-imported by features/job (JobCreatePage, JobSettingsTab) before
this slice added a third importer (purchasing/PoSummaryCard) -- it never
had a route of its own, a shared widget library in a domain-shaped box.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
Reversed 2026-08-11. The 2026-08-10 record said SSE ships with the
production-serving decision, filed under "Post-cutover -- decided,
deliberately NOT before 15 August." That was overturned: racing bad
architecture into production defeats the point of the rewrite
(non-negotiable #3 in the Cutover section), and the interim polling
shape plus the un-runnable apps/xero/sync_stream.py view are exactly
that. Slice 3 -- live updates done properly (serving model fix + SSE
ticker + discard the interim shortcuts) moves to its own MUST-tier
section with a milestone checkbox, and every stale "deferred
post-cutover" cross-reference in the file is corrected. The
purchasing-slice PR is unaffected -- it is unrelated and merges as
planned; this correction only fixes the durable record for the next
session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
QueryState grew optional loadingNode/errorNode override props so a
spinner-based caller keeps its visual shell instead of losing it to
the plain-text default. JobFinishTab, DailyOverviewPage, and both
gates in TimesheetEntryPage's EntryWorkspace now render through it.
XeroQuoteCard, JobInvoiceCard and JobSettingsTab's pay-item field stay
excluded -- richer branching than binary success/fail, not the
page-level gate QueryState owns. TimesheetEntryPage's own outer gate
stays as guard-clause `if` returns, matching CLAUDE.md's stated
preference rather than converting to a shape that would abandon it.
Also: rewrite-status.md's narration of "review round found X, we
fixed Y" replaced with what the outcomes actually are -- which shared
component owns which contract, which sites are deliberately excluded
and why, which constraints a future change must respect. And the
Cutover section now states the actual two-question go/no-go criteria
(functional parity, proxied by MUST-tier E2E; materially better
architecture, judged directly) rather than only the E2E proxy, with
the honest fallback (abort and stay on v1) stated explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
@coderabbitai

coderabbitaiBot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@corrin, you've reached your PR review limit, so we couldn't start this review.

Next review available in:12 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 756f5951-a815-47ee-9870-21c9be6081d2

📥 Commits

Reviewing files that changed from the base of the PR and between a3451f2 and ac028e2.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (14)
  • .gitignore
  • docs/rewrite-status.md
  • frontend/playwright.config.ts
  • frontend/src/features/crm/CompaniesListPage.tsx
  • frontend/src/features/job/JobFinishTab.tsx
  • frontend/src/features/purchasing/JobSelect.test.tsx
  • frontend/src/features/purchasing/JobSelect.tsx
  • frontend/src/features/purchasing/PoListPage.tsx
  • frontend/src/features/shared/ItemSelect.tsx
  • frontend/src/features/shared/SaveFailedBadge.tsx
  • frontend/src/features/shared/company/CompanyLookup.tsx
  • frontend/src/features/shared/company/CreateCompanyModal.tsx
  • frontend/src/features/shared/decimal.test.ts
  • frontend/src/features/shared/decimal.ts
📝 Walkthrough

Walkthrough

The PR adds authenticated purchasing pages for stock and purchase orders, including editable lines, autosave, optimistic updates, and E2E coverage. It also extracts shared query, table, company, decimal, and debounce components and updates architecture and cutover documentation.

Changes

Purchasing frontend

Layer / File(s)Summary
Purchasing contracts and persistence
frontend/src/api/..., frontend/src/features/purchasing/JobSelect.tsx, frontend/src/features/purchasing/lines.ts, frontend/src/features/purchasing/usePoLines.ts
Adds purchasing API exports, job filtering, PO draft utilities, optimistic mutations, concurrency invalidation, and refetch reconciliation.
Purchasing pages and routing
frontend/src/features/purchasing/..., frontend/src/routes/_authed/purchasing/..., frontend/src/routeTree.gen.ts
Adds PO list, create, detail, editable line, summary, and stock-search pages with authenticated routes.
Purchasing validation
frontend/tests/e2e/purchasing/..., frontend/tests/e2e/helpers.ts, frontend/src/features/purchasing/*.test.ts
Adds unit and E2E coverage for PO creation, autosave, status updates, created-by values, job filtering, draft serialization, and stock search.

Shared frontend consolidation

Layer / File(s)Summary
Shared query and grid infrastructure
frontend/src/features/shared/..., frontend/src/features/crm/..., frontend/src/features/job/..., frontend/src/features/reports/..., frontend/src/features/timesheet/...
Adds QueryState, ListTable, DataTable, SaveFailedBadge, decimal utilities, and useDebouncedValue, then adopts them across existing pages.
Shared company and person features
frontend/src/features/shared/company/..., frontend/src/features/job/JobCreatePage.tsx, frontend/src/features/job/JobSettingsTab.tsx
Adds shared company lookup, creation, Xero validation, person selection, and person management components, and updates consumers to use the shared module.

Architecture and cutover documentation

Layer / File(s)Summary
Architecture and release rules
docs/adr/0039-one-implementation-per-concept.md, docs/cutover-checklist.md, docs/rewrite-status.md, docs/code-quality.md
Documents immediate implementation unification, independent functionality and architecture gates, threaded serving requirements, purchasing progress, and updated suppression counts.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 54.24% 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 nameStatusExplanation
Title check✅ PassedThe title clearly identifies the purchasing pages and spec progress covered by the pull request.
Description check✅ PassedThe description explains the scope, architectural changes, remaining work, and validation results in sufficient detail.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch purchasing-po

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.

@coderabbitaicoderabbitaiBot 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: 10

🧹 Nitpick comments (11)
docs/cutover-checklist.md (1)

115-123: 🩺 Stability & Availability | 🔵 Trivial

Define the ASGI acceptance criteria.

or the ASGI equivalent is not an executable serving requirement. Define the minimum concurrency, timeout, keep-alive, database-connection, and ORM-safety requirements. Link this checkbox to the deployment configuration that enforces them.

🤖 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 `@docs/cutover-checklist.md` around lines 115 - 123, Update the serving-model
checklist item to define executable ASGI acceptance criteria: minimum
concurrency, request timeout, keep-alive, database-connection capacity, and
ORM-safety requirements. Link the checkbox to the deployment configuration or
command that enforces these settings, while retaining the existing WSGI option
and cutover context.
frontend/src/features/purchasing/StockPage.tsx (2)

25-31: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Send the trimmed query.

searchActive is computed from query.trim(), but the request sends the untrimmed query. For an input of " abc" the gate opens and q is " abc". Each whitespace variation also produces a distinct query key, which adds duplicate cache entries and duplicate requests for the same search term.

♻️ Proposed fix
- const searchActive = query.trim().length >= MIN_QUERY_LENGTH+ const trimmedQuery = query.trim()+ const searchActive = trimmedQuery.length >= MIN_QUERY_LENGTH
const list = useQuery(purchasingStockListOptions())
const search = useQuery({
- ...purchasingStockSearchRetrieveOptions({ query: { q: query } }),+ ...purchasingStockSearchRetrieveOptions({ query: { q: trimmedQuery } }),
enabled: searchActive,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 25 - 31, Update
the search query construction near searchActive in StockPage so the request and
its query key use the trimmed query value, while preserving the existing
minimum-length gating behavior.

27-33: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Preserve the previous search results while fetching a new query.

Each active search term creates a new query key. search.isPending then replaces the table with Loading stock items.... Use placeholderData: keepPreviousData to keep the current rows visible during the fetch.

♻️ Proposed refactor
-import { useQuery } from '`@tanstack/react-query`'+import { keepPreviousData, useQuery } from '`@tanstack/react-query`'
 const search = useQuery({
...purchasingStockSearchRetrieveOptions({ query: { q: query } }),
enabled: searchActive,
+ placeholderData: keepPreviousData,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 27 - 33, Update
the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
frontend/tests/e2e/helpers.ts (1)

444-446: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace Math.random with a collision-free identifier.

Math.floor(Math.random() * 100000) gives 100,000 possible values. Parallel Playwright workers and repeated local runs can produce the same supplier name. A duplicate [TEST] Supplier N creates a second live Xero contact push with the same name, which makes later lookups ambiguous.

Use crypto.randomUUID() or combine the timestamp with the worker index.

♻️ Proposed change
- const randomSuffix = Math.floor(Math.random() * 100000)- const supplierName = `[TEST] Supplier ${randomSuffix}`+ const randomSuffix = crypto.randomUUID().slice(0, 8)+ const supplierName = `[TEST] Supplier ${randomSuffix}`
🤖 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 `@frontend/tests/e2e/helpers.ts` around lines 444 - 446, Update
createTestPurchaseOrder so supplierName uses a collision-free identifier instead
of Math.random(), preferably crypto.randomUUID() or a timestamp combined with
the Playwright worker index, while preserving the existing “[TEST] Supplier”
naming format.
frontend/tests/e2e/purchasing/stock-search.spec.ts (1)

117-134: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The counter filter can miss a regression.

The listener ignores every /search/ response whose URL contains q=5mm. The exclusion exists to skip the first search, but it also hides the most likely regression: clearing the input re-fires the same q=5mm request. That request is a real post-clear search call, and the assertion at Line 134 still passes.

Gate on time instead of on the query value.

♻️ Proposed change
- // Track whether any further /search/ request fires when we clear the box.+ // Gate on the clear action, not on the query value: a re-fired `q=5mm`+ // request after clearing is still a post-clear search call.
let postClearSearchCalls = 0
+ let cleared = false
page.on('response', (response) => {
- if (- response.url().includes('/api/purchasing/stock/search/') &&- !response.url().includes('q=5mm')- ) {+ if (cleared && response.url().includes('/api/purchasing/stock/search/')) {
postClearSearchCalls += 1
}
})
+ cleared = true
await input.fill('')
🤖 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 `@frontend/tests/e2e/purchasing/stock-search.spec.ts` around lines 117 - 134,
Update the response listener in the stock-search test to count search responses
based on whether they occur after the input is cleared, rather than excluding
URLs containing q=5mm. Start tracking post-clear responses only immediately
before input.fill(''), so the initial search is ignored while any repeated q=5mm
request after clearing is counted and fails the existing assertion.
frontend/src/features/shared/ListTable.tsx (1)

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

Document that renderRow must supply the React key.

Line 66 calls rows.map(renderRow) and applies no key. Every current caller sets key on its own <tr>. That contract is not stated on the prop, so a future caller can omit the key and cause a React key warning plus incorrect row reconciliation.

♻️ Proposed doc addition
+ /** Must set a stable React `key` on the returned row element; ListTable+ maps rows directly and applies no key of its own. */
renderRow: (row: TRow) => ReactNode

Also applies to: 66-66

🤖 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 `@frontend/src/features/shared/ListTable.tsx` at line 21, Document the
`renderRow` prop contract in `ListTable`: implementations must return each row
with its own stable React `key`, since the `rows.map(renderRow)` call does not
apply one. Add this requirement to the prop’s existing documentation without
changing the rendering logic.
frontend/src/features/shared/company/PersonSelectionModal.test.tsx (1)

19-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering the create and update flows.

The single test covers accessibility only. handleCreate and handleUpdate hold the business rules: first person is always primary, blank optional fields are omitted from the request body, and an invalid email blocks submission. These rules are unit-testable with mocked mutations.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx` around
lines 19 - 48, Extend the PersonSelectionModal tests beyond accessibility to
cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
frontend/src/features/shared/company/PersonSelectionModal.tsx (3)

294-303: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The person card is a clickable div.

The card carries onClick but no role, tabIndex, or key handler. Keyboard users reach the person through the hover-revealed Select button, which group-focus-within exposes, so the flow is not blocked. Removing the card-level onClick and relying on the explicit Select button would remove the duplicated activation path and the event.stopPropagation() calls at Lines 341, 354, and 367.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
294 - 303, The person card in the person-selection rendering should no longer be
clickable via its container. Remove the card-level onClick handler from the div
around person.person_id, rely on the explicit Select button for activation, and
remove the associated event.stopPropagation() calls in the button handlers.

218-220: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse invalidatePeople here.

Lines 218-220 repeat the query-key invalidation that invalidatePeople defines at Lines 118-121. handleUpdate and handleConfirmDelete already call the helper. A future key change would need two edits.

♻️ Proposed fix
- await queryClient.invalidateQueries({- queryKey: companiesPeopleListQueryKey({ path: { company_id: companyId } }),- })+ await invalidatePeople()
As per coding guidelines: "Use one implementation per concept; search before implementing and extend a near-match instead of creating a parallel implementation."
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
218 - 220, Replace the direct companies-people query invalidation in the
relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.

Source: Coding guidelines


534-542: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Handle the returned promise on the submit button.

handleUpdate and handleCreate are async. Passing them directly to onClick returns a floating promise from the event handler. The delete button at Line 272 already wraps its call with void. Use the same form here for consistency and to satisfy @typescript-eslint/no-misused-promises if that rule is enabled.

♻️ Proposed fix
- onClick={editingPerson ? handleUpdate : handleCreate}+ onClick={() => {+ void (editingPerson ? handleUpdate() : handleCreate())+ }}
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
534 - 542, Update the submit button’s onClick handler to explicitly discard the
promise returned by handleUpdate or handleCreate, matching the existing
delete-button pattern. Preserve the editingPerson conditional selection and
button behavior.
frontend/src/features/shared/useDebouncedValue.test.tsx (1)

39-46: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the fake-timer queue on unmount.

clearTimeout is global, so another cleanup can satisfy toHaveBeenCalled(). Since this suite enables fake timers, assert that vi.getTimerCount() is greater than zero after renderHook, then zero after unmount().

🤖 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 `@frontend/src/features/shared/useDebouncedValue.test.tsx` around lines 39 -
46, Update the “clears a pending timer on unmount” test to assert the fake-timer
queue directly: verify vi.getTimerCount() is greater than zero after renderHook
creates the debounced timer, then verify it is zero after unmount(). Remove the
clearTimeout spy assertion.
🤖 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 `@docs/rewrite-status.md`:
- Around line 57-61: Update the frontend rebuild table in the rewrite-status
document to replace references to features/company/PersonSelectionModal.tsx and
features/company/CompanyLookup.tsx with their canonical features/shared/company/
paths, leaving other entries unchanged.
In `@frontend/src/features/crm/CompaniesListPage.tsx`:
- Around line 113-119: Apply the first-load-only error rule at both QueryState
call sites: in frontend/src/features/crm/CompaniesListPage.tsx lines 113-119,
change the ListTable isError value to companies.isError && companies.data ===
undefined; in frontend/src/features/job/JobFinishTab.tsx lines 209-213, use a
loadError derived per query from isError && data === undefined so cached summary
and checklist content remains visible during failed refetches.
In `@frontend/src/features/purchasing/JobSelect.tsx`:
- Around line 113-150: Update JobSelect to support keyboard navigation by
tracking an active option index, moving it with ArrowUp/ArrowDown, and selecting
the active job on Enter. Add role="listbox" to the dropdown and role="option"
with aria-selected to each mapped job row, making options keyboard-accessible
and reusing the shared picker pattern if available.
- Around line 91-111: Update the closing paths in JobSelect’s onBlur timeout and
Escape handler to reset editing and clear the stale search term when the picker
closes without selection, so value returns to the bound job and filtering does
not reuse old input. Store the 150ms blur timeout in a ref and add a useEffect
cleanup that clears it on unmount, while preserving the existing select
behavior.
In `@frontend/src/features/purchasing/PoListPage.tsx`:
- Around line 57-74: Update the purchase-order row in PoListPage’s renderRow
callback to be keyboard accessible: make the tr focusable, expose it as an
interactive control, and handle keyboard activation by navigating to the same
po/$poId destination as onClick. Preserve the existing mouse navigation
behavior.
In `@frontend/src/features/shared/company/CompanyLookup.tsx`:
- Around line 238-242: Update the suggestions empty-state rendering near
companiesSearchRetrieveOptions to check search.isError first and display an
error state when the request fails. Gate the existing “No companies found”
message on a successful, non-error search so rejected requests never appear as
empty results.
- Around line 88-99: Associate quickCreateCompany with an active request token
and call handleCompanyCreated only when the response still matches the current
lookup interaction; otherwise ignore the completion. In
frontend/src/features/shared/company/CompanyLookup.tsx lines 88-99, update
quickCreateCompany accordingly. In
frontend/src/features/shared/company/CreateCompanyModal.tsx lines 49-64, either
prevent dismissal while creation is pending or ignore completion after the modal
closes. Add regression tests covering abandoned quick-create and modal-creation
interactions.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 242-281: Update the form footer submit state in the component’s
submitDisabled logic to also disable submission whenever deleteTarget is
non-null or removeLink.isPending is true. Keep the existing confirmation overlay
behavior and ensure create/edit actions cannot start while deletion is pending
or awaiting confirmation.
In `@frontend/src/features/shared/decimal.ts`:
- Around line 10-16: Update parseDecimalInput to validate cleaned input against
an explicit fixed-point decimal syntax before numeric finiteness checks,
rejecting hex, octal, binary, and other non-decimal forms. Normalize accepted
values such as “.5”, “+5”, and “5.” into the backend-supported decimal
representation, and ensure trimDecimal cannot return invalid syntax verbatim.
In `@frontend/src/features/shared/ItemSelect.tsx`:
- Around line 19-43: Rewrite or remove the comments at
frontend/src/features/shared/ItemSelect.tsx:19-43, replacing implementation,
E2E, and history narration with the rejected API alternative and factual
constraint, or remove them. Apply the same constraint-based approach at
frontend/src/features/shared/SaveFailedBadge.tsx:1-4,
frontend/src/features/shared/decimal.test.ts:33-34 (including the rejected
formatting behavior and exponent constraint),
frontend/src/features/shared/company/CompanyLookup.test.tsx:154-155 (including
the Sonner rendering constraint if retained),
frontend/src/features/shared/company/CompanyLookup.tsx:88-90, and
frontend/src/features/shared/company/CreateCompanyModal.tsx:16-26 and :39
(document only rejected alternatives and current product/state constraints,
otherwise remove the comments).
---
Nitpick comments:
In `@docs/cutover-checklist.md`:
- Around line 115-123: Update the serving-model checklist item to define
executable ASGI acceptance criteria: minimum concurrency, request timeout,
keep-alive, database-connection capacity, and ORM-safety requirements. Link the
checkbox to the deployment configuration or command that enforces these
settings, while retaining the existing WSGI option and cutover context.
In `@frontend/src/features/purchasing/StockPage.tsx`:
- Around line 25-31: Update the search query construction near searchActive in
StockPage so the request and its query key use the trimmed query value, while
preserving the existing minimum-length gating behavior.
- Around line 27-33: Update the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
In `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx`:
- Around line 19-48: Extend the PersonSelectionModal tests beyond accessibility
to cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 294-303: The person card in the person-selection rendering should
no longer be clickable via its container. Remove the card-level onClick handler
from the div around person.person_id, rely on the explicit Select button for
activation, and remove the associated event.stopPropagation() calls in the
button handlers.
- Around line 218-220: Replace the direct companies-people query invalidation in
the relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.
- Around line 534-542: Update the submit button’s onClick handler to explicitly
discard the promise returned by handleUpdate or handleCreate, matching the
existing delete-button pattern. Preserve the editingPerson conditional selection
and button behavior.
In `@frontend/src/features/shared/ListTable.tsx`:
- Line 21: Document the `renderRow` prop contract in `ListTable`:
implementations must return each row with its own stable React `key`, since the
`rows.map(renderRow)` call does not apply one. Add this requirement to the
prop’s existing documentation without changing the rendering logic.
In `@frontend/src/features/shared/useDebouncedValue.test.tsx`:
- Around line 39-46: Update the “clears a pending timer on unmount” test to
assert the fake-timer queue directly: verify vi.getTimerCount() is greater than
zero after renderHook creates the debounced timer, then verify it is zero after
unmount(). Remove the clearTimeout spy assertion.
In `@frontend/tests/e2e/helpers.ts`:
- Around line 444-446: Update createTestPurchaseOrder so supplierName uses a
collision-free identifier instead of Math.random(), preferably
crypto.randomUUID() or a timestamp combined with the Playwright worker index,
while preserving the existing “[TEST] Supplier” naming format.
In `@frontend/tests/e2e/purchasing/stock-search.spec.ts`:
- Around line 117-134: Update the response listener in the stock-search test to
count search responses based on whether they occur after the input is cleared,
rather than excluding URLs containing q=5mm. Start tracking post-clear responses
only immediately before input.fill(''), so the initial search is ignored while
any repeated q=5mm request after clearing is counted and fails the existing
assertion.
🪄 Autofix

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 Plus

Run ID: 66ca2b61-202f-4ca9-a415-b773f6995a0a

📥 Commits

Reviewing files that changed from the base of the PR and between 3030b60 and a3451f2.

📒 Files selected for processing (60)
  • docs/adr/0039-one-implementation-per-concept.md
  • docs/code-quality.md
  • docs/cutover-checklist.md
  • docs/rewrite-status.md
  • frontend/src/api/index.ts
  • frontend/src/api/query-client.ts
  • frontend/src/features/crm/CompaniesListPage.tsx
  • frontend/src/features/crm/CompanyDetailPage.tsx
  • frontend/src/features/job/JobCreatePage.tsx
  • frontend/src/features/job/JobFinishTab.tsx
  • frontend/src/features/job/JobSettingsTab.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/purchasing/JobSelect.test.ts
  • frontend/src/features/purchasing/JobSelect.tsx
  • frontend/src/features/purchasing/PoCreatePage.tsx
  • frontend/src/features/purchasing/PoDetailPage.tsx
  • frontend/src/features/purchasing/PoLinesTable.tsx
  • frontend/src/features/purchasing/PoListPage.tsx
  • frontend/src/features/purchasing/PoSummaryCard.tsx
  • frontend/src/features/purchasing/StockPage.tsx
  • frontend/src/features/purchasing/index.ts
  • frontend/src/features/purchasing/lines.test.ts
  • frontend/src/features/purchasing/lines.ts
  • frontend/src/features/purchasing/usePoLines.ts
  • frontend/src/features/reports/JobMovementReportPage.tsx
  • frontend/src/features/reports/WipReportPage.tsx
  • frontend/src/features/shared/DataTable.tsx
  • frontend/src/features/shared/ItemSelect.tsx
  • frontend/src/features/shared/ListTable.test.tsx
  • frontend/src/features/shared/ListTable.tsx
  • frontend/src/features/shared/QueryState.test.tsx
  • frontend/src/features/shared/QueryState.tsx
  • frontend/src/features/shared/SaveFailedBadge.tsx
  • frontend/src/features/shared/company/CompanyLookup.test.tsx
  • frontend/src/features/shared/company/CompanyLookup.tsx
  • frontend/src/features/shared/company/CreateCompanyModal.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.test.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.tsx
  • frontend/src/features/shared/company/PersonSelector.tsx
  • frontend/src/features/shared/company/create-company.ts
  • frontend/src/features/shared/company/index.ts
  • frontend/src/features/shared/company/xero-contact.ts
  • frontend/src/features/shared/decimal.test.ts
  • frontend/src/features/shared/decimal.ts
  • frontend/src/features/shared/useDebouncedValue.test.tsx
  • frontend/src/features/shared/useDebouncedValue.ts
  • frontend/src/features/timesheet/DailyOverviewPage.tsx
  • frontend/src/features/timesheet/SmartTimesheetTable.tsx
  • frontend/src/features/timesheet/TimesheetEntryPage.tsx
  • frontend/src/routeTree.gen.ts
  • frontend/src/routes/_authed/purchasing/po/$poId.tsx
  • frontend/src/routes/_authed/purchasing/po/create.tsx
  • frontend/src/routes/_authed/purchasing/po/index.tsx
  • frontend/src/routes/_authed/purchasing/stock.tsx
  • frontend/tests/e2e/helpers.ts
  • frontend/tests/e2e/purchasing/create-purchase-order.spec.ts
  • frontend/tests/e2e/purchasing/po-created-by.spec.ts
  • frontend/tests/e2e/purchasing/stock-search.spec.ts
💤 Files with no reviewable changes (2)
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/calc.test.ts

Comment threaddocs/rewrite-status.md
Comment threadfrontend/src/features/crm/CompaniesListPage.tsx
Comment threadfrontend/src/features/purchasing/JobSelect.tsx
Comment threadfrontend/src/features/purchasing/JobSelect.tsx
Comment threadfrontend/src/features/purchasing/PoListPage.tsx
Comment threadfrontend/src/features/shared/decimal.ts
Comment threadfrontend/src/features/shared/ItemSelect.tsx

@coderabbitaicoderabbitaiBot 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.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 10

🧹 Nitpick comments (11)
docs/cutover-checklist.md (1)

115-123: 🩺 Stability & Availability | 🔵 Trivial

Define the ASGI acceptance criteria.

or the ASGI equivalent is not an executable serving requirement. Define the minimum concurrency, timeout, keep-alive, database-connection, and ORM-safety requirements. Link this checkbox to the deployment configuration that enforces them.

🤖 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 `@docs/cutover-checklist.md` around lines 115 - 123, Update the serving-model
checklist item to define executable ASGI acceptance criteria: minimum
concurrency, request timeout, keep-alive, database-connection capacity, and
ORM-safety requirements. Link the checkbox to the deployment configuration or
command that enforces these settings, while retaining the existing WSGI option
and cutover context.
frontend/src/features/purchasing/StockPage.tsx (2)

25-31: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Send the trimmed query.

searchActive is computed from query.trim(), but the request sends the untrimmed query. For an input of " abc" the gate opens and q is " abc". Each whitespace variation also produces a distinct query key, which adds duplicate cache entries and duplicate requests for the same search term.

♻️ Proposed fix
- const searchActive = query.trim().length >= MIN_QUERY_LENGTH+ const trimmedQuery = query.trim()+ const searchActive = trimmedQuery.length >= MIN_QUERY_LENGTH
const list = useQuery(purchasingStockListOptions())
const search = useQuery({
- ...purchasingStockSearchRetrieveOptions({ query: { q: query } }),+ ...purchasingStockSearchRetrieveOptions({ query: { q: trimmedQuery } }),
enabled: searchActive,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 25 - 31, Update
the search query construction near searchActive in StockPage so the request and
its query key use the trimmed query value, while preserving the existing
minimum-length gating behavior.

27-33: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Preserve the previous search results while fetching a new query.

Each active search term creates a new query key. search.isPending then replaces the table with Loading stock items.... Use placeholderData: keepPreviousData to keep the current rows visible during the fetch.

♻️ Proposed refactor
-import { useQuery } from '`@tanstack/react-query`'+import { keepPreviousData, useQuery } from '`@tanstack/react-query`'
 const search = useQuery({
...purchasingStockSearchRetrieveOptions({ query: { q: query } }),
enabled: searchActive,
+ placeholderData: keepPreviousData,
})
🤖 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 `@frontend/src/features/purchasing/StockPage.tsx` around lines 27 - 33, Update
the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
frontend/tests/e2e/helpers.ts (1)

444-446: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace Math.random with a collision-free identifier.

Math.floor(Math.random() * 100000) gives 100,000 possible values. Parallel Playwright workers and repeated local runs can produce the same supplier name. A duplicate [TEST] Supplier N creates a second live Xero contact push with the same name, which makes later lookups ambiguous.

Use crypto.randomUUID() or combine the timestamp with the worker index.

♻️ Proposed change
- const randomSuffix = Math.floor(Math.random() * 100000)- const supplierName = `[TEST] Supplier ${randomSuffix}`+ const randomSuffix = crypto.randomUUID().slice(0, 8)+ const supplierName = `[TEST] Supplier ${randomSuffix}`
🤖 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 `@frontend/tests/e2e/helpers.ts` around lines 444 - 446, Update
createTestPurchaseOrder so supplierName uses a collision-free identifier instead
of Math.random(), preferably crypto.randomUUID() or a timestamp combined with
the Playwright worker index, while preserving the existing “[TEST] Supplier”
naming format.
frontend/tests/e2e/purchasing/stock-search.spec.ts (1)

117-134: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The counter filter can miss a regression.

The listener ignores every /search/ response whose URL contains q=5mm. The exclusion exists to skip the first search, but it also hides the most likely regression: clearing the input re-fires the same q=5mm request. That request is a real post-clear search call, and the assertion at Line 134 still passes.

Gate on time instead of on the query value.

♻️ Proposed change
- // Track whether any further /search/ request fires when we clear the box.+ // Gate on the clear action, not on the query value: a re-fired `q=5mm`+ // request after clearing is still a post-clear search call.
let postClearSearchCalls = 0
+ let cleared = false
page.on('response', (response) => {
- if (- response.url().includes('/api/purchasing/stock/search/') &&- !response.url().includes('q=5mm')- ) {+ if (cleared && response.url().includes('/api/purchasing/stock/search/')) {
postClearSearchCalls += 1
}
})
+ cleared = true
await input.fill('')
🤖 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 `@frontend/tests/e2e/purchasing/stock-search.spec.ts` around lines 117 - 134,
Update the response listener in the stock-search test to count search responses
based on whether they occur after the input is cleared, rather than excluding
URLs containing q=5mm. Start tracking post-clear responses only immediately
before input.fill(''), so the initial search is ignored while any repeated q=5mm
request after clearing is counted and fails the existing assertion.
frontend/src/features/shared/ListTable.tsx (1)

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

Document that renderRow must supply the React key.

Line 66 calls rows.map(renderRow) and applies no key. Every current caller sets key on its own <tr>. That contract is not stated on the prop, so a future caller can omit the key and cause a React key warning plus incorrect row reconciliation.

♻️ Proposed doc addition
+ /** Must set a stable React `key` on the returned row element; ListTable+ maps rows directly and applies no key of its own. */
renderRow: (row: TRow) => ReactNode

Also applies to: 66-66

🤖 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 `@frontend/src/features/shared/ListTable.tsx` at line 21, Document the
`renderRow` prop contract in `ListTable`: implementations must return each row
with its own stable React `key`, since the `rows.map(renderRow)` call does not
apply one. Add this requirement to the prop’s existing documentation without
changing the rendering logic.
frontend/src/features/shared/company/PersonSelectionModal.test.tsx (1)

19-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering the create and update flows.

The single test covers accessibility only. handleCreate and handleUpdate hold the business rules: first person is always primary, blank optional fields are omitted from the request body, and an invalid email blocks submission. These rules are unit-testable with mocked mutations.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx` around
lines 19 - 48, Extend the PersonSelectionModal tests beyond accessibility to
cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
frontend/src/features/shared/company/PersonSelectionModal.tsx (3)

294-303: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The person card is a clickable div.

The card carries onClick but no role, tabIndex, or key handler. Keyboard users reach the person through the hover-revealed Select button, which group-focus-within exposes, so the flow is not blocked. Removing the card-level onClick and relying on the explicit Select button would remove the duplicated activation path and the event.stopPropagation() calls at Lines 341, 354, and 367.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
294 - 303, The person card in the person-selection rendering should no longer be
clickable via its container. Remove the card-level onClick handler from the div
around person.person_id, rely on the explicit Select button for activation, and
remove the associated event.stopPropagation() calls in the button handlers.

218-220: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse invalidatePeople here.

Lines 218-220 repeat the query-key invalidation that invalidatePeople defines at Lines 118-121. handleUpdate and handleConfirmDelete already call the helper. A future key change would need two edits.

♻️ Proposed fix
- await queryClient.invalidateQueries({- queryKey: companiesPeopleListQueryKey({ path: { company_id: companyId } }),- })+ await invalidatePeople()
As per coding guidelines: "Use one implementation per concept; search before implementing and extend a near-match instead of creating a parallel implementation."
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
218 - 220, Replace the direct companies-people query invalidation in the
relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.

Source: Coding guidelines


534-542: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Handle the returned promise on the submit button.

handleUpdate and handleCreate are async. Passing them directly to onClick returns a floating promise from the event handler. The delete button at Line 272 already wraps its call with void. Use the same form here for consistency and to satisfy @typescript-eslint/no-misused-promises if that rule is enabled.

♻️ Proposed fix
- onClick={editingPerson ? handleUpdate : handleCreate}+ onClick={() => {+ void (editingPerson ? handleUpdate() : handleCreate())+ }}
🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
534 - 542, Update the submit button’s onClick handler to explicitly discard the
promise returned by handleUpdate or handleCreate, matching the existing
delete-button pattern. Preserve the editingPerson conditional selection and
button behavior.
frontend/src/features/shared/useDebouncedValue.test.tsx (1)

39-46: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the fake-timer queue on unmount.

clearTimeout is global, so another cleanup can satisfy toHaveBeenCalled(). Since this suite enables fake timers, assert that vi.getTimerCount() is greater than zero after renderHook, then zero after unmount().

🤖 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 `@frontend/src/features/shared/useDebouncedValue.test.tsx` around lines 39 -
46, Update the “clears a pending timer on unmount” test to assert the fake-timer
queue directly: verify vi.getTimerCount() is greater than zero after renderHook
creates the debounced timer, then verify it is zero after unmount(). Remove the
clearTimeout spy assertion.
🤖 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 `@docs/rewrite-status.md`:
- Around line 57-61: Update the frontend rebuild table in the rewrite-status
document to replace references to features/company/PersonSelectionModal.tsx and
features/company/CompanyLookup.tsx with their canonical features/shared/company/
paths, leaving other entries unchanged.
In `@frontend/src/features/crm/CompaniesListPage.tsx`:
- Around line 113-119: Apply the first-load-only error rule at both QueryState
call sites: in frontend/src/features/crm/CompaniesListPage.tsx lines 113-119,
change the ListTable isError value to companies.isError && companies.data ===
undefined; in frontend/src/features/job/JobFinishTab.tsx lines 209-213, use a
loadError derived per query from isError && data === undefined so cached summary
and checklist content remains visible during failed refetches.
In `@frontend/src/features/purchasing/JobSelect.tsx`:
- Around line 113-150: Update JobSelect to support keyboard navigation by
tracking an active option index, moving it with ArrowUp/ArrowDown, and selecting
the active job on Enter. Add role="listbox" to the dropdown and role="option"
with aria-selected to each mapped job row, making options keyboard-accessible
and reusing the shared picker pattern if available.
- Around line 91-111: Update the closing paths in JobSelect’s onBlur timeout and
Escape handler to reset editing and clear the stale search term when the picker
closes without selection, so value returns to the bound job and filtering does
not reuse old input. Store the 150ms blur timeout in a ref and add a useEffect
cleanup that clears it on unmount, while preserving the existing select
behavior.
In `@frontend/src/features/purchasing/PoListPage.tsx`:
- Around line 57-74: Update the purchase-order row in PoListPage’s renderRow
callback to be keyboard accessible: make the tr focusable, expose it as an
interactive control, and handle keyboard activation by navigating to the same
po/$poId destination as onClick. Preserve the existing mouse navigation
behavior.
In `@frontend/src/features/shared/company/CompanyLookup.tsx`:
- Around line 238-242: Update the suggestions empty-state rendering near
companiesSearchRetrieveOptions to check search.isError first and display an
error state when the request fails. Gate the existing “No companies found”
message on a successful, non-error search so rejected requests never appear as
empty results.
- Around line 88-99: Associate quickCreateCompany with an active request token
and call handleCompanyCreated only when the response still matches the current
lookup interaction; otherwise ignore the completion. In
frontend/src/features/shared/company/CompanyLookup.tsx lines 88-99, update
quickCreateCompany accordingly. In
frontend/src/features/shared/company/CreateCompanyModal.tsx lines 49-64, either
prevent dismissal while creation is pending or ignore completion after the modal
closes. Add regression tests covering abandoned quick-create and modal-creation
interactions.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 242-281: Update the form footer submit state in the component’s
submitDisabled logic to also disable submission whenever deleteTarget is
non-null or removeLink.isPending is true. Keep the existing confirmation overlay
behavior and ensure create/edit actions cannot start while deletion is pending
or awaiting confirmation.
In `@frontend/src/features/shared/decimal.ts`:
- Around line 10-16: Update parseDecimalInput to validate cleaned input against
an explicit fixed-point decimal syntax before numeric finiteness checks,
rejecting hex, octal, binary, and other non-decimal forms. Normalize accepted
values such as “.5”, “+5”, and “5.” into the backend-supported decimal
representation, and ensure trimDecimal cannot return invalid syntax verbatim.
In `@frontend/src/features/shared/ItemSelect.tsx`:
- Around line 19-43: Rewrite or remove the comments at
frontend/src/features/shared/ItemSelect.tsx:19-43, replacing implementation,
E2E, and history narration with the rejected API alternative and factual
constraint, or remove them. Apply the same constraint-based approach at
frontend/src/features/shared/SaveFailedBadge.tsx:1-4,
frontend/src/features/shared/decimal.test.ts:33-34 (including the rejected
formatting behavior and exponent constraint),
frontend/src/features/shared/company/CompanyLookup.test.tsx:154-155 (including
the Sonner rendering constraint if retained),
frontend/src/features/shared/company/CompanyLookup.tsx:88-90, and
frontend/src/features/shared/company/CreateCompanyModal.tsx:16-26 and :39
(document only rejected alternatives and current product/state constraints,
otherwise remove the comments).
---
Nitpick comments:
In `@docs/cutover-checklist.md`:
- Around line 115-123: Update the serving-model checklist item to define
executable ASGI acceptance criteria: minimum concurrency, request timeout,
keep-alive, database-connection capacity, and ORM-safety requirements. Link the
checkbox to the deployment configuration or command that enforces these
settings, while retaining the existing WSGI option and cutover context.
In `@frontend/src/features/purchasing/StockPage.tsx`:
- Around line 25-31: Update the search query construction near searchActive in
StockPage so the request and its query key use the trimmed query value, while
preserving the existing minimum-length gating behavior.
- Around line 27-33: Update the useQuery configuration in StockPage around
purchasingStockSearchRetrieveOptions to set placeholderData to keepPreviousData,
preserving existing search rows while a new query is fetching and avoiding the
pending loading state replacing the table.
In `@frontend/src/features/shared/company/PersonSelectionModal.test.tsx`:
- Around line 19-48: Extend the PersonSelectionModal tests beyond accessibility
to cover handleCreate and handleUpdate with mocked mutations: verify the first
person is always primary, blank optional fields are omitted from the request
payload, and invalid email prevents submission. Assert the appropriate mutation
callbacks and payloads for both create and update flows.
In `@frontend/src/features/shared/company/PersonSelectionModal.tsx`:
- Around line 294-303: The person card in the person-selection rendering should
no longer be clickable via its container. Remove the card-level onClick handler
from the div around person.person_id, rely on the explicit Select button for
activation, and remove the associated event.stopPropagation() calls in the
button handlers.
- Around line 218-220: Replace the direct companies-people query invalidation in
the relevant update flow with the existing invalidatePeople helper, matching
handleUpdate and handleConfirmDelete. Reuse that helper’s established arguments
and remove the duplicated query-key invalidation.
- Around line 534-542: Update the submit button’s onClick handler to explicitly
discard the promise returned by handleUpdate or handleCreate, matching the
existing delete-button pattern. Preserve the editingPerson conditional selection
and button behavior.
In `@frontend/src/features/shared/ListTable.tsx`:
- Line 21: Document the `renderRow` prop contract in `ListTable`:
implementations must return each row with its own stable React `key`, since the
`rows.map(renderRow)` call does not apply one. Add this requirement to the
prop’s existing documentation without changing the rendering logic.
In `@frontend/src/features/shared/useDebouncedValue.test.tsx`:
- Around line 39-46: Update the “clears a pending timer on unmount” test to
assert the fake-timer queue directly: verify vi.getTimerCount() is greater than
zero after renderHook creates the debounced timer, then verify it is zero after
unmount(). Remove the clearTimeout spy assertion.
In `@frontend/tests/e2e/helpers.ts`:
- Around line 444-446: Update createTestPurchaseOrder so supplierName uses a
collision-free identifier instead of Math.random(), preferably
crypto.randomUUID() or a timestamp combined with the Playwright worker index,
while preserving the existing “[TEST] Supplier” naming format.
In `@frontend/tests/e2e/purchasing/stock-search.spec.ts`:
- Around line 117-134: Update the response listener in the stock-search test to
count search responses based on whether they occur after the input is cleared,
rather than excluding URLs containing q=5mm. Start tracking post-clear responses
only immediately before input.fill(''), so the initial search is ignored while
any repeated q=5mm request after clearing is counted and fails the existing
assertion.
🪄 Autofix

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 Plus

Run ID: 66ca2b61-202f-4ca9-a415-b773f6995a0a

📥 Commits

Reviewing files that changed from the base of the PR and between 3030b60 and a3451f2.

📒 Files selected for processing (60)
  • docs/adr/0039-one-implementation-per-concept.md
  • docs/code-quality.md
  • docs/cutover-checklist.md
  • docs/rewrite-status.md
  • frontend/src/api/index.ts
  • frontend/src/api/query-client.ts
  • frontend/src/features/crm/CompaniesListPage.tsx
  • frontend/src/features/crm/CompanyDetailPage.tsx
  • frontend/src/features/job/JobCreatePage.tsx
  • frontend/src/features/job/JobFinishTab.tsx
  • frontend/src/features/job/JobSettingsTab.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/purchasing/JobSelect.test.ts
  • frontend/src/features/purchasing/JobSelect.tsx
  • frontend/src/features/purchasing/PoCreatePage.tsx
  • frontend/src/features/purchasing/PoDetailPage.tsx
  • frontend/src/features/purchasing/PoLinesTable.tsx
  • frontend/src/features/purchasing/PoListPage.tsx
  • frontend/src/features/purchasing/PoSummaryCard.tsx
  • frontend/src/features/purchasing/StockPage.tsx
  • frontend/src/features/purchasing/index.ts
  • frontend/src/features/purchasing/lines.test.ts
  • frontend/src/features/purchasing/lines.ts
  • frontend/src/features/purchasing/usePoLines.ts
  • frontend/src/features/reports/JobMovementReportPage.tsx
  • frontend/src/features/reports/WipReportPage.tsx
  • frontend/src/features/shared/DataTable.tsx
  • frontend/src/features/shared/ItemSelect.tsx
  • frontend/src/features/shared/ListTable.test.tsx
  • frontend/src/features/shared/ListTable.tsx
  • frontend/src/features/shared/QueryState.test.tsx
  • frontend/src/features/shared/QueryState.tsx
  • frontend/src/features/shared/SaveFailedBadge.tsx
  • frontend/src/features/shared/company/CompanyLookup.test.tsx
  • frontend/src/features/shared/company/CompanyLookup.tsx
  • frontend/src/features/shared/company/CreateCompanyModal.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.test.tsx
  • frontend/src/features/shared/company/PersonSelectionModal.tsx
  • frontend/src/features/shared/company/PersonSelector.tsx
  • frontend/src/features/shared/company/create-company.ts
  • frontend/src/features/shared/company/index.ts
  • frontend/src/features/shared/company/xero-contact.ts
  • frontend/src/features/shared/decimal.test.ts
  • frontend/src/features/shared/decimal.ts
  • frontend/src/features/shared/useDebouncedValue.test.tsx
  • frontend/src/features/shared/useDebouncedValue.ts
  • frontend/src/features/timesheet/DailyOverviewPage.tsx
  • frontend/src/features/timesheet/SmartTimesheetTable.tsx
  • frontend/src/features/timesheet/TimesheetEntryPage.tsx
  • frontend/src/routeTree.gen.ts
  • frontend/src/routes/_authed/purchasing/po/$poId.tsx
  • frontend/src/routes/_authed/purchasing/po/create.tsx
  • frontend/src/routes/_authed/purchasing/po/index.tsx
  • frontend/src/routes/_authed/purchasing/stock.tsx
  • frontend/tests/e2e/helpers.ts
  • frontend/tests/e2e/purchasing/create-purchase-order.spec.ts
  • frontend/tests/e2e/purchasing/po-created-by.spec.ts
  • frontend/tests/e2e/purchasing/stock-search.spec.ts
💤 Files with no reviewable changes (2)
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/calc.test.ts
🛑 Comments failed to post (3)
frontend/src/features/shared/company/CompanyLookup.tsx (2)

88-99: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Ignore completion from abandoned company-creation interactions.

Both creation paths apply a completed mutation after the initiating UI state can change. A quick-create response can select an old query after the user continues searching. A modal response can select a company after the user closes the dialog.

  • frontend/src/features/shared/company/CompanyLookup.tsx#L88-L99: associate quick creation with an active request token, and select the returned company only if the request still matches the current interaction.
  • frontend/src/features/shared/company/CreateCompanyModal.tsx#L49-L64: prevent dismissal while creating or ignore a completion after the modal closes. Add regression tests for both paths.
📍 Affects 2 files
  • frontend/src/features/shared/company/CompanyLookup.tsx#L88-L99 (this comment)
  • frontend/src/features/shared/company/CreateCompanyModal.tsx#L49-L64
🤖 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 `@frontend/src/features/shared/company/CompanyLookup.tsx` around lines 88 - 99,
Associate quickCreateCompany with an active request token and call
handleCompanyCreated only when the response still matches the current lookup
interaction; otherwise ignore the completion. In
frontend/src/features/shared/company/CompanyLookup.tsx lines 88-99, update
quickCreateCompany accordingly. In
frontend/src/features/shared/company/CreateCompanyModal.tsx lines 49-64, either
prevent dismissal while creation is pending or ignore completion after the modal
closes. Add regression tests covering abandoned quick-create and modal-creation
interactions.

238-242: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Render a search failure state.

If companiesSearchRetrieveOptions rejects, search.isPending is false and this branch renders No companies found.. The user receives a false empty-result message. Render an error state when search.isError is true, and reserve the empty state for successful empty responses.

🤖 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 `@frontend/src/features/shared/company/CompanyLookup.tsx` around lines 238 -
242, Update the suggestions empty-state rendering near
companiesSearchRetrieveOptions to check search.isError first and display an
error state when the request fails. Gate the existing “No companies found”
message on a successful, non-error search so rejected requests never appear as
empty results.
frontend/src/features/shared/company/PersonSelectionModal.tsx (1)

242-281: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The delete overlay does not block the form column.

The confirmation panel is absolute inset-0 inside the people-list column only. The create/edit form and the submit button stay interactive while the confirmation is open. submitDisabled at Line 226 also ignores removeLink.isPending, so a user can start a create while a delete is in flight. Consider disabling the footer submit while deleteTarget !== null or removeLink.isPending.

🤖 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 `@frontend/src/features/shared/company/PersonSelectionModal.tsx` around lines
242 - 281, Update the form footer submit state in the component’s submitDisabled
logic to also disable submission whenever deleteTarget is non-null or
removeLink.isPending is true. Keep the existing confirmation overlay behavior
and ensure create/edit actions cannot start while deletion is pending or
awaiting confirmation.

corrinand others added 4 commits August 11, 2026 12:47
- CompaniesListPage/JobFinishTab: QueryState was passed the raw
isError, breaking the first-load-only rule the other converted
sites already follow -- a background refetch failure was
unmounting already-rendered content instead of leaving it on
screen.
- JobSelect: closing the picker without a selection (blur or
Escape) left `editing` true forever, so the input kept showing
the abandoned search term instead of reverting to the bound job;
the blur timer was untracked and could fire a state update after
unmount. Also added keyboard selection (arrow keys + Enter,
role=listbox/option, aria-activedescendant) -- the dropdown was
mouse-only.
- PoListPage: the row was reachable only by mouse click; added a
real Link on the PO number cell, matching CompaniesListPage's
existing pattern.
- decimal.ts: parseDecimalInput accepted anything Number() parses,
including hex/octal/binary literals and bare exponents, and sent
them to the wire verbatim as garbage Decimal strings. Validates
fixed-point syntax explicitly now.
- Stale features/company/* paths in rewrite-status.md's build-order
table, left behind by the features/shared/company move.
- Comments narrating port history or test intent rewritten to state
the rejected alternative and the constraint (ADR 0043) in
ItemSelect, SaveFailedBadge, CreateCompanyModal, CompanyLookup;
two flagged sites (the E2E-repair-loop comment, the sonner test
comment) were already compliant and left as-is.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
PR #54 (main) widened pyproject.toml's django specifier to
>=6.0,<6.2 but never regenerated the matching uv.lock, which still
recorded <6.1 in its requires-dist metadata. Every `uv run` this
session silently self-corrected the lock locally; committing that
fix rather than leaving a lockfile permanently out of sync with the
manifest it's supposed to pin.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
frontend/test-results/ was covered; run_e2e.sh's Playwright
invocation also writes artifacts relative to the repo root in some
invocations, leaving an untracked test-results/ dangling after every
E2E run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
…config
playwright.config.ts resolved .env.test, testDir, outputDir and the
html reporter's folder relative to process.cwd(). Any invocation
whose cwd wasn't frontend/ (npm --prefix from the repo root, a bare
npx playwright test) silently missed .env.test -- dropping
E2E_TEST_USERNAME/PASSWORD with no error -- or wrote artifacts to
the wrong location instead of erroring loudly, which is how a
root-level test-results/ kept reappearing. Anchored every one of
these to import.meta.dirname instead: verified from an unrelated
cwd, .env/.env.test still resolve correctly and no stray directory
gets created anywhere. The root-level gitignore entry from the
previous commit stays as a defensive backstop, but this is the real
fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WENcwU8rC6o7bxuHL6md2L
@corrin
corrin merged commit f7a8080 into mainAug 11, 2026
3 checks passed
@corrin
corrin deleted the purchasing-po branch August 11, 2026 02:01
Sign up for freeto 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.

1 participant

@corrin