> ## Documentation Index
> Fetch the complete documentation index at: https://help.stralos.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Rate document import frontend

> Frontend contract for selection-first spreadsheet and whole-document import flows

The rate import flow belongs to `stralos-feature-rates`. `RateImportPage` is registered at `/rates/import`. Admins and members can import (`canEditRates`). Anyone else sees "Importing isn't open to you here" instead of the upload controls.

Some records an import can create stay with admins: a new category, or one of the company's own locations (`isCatalogAdmin`). A supplier's location can be added by anyone who can import. When api-rates reports a failed record with `code: "admin_required"`, the page names that record in its own sentence so the person can ask an admin to add it.

## Project prices import

`RateImportPage` doubles as the project prices importer. Adding `?projectId=<uuid>` to `/rates/import` activates the project path. The page validates the parameter against a UUID pattern; a value that does not match shows "Project not recognised" instead of the upload controls.

The project path reuses the full shared import UI — selection-first spreadsheet, whole-document PDF, catalogue-records panel, and review table — with these differences:

| Behaviour | Library path | Project path |
| - | - | - |
| Page title | "Import rates" | "Import project rates" |
| Subtitle | — | "Imported prices will be saved to this project." |
| Back navigation | `/rates` | `/projects/:projectId/estimate/builder/rates` |
| Rate item search | Full library (`listRateSummaries`) | Library + project scope (`searchProjectImportRateItems`) |
| Category on matched row | Disabled (locked while the row is bound to an existing item) | Disabled (locked to the item's stored category) |
| Category spread | Skips rows already bound to an existing rate item | Skips rows already bound to an existing project rate item |
| Commit endpoint | `POST /api/rates/rate-imports` | `POST /api/rates/project-rate-imports` |
| Upload type | `price_list` | `project_quote` |
| Job fetch | `GET /api/rates/rate-imports/jobs/:jobId` | Same endpoint, `projectId` (and `estimateId`, and `targetRateOptionId` when set) forwarded as query params |
| On success, standard | "N rates imported / Prices were added…" | "N rates imported / Prices were saved to this project." plus "N earlier project price(s) was/were replaced." when `replacedRateCount > 0` |
| On success, reprice pending | n/a | "Project rates were saved. Estimate costs will finish updating when you return to the project.", with the same replaced sentence after the first one |
| On success — navigation | `/rates` | `/projects/:projectId/estimate/builder/rates` with `projectRateImport` location state when a reprice was triggered |
| Source file | Linked through `importFileId` only | Also saved as a project document in the **Rate imports** folder and sent as `sourceDocumentId`. If that save fails, the import still completes and a warning toast says "File not saved to the project". |

The category picker is disabled on any row where `existingRateItemId` is set, on both paths. This prevents the import from recategorising an item through a write that the reviewer did not explicitly request. Switching the row to **Creates a new rate** clears `existingRateItemId` and re-enables the picker.

When a row is in update mode, the disabled picker shows the item's stored category under a **Current category** group header, so the reviewer can see which category the existing item belongs to. For legacy items where no category metadata was stored, the picker remains blank.

On a successful commit that triggers a re-price, the page navigates to the project rates page and carries `{ projectRateImport: { projectId, reprice } }` in the router location state. The project rates page reads this state to surface the re-price result inline.

### Replacing placeholder project rates

A quote imported over a placeholder project rate (a current project price typed in by hand, `provenance: manual`) replaces it outright, with the quote's supplier and location. api-rates does the replacing on commit; the page explains it and carries `replacesRateOptionId` back. See [Placeholder replacement](https://help.stralos.app/api/rates.md#placeholder-replacement) for the contract.

* **Match.** The project poll can suggest a project rate (`matchSource: "project-rate"`). `matchSourceNote` reads "Matched to a rate in this project" and `needsCheck` always shows **Check this one** for it. Matched rows carry `placeholderRates` (with `supplierName` and `locationName`).
* **Update from import.** feature-estimate's project rate row menu opens the project import route with `?targetRateOptionId=<uuid>` (only when an estimate is in scope). `queryUuid` reads it on the project path only; the poll forwards it, and the one row api-rates aims at that rate returns `replacesRateOptionId`. The subtitle adds "Upload the quote for the project rate you're updating, and the matching row will replace it." When no row is aimed at it, an info alert (`data-testid="import-target-not-found"`) says "We couldn't find the project rate you're updating in this file, so it will stay as it is."
* **Binding.** `src/rateImportReplace.ts` treats the server's placeholder facts as true only while the row is still bound to its suggestion (`existingRateItemId === suggestedRateItemId`). Binding another item or choosing **Creates a new rate** drops both the note and `replacesRateOptionId`, which the commit would otherwise refuse as `replace_target_mismatch`.
* **Which prices.** `placeholdersReplaced` mirrors api-rates' rule: placeholders with the row's condition (blank reads `standard`) and no supplier or the row's supplier, plus the one `replacesRateOptionId` names. Another company's placeholder is never shown as replaced.
* **Note.** `replaceNote` renders under the row's match line in `status.warning.fg` (`data-part="replaces-project-rate"`) and is derived on every render, so a change of company, condition or item re-decides it.

| Case | Note |
| - | - |
| Only the targeted rate | "Replaces the project rate you're updating." |
| The targeted rate and typed-in prices | "Replaces N project prices, including the one you're updating." |
| Several typed-in prices | "Replaces N placeholder prices (entered by hand)." |
| One of your own | "Replaces your placeholder price \$45.00 per t at Rolleston (entered by hand)." |
| One with a named supplier | "Replaces {supplier}'s placeholder price … (entered by hand)." |
| One whose supplier could not be named | "Replaces a placeholder price … (entered by hand)." |

The price part is omitted when `costRate` is null and the place when `locationName` is null.

## Import paths

Selection-first import is the only path for spreadsheets. The `?path=survey` opt-out is retired: an old `?path=survey` link lands on selection-first too. PDFs always use the whole-document flow, routed by the file. A reopened `?survey=` record also selects survey, while a reopened `?job=` derives its path from whether the job carries an `analysisId`.

PDFs bypass both the parse endpoint and `RegionCanvas`. `importPathForFile` chooses the whole-document path from the file name before any request is sent. The page calls `surveyRateImport`, records the returned `surveyId`, and continues through the survey flow. This replaces the older behaviour that parsed a PDF first and routed only reconstructed scans to survey, which consumed the one-shot upload before the fallback could reuse it.

## File preparation

`spreadsheetImport.ts` performs only the cheap checks that belong in the browser. It accepts `.csv`, `.xls`, `.xlsx`, and `.pdf`, rejects an empty file, and limits the upload to 10 MB. File parsing and structural limits are enforced server-side. The page requests a presigned URL and uploads the original bytes directly before starting either path.

The upload screen names the accepted formats as **Excel, CSV or PDF** and discloses that AI reads the file before the person hands it over.

`ImportWizardOverview` renders on the upload screen, above the drop zone, before any file is chosen. It shows a short account of the whole process and a numbered card per step. Because the path is determined by the file's extension and no file has been chosen yet, the overview cannot show a single path's steps. `importWizardOverviewSlots` merges the two step lists by identity: **Upload** and **Check and import** appear once because both paths share them, while **Pick the rates** carries an **Excel or CSV** badge because PDFs skip it. The overview therefore has three cards, while the step row on the PDF path numbers its two actual screens from one to two. `STEP_LABELS` and `STEP_DESCRIPTIONS` supply the copy for both paths. No step is marked done — nothing has happened yet at the upload screen.

Reading the file is part of step one, not a step of its own. The `ingesting`, `surveying`, and `parsing` stages all render `ImportProgress` inside the drop zone on the upload screen — the `ImportWizardOverview` marker remains visible throughout. The file picker is hidden while reading is in progress; a second file dropped into the zone while the first is still being read is silently ignored. Once reading is complete, `extracting` replaces the upload screen with its own layout, where per-block progress needs the full space. `creating` is used only when a reviewer asks to re-read a whole document after reclassifying a heading.

## Selection-first spreadsheet flow

The three visible steps are **Upload**, **Pick the rates**, and **Check and import**.

The earlier four-step sequence — Parse, Select blocks, Confirm entities, Review & import — is replaced. Entity and catalogue decisions are now settled on the review screen, above the rows they affect. The stages are `selecting → analysing → extracting → reviewing`; there is no `confirming` stage on the region path. Analysis and extraction are not numbered steps because neither asks the reviewer to make a decision before continuing.

On upload, `createRateImportParse` starts the parse and reports its `parseId` immediately through `onParseStarted`, before polling completes. A spreadsheet with a usable grid advances to `RegionCanvas`, where the reviewer selects a rectangular region and clicks **Read these rates**. `analyseRateImportRegion` returns the analysis and then immediately starts the extraction job without a confirmation gate.

`?parse=<id>` reopens the selection-first flow through `resumeRateImportParse`. The `parsing`, `selecting`, and `analysing` stages retain that parse ID; extraction and review use `?job=<id>`. A legacy parse record whose file name is a PDF cannot reopen a selection grid. The page explains that a PDF is read whole and asks the reviewer to upload it again.

## Whole-document survey flow

The survey flow handles every PDF and reopened `?survey=` sessions. It is still called the legacy path in parts of the frontend because it predates selection-first import.

### Request and polling

`surveyRateImport(fileName, uploadId, onSurveyStarted?)` starts the survey by calling `POST /api/rates/rate-imports/survey` with the file name and upload ID. The server queues the survey and returns `{ surveyId }`. The optional `onSurveyStarted` callback fires immediately with that `surveyId` — before any polling begins — so the page can update the address bar without waiting for the full analysis to complete. The function then polls `GET /api/rates/rate-imports/surveys/{surveyId}` every 4 seconds until the survey status is `complete` or a `blocks` field is present in the response.

The caller's interface is otherwise unchanged: one `await`, one `RateImportSurvey`. During the `surveying` stage, `ImportProgress` renders inside the drop zone on the upload screen — the same component used by later stages — so the step row, detail line, and elapsed clock are consistent throughout the flow. The page does not navigate away during file reading; the upload card remains the visible container until extraction begins.

### Survey status and timeouts

The poll response carries a `status` field. The function handles each status as follows:

| Status | Behaviour |
| - | - |
| `queued` or `running` | Waits the poll interval and polls again. |
| `complete` | Returns the survey payload. |
| `failed` | Throws with the server's `error` message, or a fallback message if no reason is given. |

If polling exceeds 5 minutes without a terminal status, the function throws: "The document is taking longer than expected to analyse. It may still finish — try again shortly." This message is intentional — the survey may still complete server-side, and reporting it as failed would be incorrect.

If the initial `POST` is rejected (for example, the upload was not accepted), no polling occurs and the rejection propagates immediately.

### Error surfacing

Errors on the import review screen and its catalogue-records panel are surfaced using `apiMessageWithServerDetail` rather than `safeApiMessage`. The import surfaces are the deliberate exception to the catalogue-wide rule of discarding server bodies: every failure here describes the caller's own submission — a duplicate unit code in their spreadsheet, a unit their category forbids, a category name the catalogue holds twice — so the server's sentence is more actionable than a generic one.

The rules are:

* When the server returns a message, it is shown verbatim after stripping transport noise (status codes, URLs). For example, a 422 with body `Code "Central" appears on 2 rows that would all price the SAME item at the same source and condition` shows that sentence rather than "Some of the ticked rows can no longer be imported. Check them and try again."
* When the response carries no message body, the curated fallback sentence is used.
* A 401 always shows the curated sign-in sentence regardless of the server body, because that body describes the session rather than the submission.
* 500 and 503 use the same sentences as every other surface in the feature — `DEFAULT_STATUS_MESSAGES` in `apiErrorMessage.ts` — rather than custom overrides. A 500 reads "Something went wrong on our side. Try again in a moment." and a 503 reads "Stralos is busy right now. Try again in a moment." `IMPORT_STATUS_MESSAGES` does not override either status.

All other surfaces in the feature continue to use `safeApiMessage`, which discards recognised HTTP bodies so that identifiers belonging to another organisation cannot appear in the UI.

## Address bar tracking

The address bar follows the import as it progresses through stages so the reviewer can navigate away and return to the same flow. The URL is updated using `replace` (not push) to avoid leaving one history entry per stage transition.

The parse or survey ID enters the address bar the instant its upload-start callback returns — not after polling completes. These IDs are the only route back while analysis is still running.

Selection-first `parsing`, `selecting`, and `analysing` stages carry `?parse=`; survey `surveying` and re-read `creating` stages carry `?survey=`; extraction and review carry `?job=`. The reopened job's `analysisId` sets `selectionPath` in both directions so a survey job cannot inherit the new default.

The `ingesting` stage has no server-side anchor and shows no URL parameter.

## Confirm, extract, and review

Both import paths settle entity and catalogue decisions in `ImportEntitiesPanel` on the review screen, above the rate rows they affect. There is no separate confirmation screen. The rows provide the evidence needed to decide what each document heading means. A proposal with no candidates, unclassified headings, or excluded blocks omits the panel.

On the survey path, **Show the document** expands `DocumentCells` above the question rows. The survey relays sheets via `rateSurveyResponseSchema.sheets` (the upload is deleted once read; the review receives them directly). `RateImportPage` holds the sheets in `pendingSheets` from the moment the survey lands and passes them to `ImportEntitiesPanel`. `DocumentCells` is a reading surface only — no click handler, no `data-grid-cell`, no selected state — and shares geometry, cell access, and number alignment with `RegionCanvas` via `rateImportRegion`. The grid is height-capped at `DOCUMENT_GRID_MAX_HEIGHT` (`min(260px, 30vh)`) so the questions and rates remain visible.

Each candidate row that can be located in the cells gets a **Show in the document** link. Selecting it expands the document if needed and scrolls to the evidence. `anchorFor` searches the cells for the candidate's name first and falls back to the band of rates the survey associated with the candidate only when the name search finds nothing. That fallback is labelled as the weaker claim it is. The `evidence` field in the survey schema is prose and is not parsed. The selection-first path renders the same panel without a document because its reviewer has already worked directly on the grid.

### Import panel heading and blocking rows

The headline in the catalogue-records panel is driven by `catalogueGap` and, when no missing records remain, by the count of blocking rows. A blocking row is a selected rate row that cannot be committed because a required answer is absent — most commonly a missing supplier and location assignment. The heading is computed from:

* Missing or unidentified catalogue records: `"{N} suppliers need adding before you can import"` or similar.
* No catalogue gap but blocking rows with source issues only: `"{N} rows need a supplier and location before you can import"`.
* No catalogue gap but blocking rows with mixed issues: `"{N} rows to fix before you can import"`.
* Nothing to do: `"Every supplier, location and category in this file is already in your rates"`.

The panel can no longer reassure over rows that still have an unresolved source answer. Previously the heading could say `"already in your rates"` for rows that were in fact blocked; the heading now reads from the same `blockingRows` set the **Import** button uses to decide whether to enable itself, so the reassurance is true or absent.

Each blocked row's picker is marked `aria-invalid` with an inline message describing the specific reason it is blocked. Blocking issue messages render in the error colour (`status.error.fg`, red) — the same colour applied to the picker border via `aria-invalid`. Amber is reserved for advisory messages the reviewer may choose to ignore; a message that refuses the import is always red.

### Catalogue-records panel UI: one picker per row

Every row in the panel uses the same gesture regardless of kind: one
`SelectCombobox` whose list holds existing records to map to and create actions at
the bottom.

Create actions appear at the bottom of the same list. A location's "Create new \<company> location…" entry is grouped under its company, because adding a location is an action on that company.

**Re-typing a row.** Changing a row's kind clears its previous target and immediately looks for a record of the new kind that matches the heading's name. For a location re-typed as such, the auto-match searches only organisation-owned locations (`partyId: null`) — picking a supplier-owned location would silently attribute the document's rates to that company, so supplier locations are left for the reviewer to choose explicitly from the picker.

**Completing supplier chains.** Creating a supplier row immediately offers the location creator so the reviewer can create a document-specific location without leaving the panel. A location is not mandatory: the location picker offers **All locations** for every owner. Selecting it sets `locationId` to `null`, which is a valid and equal pricing target.

**Prefilling editors from the document.** Editors opened from `ImportEntitiesPanel` opt into `selectNameOnOpen`. When the row names the record being created, `documentName()` collapses whitespace, pre-fills that wording, focuses the name field and selects the full value. A source row pre-fills the company; a location row pre-fills the location. Creating a company from a location row leaves the company name empty. The follow-on location editor opened from a supplier row pre-selects the just-created supplier as the owner and renders all remaining location fields, because the parent is now known even though the document did not name the location. `SupplierEditorModal` compares changes with the value it opened with, so a pre-filled editor does not immediately show **Discard changes?**. The catalogue and review screens do not opt into selected-name behaviour for existing records.

**Catalogue owners.** The location picker loads the organisation and every active supplier company, together with their locations, not only the owners the survey proposed. An existing organisation-owned quarry or yard can be selected without needing to be named in the document.

### "All locations" on every owner

`uniquelyMatchImportedSource` treats a location-scope value of `"all"`, `"all locations"`, or `"source wide"` as an owner-wide selection. **All locations** is offered for the organisation and every supplier throughout the review screen. A prior restriction that narrowed an explicit "All locations" label to the sole location for a single-location owner (rule R-A) is retired; owner-wide pricing is a real target.

`RateLocationPicker` — the component behind the catalogue-records panel's location picker — leads every owner group with an all-locations row, matching the row-level picker. The value encodes `scope` and `supplierId`; the label names the organisation or supplier so the closed field still identifies whose rates these are when the group heading is off screen.

An **All locations** answer settled in the panel is filed as evidence in `referencesWithConfirmed` via `sourceWideNames` rather than as a location entry. Filing it as a location with a null id would produce an inconsistency on matching rows; `selectCreatedPair` normalises the answer to `locationId: null` so the matcher treats it correctly.

### URL persistence and leave warnings

The address bar follows the import through its stages. Region-path parsing, selecting, and analysing retain `?parse=<id>`; survey reading and a requested re-read's catalogue-resolution stage retain `?survey=<id>`; extraction and review use `?job=<id>`. Reopening `?survey=` restarts the whole-document extraction and returns to review. Reopening `?parse=` restores the parsed grid at the region canvas, so the region and analysis must be repeated. The URL updates use `replace` rather than `push` so that **Back** exits the import rather than stepping backwards through its own stages.

The active job id is tracked separately from the polled job object: the job object is `null` for the first tick and throughout the first poll on a reopen, so deriving the address bar from it would delete the `?job=` parameter that had just been read.

Navigating away while work would be lost shows a confirmation prompt. The stages affected, and the message shown, differ:

| Stage | Prompt |
| - | - |
| `reviewing` | Row corrections are lost. |
| `ingesting` / `creating` | Nothing useful to come back to. |
| `extracting` | No prompt. The job runs server-side and `?job=` reopens it; warning here would train the user to dismiss a dialog protecting nothing. |

The `beforeunload` event is guarded for stages in `STAGES_WITH_UNSAVED_WORK`. Shell navigation links (react-router pushes) are not intercepted — that path requires `useBlocker` with a data router, and the shell mounts `BrowserRouter`.

On the survey path, the initial survey result starts the per-block extraction job immediately with no entity call or vocabulary. If the reviewer reclassifies a heading and chooses to read the rates again, the page calls `createRateImportEntities` to resolve those decisions, builds the vocabulary, and starts a replacement extraction while retaining the previous review on failure. On the region path, the extraction job is started directly by `analyseRegion` with no entity creation. In both cases, the page polls the job every 2 seconds. `ImportProgress` renders in place of the bare spinner: `IMPORT_STEPS` and `SELECTION_IMPORT_STEPS` name the work in user language, while `ANALYSING_STEP_LABEL` supplies **Working out your columns** to both the progress row and `AnalysisMask`. It shows which stage is active, displays an elapsed clock, and renders a progress bar. The bar is determinate during extraction only — the server reports per-block state there; earlier stages return no measurable progress and the bar runs indeterminate to avoid implying a number the server cannot give. A running block counts as half a step, so the bar moves when a block starts as well as when it finishes; the bar reaches 100% only once every block has settled. A terminal job becomes an editable `RateImportReview`; every rate is initially selected.

### Review table scroll containment

The review table's scroll container (`data-part="review-table-scroller"`) uses `w="0" minW="100%"` so that horizontal overflow scrolls inside the card rather than widening the page. A zero width contributes nothing upward through flex layout; `minW="100%"` fills the card's row. Without this, the table's `min-content` width propagated through every flex ancestor, pushing all cards off-screen to the right with no scrollbar on the page and the columns past **Additional information** clipped with no way to reach them.

`ScrollCard` intercepts `focusin` events in the capture phase on its outer frame and stops propagation when the event target is the scroller element itself. The scroller is `tabIndex={0}` so a keyboard can reach it, but a mouse-down on any unfocusable element inside it (a row's tick box, the select-all box, a cell's text) also lands focus on the scroller. `StickyTableScrollArea` listens for `focusin` to reveal the focused element from under the pinned header; when the focused element is the whole table, it was scrolling the page back to the first row on every tick. Stopping the scroller's own focus event prevents that jump. Focus events originating from an input inside the table still reach the listener.

### Review page heading, notices and actions

The header holds only the title and the step strip: **Review imported rates**, with "N rates from {fileName}" beneath it. Above the table, in order:

* An info alert when an **Update from import** target matched no row (see [Replacing placeholder project rates](#replacing-placeholder-project-rates)).
* A warning alert counted from the ticked rows that still need a company and location: "N rates need a company and location", with "Pick one below and press Apply location to all rates, or choose one on each row." (or "Choose one on each row." when the all-rates card is not shown).
* The all-rates card (`data-testid="all-rates-card"`). When the document named one supplier or one place, `documentScopeReadouts` shows **Supplier in document** beside **Stralos supplier**, and **Document location → Stralos location**, as evidence for the pick. The **Company and location for all rates** picker and the **Apply location to all rates** button fill the ticked rows without one, asking first (**Change all N** / **Only the empty ones**) before changing rows that already have one.

Below the table, a row holds "N of M rates selected" and the list of rows to fix on one side and the actions on the other. The amber count repeats there as "N rates need a location". The three buttons share one `HStack` (`data-testid="import-review-footer-actions"`) that wraps at phone width, import first: **Import N selected rates**, **Choose another file**, **Cancel**. None of them is in the header or in `SectionCard`'s `secondaryActions`, which once stacked them one per line.

### Review table columns

The column order follows how a quote line is checked: **Rate item** (what Stralos will call it), **Supplier rate name** (what the supplier called it), **Category**, **Code, cost and unit** (its price), **Company and location** (where it is from), then **Condition** when any row has one, and **Additional information**. Headers that need it carry a second line through `ImportColumnHeader`: "Your name in Stralos", "Name from the supplier document", and "From the document · you can change them".

The table is wrapped in `ScrollCard`. At a 1600 px window the columns fit without clipping. At narrower widths the card scrolls horizontally — the page does not grow. `ScrollCard` draws an always-visible scrollbar and, when the content is wider than the card, shows a hint sentence beneath: "This table is wider than the window — scroll sideways to see every column." A fade on the side with more content marks the hidden edge.

The table body is a `VirtualTableBody` from `@stralos-co-nz/ui` 0.65.0. Only the rows currently on screen plus a margin are kept in the DOM. The `revealRow` prop accepts `{ index: number; onReady: () => void }` and is used to scroll a specific row into view when the issue list jump-to-cell link targets a row that is not currently rendered. Virtualisation is transparent to reviewers — the scrollbar spans the full extracted rate list and every row is reachable by dragging it.

| Column | Notes |
| - | - |
| Rate item | `Combobox` with async search via `listRateSummaries`. The picker does not fetch rate summaries on mount. The catalogue is searched on the first keystroke, so the dropdown opens empty until the reviewer types. Type to create a new item; select to bind to an existing one. While the row is not bound to an existing item, `SEARCH_EXISTING_RATE_HINT` ("Or type to search for an existing rate") shows under the box. When the row arrived pre-matched by the server, the picker is pre-filled with `existingRateName` (the catalogue item's name) rather than the extracted document text, and it is disabled, so the reviewer cannot retype it while the row is bound to that item. Switching the row to **Creates a new rate** re-enables the picker. A suggestion exists only when the organisation already holds that name; the row is blocked locally on the name cell when the reviewer has a suggestion selected but the name is already taken. The escape is to retype the name, which clears the suggestion binding and allows a new item to be created. The cell also holds the **Updates *rate name*** / **Creates a new rate** choice, the **New rate** or **Check this one** badge, the source sheet and row, the match line (`suggestionNote`) and the placeholder replacement note (`replaceNote`). |
| Supplier rate name | The editable **Supplier item name** input, seeded from `documentName`, the document's own wording for the row. It is editable while the row creates a new item, and disabled while the row is bound to an existing item (`existingRateItemId`). Binding a row to an existing item overwrites the catalogue name shown in **Rate item** but leaves `documentName` (and therefore this input) unchanged. At commit, the trimmed value is sent as `sourceItemName` on the rate option. On a row assigned to the organisation's own price (`carriesSupplierItemFields(rate)` is false), the cell stays empty and `commitRateImport` sends no `sourceItemName`. |
| Category | `SelectCombobox` grouped by parent group. Pre-populated from the document's match via `resolveImportCategory`. On update rows (`existingRateItemId` is set), the picker is disabled and shows the item's stored category under a **Current category** group; when no category metadata was stored for a legacy item, the disabled picker is blank. Switching the row to **Creates a new rate** re-enables the picker. When the document named a category the catalogue does not hold, the table shows `{fileSaid} "…"` below the picker as evidence (see **Company and location** for `fileSaid`). `Create new category…` opens one review-level `CategoryEditorModal` as a side sheet for the requesting row. On save, the new category is added to the list, applied to that row, and shared with eligible new-item rows that use the same document heading. |
| Code, cost and unit | One cell laid out as a grid (`gridTemplateColumns="100px 110px 105px"`, `data-part="code-cost-unit"`), each part with its own issue anchor so a blocker link still lands on its box. **Code**: supplier rows only, disabled on a row bound to an existing item; own-price rows send no code. \*\*Cost ($)**: right-aligned currency input with a formatted echo below it showing the price as money with its unit (e.g. `$57.50/t`), matching the library display. **Unit**: `UnitCombobox`, a strict catalogue picker. Extracted units outside the catalogue still display (fallback to raw value) so the reviewer can see what came in. A bare "roll" token is ambiguous: `unitsSharingSymbol\` identifies it as matching both Roll Length and Roll Area. When the row's category allows exactly one of these, the token resolves automatically. When the category allows both, the row is blocked with an ambiguity message and the reviewer must choose the correct unit. Keeping the three together keeps the price beside its unit; cost was once the last column and was clipped off screen at 1600 px. The column minimum widths are a shared budget that sums to fit a 1600 px viewport alongside the tick column. |
| Company and location | `RateLocationPicker`. The picker lets the reviewer assign the extracted row to an owner and location. **All locations** is a real choice for every owner and commits `locationId: null`. A blank location in the file is not read as All locations: the row stays unresolved and is blocked with "choose a location for this price" until the reviewer picks a place or **All locations**. (When the file named a location that did not resolve, the message is "choose the company and location".) The API enforces the same rule with `422 location_required`. The document's own supplier name and location text are shown beneath the picker as read-only evidence so the reviewer can see what the source file said. When the document supplied at least one of a company name or location scope, the evidence line reads `{fileSaid}:` followed by the supplier and the location joined by a dash (omitting absent halves), where `fileSaid` is "Document said" for a PDF and "Spreadsheet said" otherwise. When the document named neither, the line reads `No company or location named in the file`, a plain fact rather than an implied fault. The evidence line does not change when the reviewer picks a different catalogue record. |
| Condition | Shown only when at least one row arrived with a non-standard condition. A row that supplied one receives an editable input; clearing it commits `standard`. Values accept letters, numbers, spaces, hyphens, and underscores and are limited to 40 characters. |
| Additional information | Always present on every row. Carries banded pricing schedules and density as whitespace-separated `key=value` pairs. The cell is populated by extraction and is read-mostly; the reviewer can edit it but normally should not need to. For new quarry-materials items, when the document did not supply a density the AI extraction may suggest one; the cell is pre-filled with that estimate and shows an “AI estimate — check before importing” tooltip with the basis. When `anyRowIsQuarry` or `anyRowIsCartage`, the cell also renders labelled fields beneath the `key=value` input for the applicable physics values: **Bulking %** and **Compaction %** for quarry rows, **Max tonnes** and **Max m³** for cartage rows. These fields were previously separate trailing table columns; they now live inside the **Additional information** cell to keep the table within a 1600 px card. See [Additional information cell](#additional-information-cell) for the syntax and validation rules. |

The frontend normalises a condition to lowercase and sends `standard` when the field is blank. The **Condition** column is derived from `documentCondition`, not the edited value, so clearing a field does not remove its own input while the reviewer is typing. Invalid conditions are anchored to that input.

Row-level validation prevents a selected invalid rate from reaching commit. An unmatched row shows the **New rate** badge. A matched suggestion offers **Updates *rate name*** and either **Creates a new rate** or, when the name is already taken, **Creates a new rate (give it its own name)**. Imports remain cost-only; project pricing owns overhead and markup.

### Additional information cell

The **Additional information** column replaced the `Density (t/m³)` column. It is present on every review row rather than conditionally shown, and it carries both banded pricing schedules and the density (conversion factor) as whitespace-separated `key=value` pairs:

```
band_1=0,20,57.52,"<20m²" band_2=20,50,38.35,"20-50m²" band_3=50,,32.36
density=1.7
```

The recognised keys are `band_<n>` and `density`. Every unrecognised key is refused — it is not silently dropped — because a mistyped `desnity=15` that imported as "no density" is the worst available outcome.

**The grammar is a validator, not a transport.** The review edits a row's attributes through structured controls in `RateAttributesEditor.tsx`: `rateAttributesDraft` turns the row's structured `banding` and numeric `conversionFactor` into a draft, and `parseRateAttributesDraft` checks it with the same bounds, continuity, and precision rules. Neither the model nor the commit ever receives this syntax directly.

**Banded rates (`band_<n>`)** carry four comma-separated fields: `from,to,price,"label"`. The label is optional; when absent it is derived from the bounds. `to` is redundant as data (a band runs until the next one starts) but is required as a transposition guard: the commonest error is writing fields in the wrong order, and a price where a bound belongs almost never equals the next band's start. Constraints:

* Minimum 2 bands, maximum 40 bands.
* The first band must start at 0.
* `from` quantities and non-empty `to` quantities at most 3 decimal places; prices must be non-negative and have at most 2 decimal places.
* Bands must be in ascending order with no gaps — each band's `to` must equal the next band's `from`.
* Only the last band may have an empty `to`.
* Labels at most 40 characters; labels cannot contain a double quote.

**Structured band editor (`RateAttributesEditor`).** When a row carries extracted banding, the cell exposes an **Edit N quantity bands** toggle that opens structured **From**, **To**, **Unit price**, and **Supplier heading** fields for each band. The last band's **To** field is read-only: typing into it has no effect, and it shows a **No upper limit** placeholder rather than a value. This mirrors the grammar rule that only the last band may have an empty `to`, enforced in the editor rather than only at commit.

Editing the density field on a banded row updates only the density. The bands, their supplier headings (`label` values), and the `tierBehaviour` (whole quantity or progressive) are preserved exactly as extracted. Editing a band's unit price updates only that price and leaves the density and other bands intact.

**Density (`density`)** carries a single number: `density=1.7`. Constraints: greater than 0, at most 1,000,000, at most 4 decimal places.

**Read-back display.** Above the text input, `describeRateAttributes` renders the row's data in plain language — the supplier's own band headings beside their prices, and the density value — so the reviewer can scan it without parsing the raw syntax. When a row carries banding, a note states whether whole-quantity or per-band charging applies.

**Charging rule.** Extraction always sets `tierBehaviour: "whole_quantity"` (the confirmed default for civil rates): the entire quantity is charged at the band it falls in. If the current review row already carries `progressive`, editing and reparsing its additional-information text preserves that row-level choice. The review does not load the stored pricing rule from a matched option; committing a banded row replaces that option's base tiered rule with the behaviour carried by the row.

**AI density hint.** When the AI extraction suggested a density for a quarry row and the reviewer has not changed it, a compact hint appears beneath the **Additional information** field via `AiMaterialHint`. The same `AiMaterialHint` component renders in the **Bulking %** and **Compaction %** cells under the same conditions.

`AiMaterialHint` uses `AiCellLabel` from `@stralos-co-nz/ui`. When the field still holds the AI-supplied value, `AiCellLabel` is rendered with `state="ai"`. Once the reviewer has changed the value, the component switches to `state="overridden"` and exposes a **Revert to AI value** action via `onRevert`. This replaces the previous hand-rolled `"AI suggests X"` text-plus-popover, which disappeared on edit and provided no way to recover the original suggestion.

`AiMaterialHint` compares the current field value with the AI suggestion via `String(current) !== value`. When they differ, `overridden` is true and `onRevert` is passed to `AiCellLabel`; when they match, `onRevert` is `undefined` and the label renders in the plain `ai` state. The hint renders only on new items (rows where `existingRateItemId` is absent).

**Missing density prompt.** When a quarry row has no density and no AI suggestion, a muted line reads "Enter the density in t/m³, for example 1.7" beneath the Additional information field. This is a contextual prompt, not a validation error; the row issue list carries the blocking error separately.

**Validation and issue anchoring.** Parse errors, an out-of-range density, and a missing density on a quarry row are reported as row issues anchored to `attributes`. A banded price that disagrees with the representative cost is anchored to `cost`. Each issue links to the relevant cell and puts focus in it. A density not needed by the row's category shows a "Not needed for this category" note beside the field rather than a blocking error.

**Column heading hint (`RateAttributesHint`).** A `?` button sits in the **Additional information** column heading. Clicking it opens a `Popover` (via `Portal`) with a structured explanation of what the cell contains and how to correct it if needed. The hint is on the heading once — not repeated per row — to avoid spending vertical space that the review canvas budget guards carefully. The trigger is sized to the heading's own line height (`h="5"`, \~20 px) so the header row does not grow.

The hint exports two constants consumed by its own test suite:

* `ACCEPTED_ATTRIBUTE_EXAMPLES` — `density`, `bands`, and `quotedLabel` strings that the field accepts. The banded example's prices, bounds, and headings are read out of `parseRateAttributes` at render time so the prose and the parser cannot drift apart.
* `WHOLE_QUANTITY_EXAMPLE_QUANTITY` — the quantity used in the whole-quantity charging sentence, exported so the test can confirm it falls in the correct band.

The test puts every accepted example through `parseRateAttributes` and asserts it passes. It also holds its own refused example, `"density=1.60934"` — a value that passes wire validation but is refused at commit because it exceeds four decimal places — and asserts it fails. The hint names that rule explicitly because no schema error surfaces it before the 422. A hint that drifts from the grammar goes red in CI rather than quietly misleading reviewers.

**Empty-cell placeholder.** A non-quarry row with an empty **Additional information** cell shows "No additional information provided". The empty cell is a complete state: the row needs no density and carries no banding. Quarry rows without a density show "Enter the density in t/m³, for example 1.7" as a prompt beneath the field (see **Missing density prompt** above).

### Catalogue records panel on the review screen

`ImportEntitiesPanel` is mounted on the review screen, above the rate rows. It shows the catalogue gap — what the document refers to that does not yet exist in the catalogue — using `catalogueGap`, which counts only rows that are missing or unreadable. When every record already exists, it shows "Every supplier, location and category in this file is already in your rates".

The panel's category candidates are computed by `reviewEntityProposal` in `rateImportCategories.ts`. It derives the required categories from the extracted rate rows directly, not solely from the survey's initial scan. This means a document heading that was missed by the survey — for example, a price-list category found only in the rate data when the survey identified only a supplier — still appears as a candidate in the panel. The function merges the survey's existing candidates with any additional categories the rows require, preserving prior decisions (state, matches, evidence) for headings the survey did name.

**Reference read errors.** The panel makes three independent reads for its own
pickers: `sources` (suppliers and locations), `categories`, and `units`. A failed
read is never treated as an empty catalogue, which could otherwise prompt a
reviewer to create a duplicate record.

Each read now reports its own failure in a red banner above the headline, using the server's own message. A failed `sources` read says "Your suppliers and locations could not be loaded"; a failed `categories` read says "Your categories could not be loaded"; a failed `units` read says "The unit catalogue could not be loaded". Only failed reads appear in the banner — a read that succeeded is not mentioned.

The banner includes a **Retry loading your rates** button. It re-runs only the failed reads, so a successfully loaded catalogue cannot be overwritten by the retry. Reads are generation-guarded per read: a slow rejection that arrives after a successful retry does not paint over the good result.

**Headline suppression on matching failures.** `MATCHING_READS` are `sources` and `categories` — the reads whose results appear in the row pickers. When either has failed, `matchingUnread` is `true` and the headline changes from the gap count (for example "2 suppliers need adding") to "These rows cannot be matched against what you already have", with a subtitle: "Your rates could not be read, so nothing below can be shown as already existing. Retry before creating anything — a record added twice has to be merged by hand."

A failed `units` read is reported in its banner but does NOT suppress the headline. Units feed the category editor checklist, not any picker's list of existing records, so the supplier, location, and category gap count remains accurate.

Records already in the catalogue are shown collapsed behind **Show the N already in your rates** to keep the focus on the work; the control switches to **Hide the N already in your rates** when expanded. A record settled on this panel stays visible in the open list — a click that deletes its own row would read as a click that did nothing.

**Bulk add suppliers.** When the document refers to suppliers that are not in the catalogue, an **Add the N missing suppliers** button creates all of them in one gesture, sequentially. The ones that succeed are settled; the one that fails is named. Categories and locations cannot be bulk-created because a category needs a parent group and its allowed units, and a location needs its owner — none of which the document contains.

**Confirming a record repairs its rows.** A settled record is added to `uniquelyMatchImportedSource` as evidence (`referencesWithConfirmed`), under the document's exact wording. A source-wide location answer is filed under `sourceWideNames` rather than `sourceLocations`, so the matcher can resolve it without a location id. Rows that already carry a source are not touched. `entitiesSettled` calls `rebindUnansweredRows` with the confirmed records immediately after updating `categoryMappings`, so location records confirmed in the entity panel are applied to unanswered rows in the same pass as category records.

Import is blocked by the rows, not by a second gate in the panel. An unsettled record shows up as the row issue it causes, beside the cell that fixes it.

### Location and supplier creation during import

`RateImportLocationDialog` and `RateImportSupplierDialog` (in `src/RateImportLocationActions.tsx`) are the two write surfaces available on the import review screen when the catalogue does not hold what a row is priced against.

`RateImportLocationDialog` adds a place. The owner field defaults to the company the row that opened it is already priced against, but the reviewer must confirm it — a place created under the wrong company silently attributes every price bound to it to the wrong business. Owner choices are the organisation's own entry first, then active suppliers alphabetically. Location kind choices are yard, depot, quarry, branch, office, and other. The dialog renders the shared `LocationCreationSheet`, so the same address and coordinate requirement applies as on the Rates page. See [Address and coordinate requirement](https://help.stralos.app/internal/rates-editor-frontend.md#address-and-coordinate-requirement). A `location_address_required` refusal from the server shows the server's own sentence.

`RateImportSupplierDialog` adds a supplier company. It calls `createSupplierCompany` and returns the created party so the caller can select it immediately.

Both dialogs replace the old `SourceLocationCreator` three-act component. The old sentinel tokens (`NEW_INTERNAL`, `NEW_INTERNAL_ALL`, `NEW_INTERNAL_LINK`, `LINK_LOCATION`, `ALL_LOCATIONS`) no longer exist: the `rate_sources` and `rate_source_locations` tables are removed, and `locations.partyId` is the sole ownership record.

**All locations** is offered for every owner in the picker and is a real, equal pricing target. `locationId === null` is sent as a valid commit value, not treated as an absent answer.

After creation, source references are refreshed in place. The auto-match logic runs over the new references but never replaces a reviewer's existing selection.

`ImportEntitiesPanel` uses the same `RateLocationPicker` as the rate editor for its row-level pickers, feeding it with `locationPickerData` built from the live catalogue. The organisation's own entry appears first; suppliers follow alphabetically.

## Issue list and jump-to-cell

Each row-level validation problem is surfaced as its own button in the issue list, rather than as a single combined sentence. Clicking the button scrolls to the offending cell and puts focus in it.

Row anchors follow the pattern `rate-import-{rateId}-{field}`. The anchor is keyed on the row ID, not its index or name — rows are re-sorted and renamed on the review screen, and a non-unique name would produce ambiguous anchors. `field` is one of: `row`, `name`, `code`, `category`, `unit`, `cost`, `source`, `condition`, `attributes`.

For `cost`, `code`, `condition`, and `attributes`, the anchor ID is set directly on the input element. For picker fields the anchor is on a wrapper; the focus logic queries the first focusable element inside the anchor rather than the anchor itself. The `row` anchor is the fallback when an issue's field-specific anchor is absent.

The issue list is not truncated. All issues are shown and the list scrolls, so a reviewer knows exactly how many problems remain without a silent "and N more" count.

## Category resolution during review

The frontend loads the rate-category catalogue to exhaustion via `listAllRateCategories` and refreshes it after creating survey candidates. A failed catalogue load produces an error banner with a **Retry** control; it is not treated as an empty catalogue.

A category decision from the catalogue-records panel is seeded into `categoryMappings`, keyed by the document's trimmed category name. The review uses that mapping before name resolution and sends the selected rows' applicable mappings in the commit body, so the category displayed during review is the one the server resolves at commit.

When a re-read is triggered after reclassifying a heading, confirmed category mappings are passed as `initialMappings` into the new extraction's polling cycle. `reviewEntityProposal` seeds the incoming proposal with those mappings so decisions already made on the review screen survive the re-read. Mappings are passed alongside `keepPreviousReview: true` so that a failed re-read restores the previous review intact rather than leaving a blank screen.

There is no separate category-mapping panel. Each review row owns a `SelectCombobox` in its **Category** cell. It shows a resolved category when one exists, offers the complete catalogue grouped by parent group, and includes **Create new category…**. An unresolved selected row is reported through the ordinary row issue list and blocks import; a deselected unresolved row does not. The unit picker stays locked until the row has a resolvable category.

A row already bound to an existing rate item (`existingRateItemId` is a UUID)
can keep that item's stored category without another answer. If the reviewer chooses
a different category, the commit treats that as an explicit recategorisation. It
updates the item only when its category-specific physical fields and every live
option that would remain after the import are compatible with the destination.
Slots replaced by the import are assessed using their incoming values, and
multiplier dependants of a replaced Standard option move with it. An incompatible
move refuses the whole commit without partially moving other items.

The frontend propagates a category answer to other rows that share the same
`documentCategory` heading and are eligible to receive it. Eligibility is
determined by `sharedAnswer` in `rateImportSharedAnswers.ts`: rows already bound to an existing rate item (`existingRateItemId` is set) are not eligible, regardless of their document heading. Among the remaining rows, unmatched rows are eligible; rows answered manually are never overwritten.
Every row that will create an item must have a resolvable category: `null` is the
reviewer's explicit instruction to create a new item, while `undefined` is an
unmatched row shown with the **New rate** badge.

The server-published `unitCode` records the normalised catalogue unit for the extracted token. Row validation uses it when checking whether the chosen category permits the unit, so an alias such as `hr` is judged as `hour` rather than as an unrelated raw value. A category-unit mismatch is reported under **Unit**, while an absent category match is reported under **Category**.

Mappings are cleared when another file is chosen. They are scoped to the current review and are never persisted as catalogue aliases.

The import review table's **Category** column shows the resolved `parentGroup` label beneath the picker (`formatRateGroupLabel(resolveImportCategory(...)?.parentGroup)` for new items; `formatRateGroupLabel(rate.existingRateGroup)` for existing-item rows). This replaces the previous display of `rate.rateGroup`, an optional legacy passthrough for a valid group value explicitly present in the spreadsheet. The server ignores that value at commit.

`categoryStatusOf` distinguishes four outcomes:

* `blame: "category"` — no catalogue match for the document's category name. Reported under the **Category** cell.
* `blame: "unit"` (ambiguous roll) — the category matched and allows both roll units (Roll Length and Roll Area), but the row carries a bare "roll" token that does not settle to one. The message reads `"roll" could be Roll Length or Roll Area. Pick one.` The reviewer must select the correct unit. A category that allows exactly one roll unit resolves the ambiguity automatically.
* `blame: "unit"` (row unit) — the category matched, but it does not permit the row's own unit. Reported under the **Unit** cell.
* `blame: "unit"` (existing option units) — the category matched and the row's own unit is fine, but the matched item already has a price using a unit the new category does not permit. `categoryStatusOf` checks `existingOptionUnits` — the units the server reports for the matched item — and surfaces a warning under the **Unit** cell naming the offending unit: `"<Category> does not use <unit> — this rate is already priced in it"`. The reviewer's next step is to find the price carrying that unit and resolve it before committing.

These are the helper's outcomes. The rendered table calls `statusOf`, which returns `unitProblem: null` for rows with `existingRateItemId` instead of invoking `categoryStatusOf`. The new existing-option check is therefore bypassed for matched review rows, including after a category change; helper tests do not establish a visible warning on that path.

This is a warning only. The server remains the authority: a recategorisation that physics-licensing rules refuse (bulking, capacities, a factor a group forbids) still surfaces at commit even when the pick screen shows no warning. When `existingOptionUnits` is absent — a new item, or an older server — the check treats the item as having nothing else to licence, which is the behaviour before this check was added.

#### Category column: one message rule

When a row is blocked on a missing or unmatched category, the red blocking error under the **Category** picker is the only category-related message shown on that row. The amber `NO_CATEGORY_MATCH` label that previously appeared beneath the picker as a secondary note is suppressed when the row is already blocked on the category field. The amber label is still shown when the row is blocked on a different field (for example, a missing supplier), because in that case it is the only mention of the category issue.

## Selection-first path

The selection-first path is the only path for spreadsheets, whatever the `path` query value. PDFs always use the whole-document survey path.

### When to use it

The path gives the reviewer explicit control over which spreadsheet rows are imported. The reviewer draws one rectangle on the sheet; the model reads the headings and interprets the structure itself. PDFs use the whole-document survey flow instead.

### Stages

The path runs through the following stages, tracked in the step indicator at the top of the page.

| Step | Stage | Description |
| - | - | - |
| 1 — Upload | `parsing` | The file is uploaded and parsed. No model call is made. |
| 2 — Pick the rates | `selecting` | The reviewer clicks two corners to draw one rectangle on the sheet. |
| — | `analysing` | The rectangle is posted for a narrow model call. This is not a separate step — it runs beneath `AnalysisMask` while the canvas remains mounted. |
| — | `extracting` | The extraction job starts immediately after analysis. |
| 3 — Check and import | `reviewing` | Extracted rates are reviewed and committed. Catalogue records are settled here, above the rows they affect. |

There is no separate "Confirm entities" step on either path. Both paths render
`ImportEntitiesPanel` on the review screen above the extracted rates. Catalogue
records are written by the editors as each row is settled there.

### Parse stage

`createRateImportParse` uploads the spreadsheet and starts a parse, then polls `GET /api/rates/rate-imports/parses/:parseId` until the parse is complete. The parse record holds the spreadsheet sheets; no model call analyses blocks at this stage. The `parseId` enters the address bar immediately after the parse `POST` returns, so the reviewer can navigate away and return (`?parse=<id>` resumes the canvas at the selecting stage).

PDFs do not create parse records on the current path.

### Canvas and analyse stages

`RegionCanvas` renders during `selecting` and stays mounted during `analysing`. Keeping it mounted means a refusal from the analyse call returns the reviewer to a screen that still holds the drawn region, rather than an empty canvas.

`RegionCanvas` renders the whole sheet as a single scrolling grid. A sheet dropdown lists every sheet in the parse; switching sheet clears a half-drawn region. The reviewer clicks the top-left cell, then the bottom-right cell. Corner order does not matter — `regionFrom` normalises the two corners into a valid region. The grid is virtualised: only the cells on screen plus a margin exist in the DOM at any instant, so the scroll container and scrollbar describe the full sheet regardless of sheet size.

**Select the whole sheet.** A **Select the whole sheet** button sits on the sheet strip beside **Start again**. Pressing it draws a rectangle from A1 to the bottom-right cell of the used range in one step, replacing any existing half-drawn or finished box. The button is always visible but disabled on an empty sheet; it is also disabled while analysis is running. **Start again** appears only after a corner has been placed or a box has been drawn, while **Select the whole sheet** is present from the first paint, because the common case — a file where every row is a rate — is most efficiently handled before a click has been made.

The instruction bar underneath the step cards holds a fixed height across all three canvas states (no box, one corner placed, box drawn). The footer line either quotes the **Select the whole sheet** shortcut (when no box exists and the sheet is non-empty) or names what to do with the finished box. Keeping the line count constant prevents the grid from shifting when the first corner is clicked.

The body sent to the analysis endpoint is `{ parseId, region }` only. `region` and `blocks` are mutually exclusive on the server: a request with both is a 400.

While `stage === "analysing"`, `AnalysisMask` is rendered as a positioned sibling over the grid. The semi-transparent scrim leaves the selected rectangle visible and uses `ANALYSING_STEP_LABEL` for the same **Working out your columns** text shown by `ImportProgress`. The busy guard refuses new grid corners while the mask is present.

**Go back and change the box** calls `cancelAnalysis`. It aborts the client poll, increments `analysisRunRef`, clears the error and returns to `selecting`; it cannot stop the server-side analysis because no cancellation route or cancelled status exists. Every awaited result checks the run generation, so a late success or refusal from an abandoned rectangle cannot update the current screen. The mask says that the reading finishes whether or not the person waits.

When the reviewer clicks **Read these rates**, `analyseRegion` runs:

1. Posts `{ parseId, region }` to `POST /api/rates/rate-imports/analyses`.
2. Polls `GET /api/rates/rate-imports/analyses/:analysisId` every 2 seconds until the analysis is `complete` or `failed`.
3. On success, immediately starts the extraction job (`startSelectionRateImportJob`) without calling `createRateImportEntities` and advances to `extracting`.
4. On refusal (422), stays on the canvas and shows the server's sentence directly. An oversized rectangle carries `region_too_large`; other bounds failures may have no code. The confirmed-block advice codes do not apply to `RegionCanvas`.

Poll timeouts (5 minutes) surface the message: "The selection is taking longer than expected to analyse. It may still finish."

### Extraction sequencing

On the region path there is no pre-extraction entity gate. `analyseRegion` starts the extraction job directly after the analysis completes, without calling `createRateImportEntities`. The extraction job on this path already completes synchronously on the server (`regionJobBlocks` builds one block born `done` carrying the rates the analysis extracted), so no model runs and no vocabulary is needed.

The extraction job is started with `startSelectionRateImportJob`, posting `analysisId` and `parseId` only. `analysisId` comes from the completed analysis; `parseId` is the import's own parse id, not the one echoed back by the analysis — the job route refuses when the two disagree. `EntityRun` is survey-only and does not represent this region path.

The survey path also has no pre-extraction gate. It starts `startRateImportJob` directly from the survey with no vocabulary so the rates exist before the catalogue decisions are shown. Mapping, creating, or ignoring a record updates the current rows in place. Only reclassifying what a heading means offers **Read the rates again**; that action resolves the panel decisions through `createRateImportEntities`, builds vocabulary, and starts another survey job. The two paths share one `RateImportPage` component and one review screen.

### URL persistence

The stages `parsing`, `selecting`, and `analysing` preserve `?parse=<id>` in the address bar (tracked in `PARSE_URL_STAGES`). Extraction and review use `?job=<id>`. There are no `confirming` or `creating` stages on the region path.

### Commit

On commit, the region path sends `analysisId` alongside the reviewed rates so a reopened job retains its analysis identity. The backend deliberately does not save a reuse template for a region analysis because confirmed-block geometry cannot be converted safely into one rectangle. Survey commits send no `analysisId`.

### Limits

The canvas enforces the same limits the server validates:

| Limit | Value |
| - | - |
| Maximum imported rates | 2,000 |

`MAX_IMPORT_RATES` mirrors `MAX_IMPORT_RATES` in `stralos-api-rates`. The mirror must be kept equal to the server's cap — a lower client limit would block imports the server would accept, which is the failure mode the limit exists to prevent.

## Selection reuse

The reuse offer is disabled on `RegionCanvas`. A saved selection is a list of `ConfirmedBlock`s, and a block carries row bounds and column roles — it has no column bounds at all. There is no honest way to turn one into a rectangle: inferring the column range from the roles would be a guess, and a multi-block template has no single rectangle. The offer is gone from the canvas; `SuggestedSelection` remains on the wire type because the server still sends it.

## Commit and recovery

The frontend sends only selected rows and the applicable `categoryMappings` to `POST /api/rates/rate-imports`. Mappings are filtered to document category names present in the selected rows; the server applies a mapping before its name-resolution arm.

The `condition` field is normalised to lowercase before commit, with a blank value becoming `standard`.

### Kept file

`commitRateImport` takes a `RateImportKeptFile` (`{ sourceDocumentId?, importFileId? }`). With neither set, the call keeps its original arguments.

* **`importFileId`**, on both routes, is the kept copy of the file named by the completed parse or survey poll. `null` means nothing was kept and an absent key means an older api-rates; either way nothing is sent. If api-rates answers `422 import_file_not_found`, `commitRateImport` retries once without it, because the link must never cost the person their reviewed rates.
* **`sourceDocumentId`**, project imports only, is the id returned by `saveRateImportFileToProject`. The page starts that save alongside the rate-extract upload, and a failure resolves to `null` so the import continues. The commit body never sends `sourceDocumentId` without a project, because api-rates refuses it on the Library route.

`saveRateImportFileToProject` in `src/api.ts` is a deliberate second upload of the same bytes: the rate-extract upload is a working copy that is deleted once read, and this one is the record. It follows feature-estimate's document upload protocol: `POST /api/documents/` with `projectId`, `fileName`, `fileType`, `sizeBytes` and `relatedFolder: "Rate imports"` (`RATE_IMPORTS_FOLDER`). With no `estimateId` the document is `on_demand`, so no AI processing starts. The file then goes to the presigned POST, policy fields first and the file last.

### Analysis identity on commit

On the region path, the commit payload includes the `analysisId` read from `analysis?.analysisId ?? job?.analysisId`:

* **In-session:** `analysis` holds the completed `RateImportAnalysis` from `analyseRegion`. Its `analysisId` is sent.
* **Reopened via `?job=`:** the reviewer navigated away during extraction or review and returned. The `analysis` component state was never set, so it is `null`. The job row carries `analysisId` — written by `selectionJobWork` only after validating the analysis it came from — and that value is used instead.

The fallback keeps a reopened import tied to the analysis that produced its rates. It does not create a reuse template: `rememberSelection` skips region analyses. The legacy and survey paths send no `analysisId` at all (`?? undefined`, not `?? null`, because `z.uuid().optional()` rejects `null`).

On success the frontend reports the imported and created counts, then navigates to `/rates`.

The review screen's three action buttons sit at the bottom of the review; see [Review page heading, notices and actions](#review-page-heading-notices-and-actions).

## API client types

`api.ts` defines the import contracts:

* `ImportedRateReview`: the detected rate, its source location, confidence, optional existing-item match, and the temporary selection ID. Includes `unitCode`: the server-resolved catalogue code for the row's unit token, used to filter category choices in the mapping combobox. For matched rows, also includes `existingOptionUnits`: the unit tokens of every live option already on the matched rate item, available to the category-status helper for checking existing prices; the rendered matched-row path currently bypasses that helper. Rows that create a new item omit the field. `rateGroup` is an optional legacy passthrough for an explicit valid spreadsheet group column; commit ignores it and derives classification from the catalogue. `documentName` carries the document's own description for the row: what the document called the item before any catalogue binding. It is rendered as the **Supplier item name** field on the review row, editable except while the row is bound to an existing item. Binding a row to an existing catalogue item overwrites `name` with the catalogue item's name but leaves `documentName` unchanged, so the reviewer can see the document's wording while deciding whether the binding is correct. At commit, `documentName` is carried through as `sourceItemName` on the created or updated rate option.
* `RateImportReview` — `fileName`, `rates`, `warnings`, and the optional `unresolvedCategories` array (absent on pre-M5 services, read as empty).
* `RateImportSurvey` and `RateImportJob` — the asynchronous structure survey and per-block extraction states.
* `RateImportParse`, `ProposedBlock`, `ConfirmedBlock`, `DismissedCell` — the selection-first path types described in [Selection-first path](#selection-first-path).
* `SuggestedSelection` — present on the wire type (the server still sends it) but the reuse offer is not shown on `RegionCanvas`. A saved selection is a list of `ConfirmedBlock`s with no column bounds, so there is no honest way to derive a rectangle from one.
* `ImportCategoryMapping` — `{ from: string; toCategoryId: string }`. Request-scoped; never persisted.
* `RateImportResult` — `importedCount`, `createdItemCount`, and `matchedItemCount`.
* `RateImportKeptFile`: `{ sourceDocumentId?: string; importFileId?: string }`. See [Kept file](#kept-file).

See [Rates API](https://help.stralos.app/api/rates.md#rate-imports) for the wire contract and [Rate import service](https://help.stralos.app/internal/rates-import-service.md) for backend behaviour.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.