Skip to main content
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: 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 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.
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: 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: 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 ” beneath it. Above the table, in order:
  • An info alert when an Update from import target matched no row (see 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. 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:
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. 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. 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: 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 ConfirmedBlocks, 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.

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.
  • 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 ConfirmedBlocks 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.
See Rates API for the wire contract and Rate import service for backend behaviour.