> ## 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 editor frontend

> Category filter, category picker, unit filtering, rate options editor, and payload contracts for the rate catalogue

The rate editor in `stralos-feature-rates` uses one category picker for item classification and a separate rate options layer for source, location, and pricing data.

## Edit authority

Two advisory checks in `src/api.ts` read the role from the shell's stored user. api-rates and api-global enforce the same rules.

| Check | True for | Gates |
| - | - | - |
| `canEditRates()` | `admin` or `member` | Adding and editing items and prices, pricing rules, retiring a price, the condition re-key, restoring history, and importing price lists |
| `isCatalogAdmin()` | `admin` | Deleting an item and bringing one back, categories, the company's own locations, and day-rate schedules |

On this page, "anyone who can edit rates" and "editors" mean `canEditRates()`; "read-only users" are everyone else.

## Item grid (RateItemGrid)

`RateItemGrid` in `src/RateRows.tsx` renders the grouped item view — one row per rate item, expandable to show that item's priced options. It replaced a custom CSS grid (`GridHeader` + mapped `RateGridRow`) as part of the design-library adoption backlog (row 22c).

### DataTable

`RateItemGrid` is a `DataTable` at `density="comfortable"`. This gives the grid sentence-case column headers, hairline rules between rows, row hover, right-aligned tabular money in the cost column, 44 px row height, and a horizontal scroll area. The two layout constants that aligned the old grid — a `gridTemplateColumns` string and a hand-summed 1000 px minimum — are removed.

### Scroll model

`RateItemGrid` uses full result-set virtual scrolling. The page passes a `virtualData` object to the grid with the full `totalCount`, a `getItem(index)` function, and an `onRangeChange(first, last)` callback. `DataTable` sets `aria-rowcount` to `totalCount` so the complete catalogue size is announced to assistive technology from the first load. Only the rows currently in the viewport — and a small buffer — are in the DOM at any time.

When the user scrolls into a range not yet held in memory, `onRangeChange` fires and `RatesPage` calls `listRateSummaries` with the appropriate `start` and `length`. The page loads at least `pageSize` rows and rounds up to page boundaries, so adjacent off-screen rows are fetched speculatively. A jump to a distant position skips any intervening pages and fetches only the target window.

`RateItemGrid` carries no `maxHeight`. The containing `SectionCard` owns vertical scrolling, keeping its header and catalogue toolbar visible while rows move beneath them. There is no "Load more" button on the item grid. The scroll sentinel (`data-scroll-sentinel`) used by the previous pagination model is absent; triggering is driven by `onRangeChange` instead.

The item grid uses `virtualData` for the full catalogue. This server-backed window replaces `DataTable`'s automatic windowing of supplied rows. UI 0.65.2 also windows loaded expandable tables by default when `virtualData` is not in use.

`DataTable`'s header is sticky by default. With ui 0.65.2, it also stays visible when the table overflows horizontally, without requiring `maxHeight`.

#### Column header pinning

The current grid uses the shared `DataTable` sticky header inside the Rates page's contained `SectionCard` scroll region. The earlier `useNativeStickyColumns` hook and its listener/timeline fallback are no longer part of `stralos-feature-rates`.

### Grid font size

The whole option grid uses one type scale defined by `GRID_VALUE_FONT_SIZE = "md"`. Values — names, codes, units, and money — all render at `md`. Annotations stacked under a value (loadings, multiplier labels, minimum charges) and column headers render at `xs`. Every row container carries the size itself, so a `<Text>` added to a row cell inherits `md` rather than the document body default.

Before this was standardised, editable cells rendered at `xs` (from the control recipe) while adjacent plain text cells inherited the body default, which produced two different sizes in the same column on rows where the two arms alternated.

### Compact arm

Below the `md` breakpoint, `RateItemGrid` renders `RateCompactRow` cards instead of the `DataTable`. A `DataTable` at that width places the **Cost** column off screen behind a horizontal scroll, which is the defect S7 fixed. The compact arm is not optional — removing it reintroduces that defect.

`RateCompactRow` always renders the cost with its unit suffix (e.g. `/h`), because the Unit column is not visible on the card. The desktop row omits the suffix precisely because it has a Unit column.

### Cost column

The cost column is declared `numeric: true`, which right-aligns its cells and renders money in tabular figures. The column header is `Cost (NZD)` — the currency code appears once in the header rather than on every cell. `SUMMARY_CURRENCY` is the constant `"NZD"` used to build that header; it reflects the suite default currency for rate summaries.

`SummaryCost` controls what each cost cell renders:

* When `costRate` is absent, the cell shows **No price yet — add one below** (anyone who can edit rates) or **No price yet** (read-only users). The compact card shortens this to **No price yet**.
* When the summary has no `costRate` but its options include stepped options, the cell shows the steps instead: one stepped option reads `3 steps · $250.00–$900.00` (`formatSteppedSummary`), and several read `Priced by step · $250.00–$900.00`. A step is a lump sum, so it never contributes to the per-unit range.
* When `costRate` is present and `min === max`, the cell shows the single price with its unit suffix (`$57.50/t`).
* When `costRate` is present and `min !== max`, the cell renders the word **from** as a separate muted text node on the same row, followed by the minimum price (`from $42.00/t`). The two nodes sit in an `HStack`; "from" is not concatenated into the price string itself. This keeps the price token a standalone formatted value rather than a hybrid string.
* The unit suffix (`/h`, `/m`, etc.) is omitted when the cost's normalisation unit matches the item's `modeUnit` — the two columns (`Unit` and `Cost ($)`) then carry the same axis and the suffix would be a repetition. The suffix is kept when the two fields disagree, because omitting it would label the cost on an axis it was not computed on.
* When both `min` and `max` are zero, the cell also shows a **Zero price** badge (`StatusBadge domain="attention" value="unpriced"`). A zero summary can exist when grouped pricing includes a tiered rule, loading, or minimum charge, so the row remains visible rather than being treated as missing.

### Expandable rows

The chevron and detail row use `DataTable`'s `expandable` prop. `getRowLabel` is set to `rate.item.name` so the chevron's accessible label reads **Expand Labourer** rather than the item's UUID.

`RateRowDetail` is extracted from the row so `DataTable` can hand it to `renderDetail` and `RateCompactRow` can render it directly. It carries `role="group"` with the label **Labourer prices**, which also prevents the table row from computing its accessible name from every cell inside the expansion.

### Company column on the item grid

The item grid's column is **Company** (key `companyLocation`, 240px): owners only, no places. Each expansion's option grid keeps its combined company-and-location column (id `"source"`), so where each price applies is read there.

`companyCell(companies)` in `src/RateRows.tsx` builds the cell from distinct owner names: one to three are joined on one line, more than `MAX_NAMED_COMPANIES` (3) read `"N companies"`, and the `title` (the hover) always lists all of them. `summaryCompanyCell` reads the names from the summary through `summaryCompanyNames` in `src/rateScope.ts` (the organisation's own price reads as its name). When a priced item has no name to give, it falls back to `summaryCompanyLabel`, which reads "Unavailable owner". An unpriced item renders a dash. The key and the API sort stay `companyLocation`, because api-rates orders by owner first, which is what the column shows.

#### Filters inside expanded rows

The toolbar's search, company and unit filters also apply to the options inside an expanded resource. `RatesPage` passes `RateRowDetail` an `optionFilter` built from `RateOptionFilters` (a `Pick` of `RatePriceFilters` covering `search`, `companyIds` and `units`) through `optionMatchesFilters`. Inside the expansion, company matches the option's owner and unit matches the option's own priced unit, not the item's mode unit. Search keeps every option when the item name matches; otherwise an option stays when its supplier code, supplier item name, company or location matches.

When at least one option is hidden, the expansion shows **Showing N of M rates — X hidden by filters** with a **Show all** button. **Show all** suspends the filter for that resource only, and the line then reads **Showing all M rates — filters not applied here** with an **Apply filters** button. The toggle resets whenever the filters change.

Under a filter, once a resource's options are loaded, its **Company** cell is built by `optionsCompanyCell` from the owners of the matching options only, so the row and its expansion agree.

## Surface layout and tab labels

The **Rates** page exposes three tabs. Tab labels are sentence case.

| Tab id | Label | `surface` | Description |
| - | - | - | - |
| `rates-tab-resources` | Resources | `all` | Item catalogue — one row per rate item, expandable to its priced options |
| `rates-tab-rates` | Rates | `all` | Flat price list — one row per priced option (`RatePriceGrid`) |
| `rates-tab-day-rates` | Day rates | `day-rates` | Day-rate schedules (`DayRatesPanel`) |

**Resources** and **Rates** share the `all` surface. Both tabs render the same toolbar filters (search, category, company, unit) and the same URL state. The active tab id is derived at render time: `activeSurface === "day-rates" ? "day-rates" : groupByItem ? "resources" : "rates"`. Selecting **Resources** sets `groupByItem: true`; selecting **Rates** sets `groupByItem: false`. The **Day rates** tab switches `activeSurface` to `"day-rates"` and writes `?view=day-rates` to the address bar directly, bypassing the shared filter serialiser.

`?view=prices` still opens the flat **Rates** tab, preserving every link produced under the previous two-tab design.

The page subtitle follows the active surface. On the `all` surface it reads "Your company's costs. Margin is added on each project." On the `day-rates` surface it reads "The day-rate schedules you issue to clients."

Both catalogue tabs render **Add rate** for anyone who can edit rates. **Resources** also renders **Add resource**. These actions sit beside **History** and **Import rates** on the shared toolbar. Both open the same item-editor and rate-option-creator flows as before.

### Sticky page header and contained list scroll

`RatesPage` wraps all content in a `SectionCard` with `padded={false}` and `stickyHeader`. The page-level card owns a bounded scroll region by default. Its header — title, tab-aware subtitle, action buttons, tab strip, and shared catalogue toolbar — stays at the top of the card while catalogue rows scroll beneath it. `scrollLabel="Rates"` supplies the accessible name for that scroll region.

The tab strip and toolbar are part of the `SectionCard` header, not part of the `Tabs.Content` panel. Both views share one fixed header regardless of scroll position. An error banner for failed reference reads is placed inside `Tabs.Content`, below the sticky area, so it does not push the tab strip out of position.

Cards that should not adopt the contained-scroll model pass `scroll="page"` to their `SectionCard`. This applies to:

* Day-rate revision lines and rules sections (`DayRateLines`, `DayRateRules`)
* The rebase review sections (`DayRateRebaseReviewPage`)
* The rate item detail page's second card (`RateItemDetailPage`)
* The rate and day-rate import pages (`RateImportPage`, `DayRateImportPage`)

Popover, side-sheet, and expanded-row cards are excluded from contained scroll by the ui library itself and do not need `scroll="page"`.

## Flat price view (RatePriceGrid)

Every price in the catalogue is shown on the **Rates** tab. `RatePriceGrid` (`src/RatePriceGrid.tsx`) is the presentational component that renders this flat view — one row per live price, using the library's `DataTable` at `density="compact"`.

`RatePriceGrid` owns no fetch and no filter state. `RatesPage` holds both and loads the full catalogue the first time the flat view is requested. It marks that snapshot stale on any write rather than re-reading on each grid edit.

`?view=prices` still opens the flat **Rates** tab, preserving every link the old tab produced.

### Table rendering

`RatePriceGrid` renders the flat list with the library's `DataTable` (`density="compact"`). The three layout constants that previously held the header and rows in alignment — a `gridTemplateColumns` string, a hand-summed `minW`, and a font size — are removed; the table's own scroll area prevents the last column clipping.

The **Cost** column is `numeric: true`, so its header and every cell are right-aligned with tabular figures. No other column carries this mark.

The column header reads `Cost (NZD)` (or the relevant currency code) when every priced row agrees on a single currency, derived via `sharedCurrencyCode(rows)`. It reads `Cost` when rows use different currencies. Individual cells always render the full formatted value through `formatMoney` — for example `US$50.00` — so a mixed-currency list stays unambiguous with or without the header hint. Unpriced options render blank rather than zero.

The flat price view uses `DataTable`'s `pagination` prop: it loads more as the reader scrolls and renders **End of list** once all matching prices are loaded. `showCount` is passed as `false` so `DataTable` does not render its own count line above the table. The count is shown once in the toolbar beside the filters: a single `ListCount` from `@stralos-co-nz/ui` with `total`, `filtered` (the page's `filtersActive`) and the noun `resource` on **Resources** or `rate` on **Rates**. It reads, for example, **Filtered to N rates** while any filter is active and **Showing all N rates** otherwise. It counts matching rows (the server's `totalCount` on **Resources**), never rows loaded so far.
The table container uses `overflowX="auto"` so wide tables scroll horizontally on narrow viewports rather than overflowing their card. The same scroll wrapper applies to the item rate-option grid in `RateRows` and the day-rate lines table in `DayRateLines`.

### Data shape and loading

`buildRatePriceRows` creates one `RatePriceRow` per `RateOptionMappingChoice`, denormalising the item and company data needed for display and filtering.

| Field | Source |
| - | - |
| `optionId`, `itemId` | Stable option and item identities. |
| `itemName`, `category` | Denormalised from the item. |
| `sourceItemCode` | The supplier's code for the item on this price (`rateOptions.sourceItemCode`). `null` for own prices, so it is neither shown nor searched. |
| `sourceItemName` | What the supplier calls this item (`rateOptions.sourceItemName`). `null` for own prices. Displayed as a secondary line on the price row when it differs from `itemName` (case-insensitive); hidden when identical. |
| `companyId`, `companyName` | Resolved via `scopeOwnerLabel` from the live locations and suppliers catalogue. Own prices render as the organisation's current name; supplier prices render as the supplier's name. An unresolved supplier id renders as **Unknown company**. `companyId` is the supplier's `parties.id`, or the literal `"organisation"` for own prices. |
| `locationName` | Resolved via `scopePlaceLabel`. A `null` `locationId` renders as **All locations** — a real, equal pricing choice, not an absent answer. |
| `unit`, `condition`, `currency` | From the option. `unit` is the priced unit and is displayed. |
| `modeUnit` | The unit most of an item's live options are priced in. Used by the unit filter in both views. |
| `rate` | `derivedCostRate ?? costRate ?? null`; `null` renders blank rather than as zero. |

`listAllActiveRateOptionChoices` runs first, then `resolveRateReferences` uses the option scopes to load the locations and suppliers needed to name them. These two reads are sequential, not parallel, because the id list for the second read is derived from the first response. Rows whose owner or location cannot be resolved stay in the table so historical prices are not hidden.

On `?view=prices`, the flat price rows and the location picker both need the full locations and suppliers catalogue on mount. Both consumers share a single in-flight call via `readLocationCatalogue` (`locationCatalogueRef` in `RatesPage`): the first caller starts the read and stores the promise; a second caller within the same in-flight window joins it rather than issuing a second request. The shared promise is released when it settles. Any write (`markPriceRowsStale`, `refreshLocationReferences`) drops the ref so the next read always fetches fresh data and no caller receives results from before a write it initiated.

### Filters on the flat view

`filterRatePriceRows` applies `search`, `companyIds`, `categoryIds`, `units`, and `unresolvedOnly` from `RatePriceFilters`. There is no missing-price or review-flag filter. Filters combine with AND; selections within a multi-select combine with OR; an empty selection means no constraint. Search covers item name, supplier code (sourced from option `sourceItemCode`), category, company name, location name, and condition. The `companyName` field used for search, sort, and display is the value from `scopeOwnerLabel`: own prices show the live organisation name; supplier prices show the supplier's name.

## Column management on the item grid

`RateItemGrid` uses `useDataTableColumns` to manage column visibility, order, and width. The state is persisted to `localStorage` under the key `stralos.rates.itemGridColumns`.

`useDataTableColumns(allColumns, { defaultState, onColumnStateChange })` returns `columns`, `choices`, and `api`. `columns` is the active subset in user-defined order; `choices` is the full set used by the column picker; `api` exposes `setWidth` for resize callbacks.

`RatePriceGrid` renders the same trailing `ItemActionsMenu` (⋮) as the grouped item grid. The verbs — **Edit…**, **History**, and **Delete…** — act on the rate item behind the price, not on the price row itself, so those actions remain available on the **Rates** tab.

`DataTableColumnMenu` renders the column picker gear. It sits in the header cell of the trailing `actionsColumn`, not in `DataTable`'s own `columnPicker` prop — that prop shares a trailing cell with `rowActions`, and this grid uses a hand-rolled row actions menu rather than `DataTable`'s `rowActions` so that the inline delete confirmation is available. Placing both in the same slot would add a second trailing header cell.

The actions column is intentionally excluded from column management. It is the only door to edit and delete, and a column picker that could hide it would silently remove a required control.

`saveItemGridColumnState` and `loadItemGridColumnState` are the read/write helpers. Both wrap `localStorage` access in a try-catch: a privacy mode that refuses writes leaves the picker functional for the current tab.

## Shared toolbar filters and URL state

`RateListQuery` is the canonical in-memory filter shape used by both the grouped item view and the flat price view:

```ts theme={null}
type RateListQuery = {
  search: string;
  categoryIds: string[];   // multi-select; empty means all
  companyIds: string[];    // parties.id values, plus the literal "organisation" for own prices; empty means all
  units: string[];         // item mode-unit codes; empty means all
  rateGroup: RateGroup | "null" | "";
  sortBy: RateSummarySortBy; // "name" | "category" | "costRate" | "companyLocation"
  sortDir: SortDirection;
};
```

`RateListUrlState` carries the same search, category, company, unit and sort fields, plus `unresolvedOnly` (the unresolved-category chip, `?unresolved=1`) and `groupByItem`, the active tab (`true` = **Resources**, `false` = **Rates**). `rateListQueryFromSearchParams` and `searchParamsFromRateListQuery` are the pure round-trip pair that serialises this state to and from the query string. The page skips its URL write when the serialised state already matches the current query string, preventing an infinite mount loop.

Malformed IDs in `companyIds` are dropped on parse and are never forwarded to the API. A mangled link therefore produces a slightly wider result list rather than a broken page.

The URL also accepts `?supplierId=<party id>` as a legacy alias for `companyIds`. Navigating to `/rates?supplierId=<party id>` opens the rates page with that supplier pre-selected in the company filter. The parameter is normalised to `companyIds` immediately on parse.

### Company and unit filters

The company filter uses party IDs (`parties.id`) plus the literal `"organisation"` for the organisation's own prices. On the grouped view, these are forwarded to `listRateSummaries` as `sources`, which api-rates applies before computing `totalCount`, so the `totalCount` the virtual scroller uses reflects the filtered set and scrolling reaches the correct end of the catalogue.

The filter options list the organisation's own entry first as a top-level checkbox with no group heading (`groupLabel: ""` in `CategoryMultiSelect`), labelled with the live organisation name. Suppliers follow alphabetically. Labels come from `scopeOwnerLabel`.

The unit filter matches the item's **mode unit** (`modeUnit`) in both views — the unit most of the item's live options are priced in. This is a derived axis computed by `modeUnitOf`, mirroring the `RateSummary.modeUnit` that api-rates returns for the grouped view. The unit filter does not match the option's individual priced unit.

### Tab serialisation and URL state

`groupByItem` is serialised as `view=prices` (**Rates** tab, `false`) or the absence of the `view` parameter (**Resources** tab, `true`). The tab strip is the control; there is no separate **Group by item** switch. Selecting a tab is driven by both user interaction and the address bar, so **Back** after switching tabs returns to the previous state. When the page receives `?view=day-rates`, the catalogue tab position is left where the user set it — the serialiser refuses to overwrite a navigation URL it has just been given.

A race fix is included: `view` is bound both ways between the tab strip and the address bar, and the page's own write lands asynchronously. Without the fix, a navigation echo of the write would undo the tab selection. The page records the last `view` value it wrote (`lastWrittenView`) and ignores that one echo; a `view` it did not write — Back, Forward, a pasted link — still moves the tab strip.

### Sort state and the company sort

`sortBy` and `sortDir` are shared URL state, serialised as `sort=` and `dir=` and omitted at the default (`sortBy: "category"`, `sortDir: "asc"`). An unrecognised `sort` value falls back to the default.

The item grid's **Company** header and the price grid's company-and-location header are sort controls (with `aria-sort`), both on the `companyLocation` field. `handleSort` records the field and whatever direction `DataTable` computed. The two tabs sort differently:

* **Resources** sorts server-side. `sortBy` is forwarded to `listRateSummaries`, so the virtual list pages in api-rates' `sortBy=companyLocation` order (see [Rate summaries](https://help.stralos.app/api/rates.md#query-parameters)).
* **Rates** sorts client-side because every price is already loaded. `visiblePriceRows` applies `sortRatePriceRowsByCompanyLocation(filtered, sortDir)` when `sortBy` is `companyLocation`; any other value leaves rows in filter order.

`sortRatePriceRowsByCompanyLocation` in `src/ratePriceRows.ts` mirrors the server order: own prices first, then companies by name (locale-aware), then **All locations** (`locationName === null`) before named places, then place name. The direction applies to those keys only. Remaining ties fall back to item name and then option id, always ascending.

## Category filter (rates page)

`CategoryMultiSelect` in `CategoryMultiSelect.tsx` replaces the previous per-group tab strip. It is a checkbox multi-select panel that portals to `document.body` to escape the toolbar card's clip container.

The closed button label is:

* `"All categories"` — nothing ticked.
* The category name — exactly one ticked.
* `"N categories"` — multiple ticked.

Each parent-group heading in the panel (`Checkbox.Root`) is itself a select-all toggle. Its checked state is `true` (all children picked), `false` (none picked), or `"indeterminate"` (partial). Ticking a heading selects every category in that group; unticking clears them. **Clear — show all categories** at the top calls `onChange([])`.

Panel position is fixed-coordinates derived from the trigger's `getBoundingClientRect` and recalculated on scroll and resize. Click-away detection accounts for both the trigger element and the portaled panel, since they are in separate DOM trees.

The panel measures its own rendered rows (`snapToRowBoundary`) and ends at the highest row boundary at or under the 340 px cap. This prevents a partial row appearing at the bottom — a half-drawn row reads as a rendering fault rather than an invitation to scroll, and the fade below it cannot rescue it. The snapped height is recalculated on open, on choice count changes, and after the first snap settles. A zero measurement (DOM not yet laid out) keeps the cap rather than collapsing the panel.

When the list is taller than the snapped height, a fade overlay is rendered at the bottom for as long as `scrollHeight - scrollTop - clientHeight > 1`. The fade is recalculated on open, on choice count changes, and on scroll.

`RatesPage` passes `categoryFilterChoices` — categories sorted by group label then name — to `CategoryMultiSelect`. Selecting categories sets `categoryFilterIds`, which is forwarded to `listRateSummaries` as the `categoryIds` query parameter. The server-side filter is exact: only items whose `categoryId` is in the supplied list are returned. Passing an empty array omits the parameter and returns all items.

`GET /rate-summaries` also accepts `units` (comma-separated unit codes) and `sources` (comma-separated supplier company UUIDs or the literal `organisation`) as server-side filters. Both must be applied as query parameters rather than client-side, because the endpoint counts and pages in memory: a filter applied after fetching would narrow the rows while leaving `totalCount` describing the wider set. When `totalCount` overstates the filtered result, the virtual scroller reserves phantom rows and scroll position never reaches the real end of the list. Pass `units` and `sources` to `listRateSummaries` whenever the rates page unit or owner multi-selects have selections.

Each `quarry_materials` item in the summary response now carries `densityTPerM3Min`, `densityTPerM3Max`, and `densityIssue`. These are derived server-side from the item's live priced options — specifically from the `conversionFactor` column that the rate editor labels "Density (t/m³)" for bulk material items. A range rather than a scalar is returned because the supplier, and therefore the density, is selected at the work-item line rather than at the library; the trench calculator (D73b) reads this range to show what the material could weigh across the library's suppliers. All three fields are `null` for items outside the `quarry_materials` group. See `lib/bulk-material-density.ts` in `stralos-api-rates` for the derivation rules and the `densityIssue` values (`no_priced_option`, `no_conversion_factor`, `unreadable_orientation`).

## Duplicate-rate name warning

While a new rate item's name is typed, `RateEditorModal` names existing rates that may be the same one (#19). It is a warning only: nothing is blocked, there is no "use this one" action, and the exact-name refusal on save (`409 name_conflict`) is unchanged.

* The modal checks only when its `similarRates` prop is set, the item is new (`values.id` is absent) and the reader can edit the catalogue. The Rates page passes `{}` (library only). `RateEditorSheet` passes `{ projectId, estimateId? }` when its host names a project, so the project's own rates are searched too, whichever mode is chosen.
* `useSimilarRates(name, scope)` in `src/similarRatesWarning.tsx` waits `SIMILAR_RATES_DEBOUNCE_MS` (400 ms), then calls `findSimilarRateItems`, which reads [`GET /api/rates/rate-items/similar`](https://help.stralos.app/api/rates#duplicate-rate-warning). Names over 200 characters are not checked. A failed check returns no matches. A result is shown only while it belongs to the name now in the field, so editing the name clears it at once.
* `SimilarRatesWarning` renders under the name field in a polite live region that is mounted empty and filled later, so screen readers announce it. One match reads "This rate may already be here: {label}."; several read "These rates may already be here:" with a list. Text uses the `status.warning.fg` token.
* `similarRateLabel(match)` formats each entry as the name, then its companies (up to three, otherwise "{first} and N more"), "your own rate" when only the organisation prices it, and "this project" for a project rate. For example `AP40 Basecourse (Example Quarries, this project)`.

feature-rates publishes this in `@stralos-co-nz/feature-rates` 0.3.0.

## Duplicate rate option error

A `409` on the rate option create path (`upsertRateOption`) previously showed the generic concurrency message ("Someone else changed this first"). The near-certain cause for a create-path 409 is a duplicate at the same supplier, location, and modifier. `OPTION_CREATE_STATUS_MESSAGES` in `RatesPage.tsx` maps `409` to:

```
"This item already has a price for that supplier, location and modifier. Edit the existing option instead."
```

The message appears both inline (rethrown to the row's alert slot) and as a `toaster.error` notification. The update path keeps the generic concurrency wording because a genuine concurrent edit is the expected cause there.

## Category picker

`RateEditorModal` renders one `SelectCombobox` under the **Category** label. Its choices come from `categoryChoices(categories)` — a sorted list where each option is:

* `value` — the category UUID
* `label` — the category name
* `group` — the parent `RateGroup` display label from `RATE_GROUP_LABELS`

Options are sorted by parent-group position in `RATE_GROUPS` (canonical display order), then by category name.

Choosing a row writes two fields simultaneously: `categoryId` and `category` (the display name). Both move together so an unresolved rate never holds a new id under a stale name. The item no longer carries a `unit` field, so changing category has no unit to re-seed or clear on the item itself.

### Category catalogue loading

`RatesPage` calls `listAllRateCategories()` once on mount and passes the result to every editor instance. The function pages `GET /api/global/rate-categories` in chunks of 200 to exhaustion (up to 50 pages). If the catalogue is truncated rather than complete, it throws — a quietly short answer is worse than an error because missing rows would be silently unselectable.

### What the server derives

Choosing a category is the only classification input. The server stamps:

| Derived column | Source |
| - | - |
| `rate_group` | `rate_categories.parent_group` |
| `category` (display name) | `rate_categories.name` |

The `rateGroup` field is not sent from the editor and is not part of the write schema. If a stale client includes the unknown key, the request parser strips it.

## Unit filtering by category

`RateCategoryDefinition` carries an `allowedUnitCodes` array — the set of unit codes permitted for options in that category. This list drives the unit picker on the **option** editor and entry row; it no longer drives a unit field on the item editor because the item no longer carries a unit.

When a new option is seeded, `newOptionValues` takes the first allowed unit from the item's resolved category. If the category is unresolved, the unit remains blank so validation asks the operator to make an explicit choice.

### Validation

`collectItemFieldErrors` blocks item save when category is missing or unresolved:

* No `categoryId` chosen → `"Choose a category."`
* `categoryId` set but not in the catalogue → `"This category no longer exists. Choose another."`

Unit validation on options is handled by `collectOptionFieldErrors` — see [Option field validation](#option-field-validation). The API rejects an option unit outside the selected category's allowed list with `422`.

## Category editor: allowed units

`CategoryEditorValues` includes `allowedUnitCodes: string[]`. When creating or editing a category, the admin selects which unit codes are permitted for rates in that category. `UpsertRateCategoryInput` carries `allowedUnitCodes` to the server.

### Unset rate group

`CategoryEditorModal` can be opened with `parentGroup` set to an empty string. This happens when the rate import opens the editor for a new category candidate — pre-selecting a group would reinstate an AI classification as a default that a hurried reviewer might accept without reading (design decision D9).

The editor requires the admin to choose a group before saving. Group selection classifies the category but does not alter its allowed units.

When a new category is created from within the rate item editor, the editor selects that category. It does not seed an item unit; a later new option uses the category's first allowed unit.

### "Category saved" toast announcement

`saveCategory` in `RatesPage` raises a `toaster.success({ title: "Category saved" })` toast in the same turn the category editor closes and the rate item editor returns to focus. Because the toast renders outside the item editor, the modal's `aria-hidden` sweep silences it for screen readers.

`@stralos-co-nz/ui` 0.59.0 resolves this by copying toast text into the topmost open overlay's own polite live region. The test `"announces Category saved inside the rate item editor it returns to"` in `RatesPage.test.tsx` asserts on that region node — querying by visible text would match the aria-hidden toast and pass against older ui versions. The visible toast is the instrument control confirming the silent copy is the only receipt a screen reader gets.

### Unit list in the category editor

The unit picker in `CategoryEditorModal` shows every unit in the catalogue, grouped under the catalogue's own category headings. There is no per-parent-group subset and no "Show all units" fold.

A previous `typicalUnitCodesByGroup` table drove a "Common units for \<group>" block above a fold, pre-ticked units on add, and was mirrored by an api-global rule that refused units outside the per-group subset. Both halves are removed. The group no longer restricts, orders, or pre-selects the unit list; which units a category allows is the organisation's decision. The group still clears the `isCartageVehicle` flag when changed to a non-Plant group, because that flag is the one field the group legitimately owns.

The unit code list and display symbols are sourced from `UNIT_CATALOGUE` exported by `@stralos-co-nz/ui` (0.74+). The previous local `src/unitLabel.ts` file (which held `knownUnitCodes`, `unitChoiceLabel`, `formatUnitSymbol`, and the inline `rateUnitsFallback` array) is removed. `rateUnitsFallback` in `ratesShared.ts` is now `UNIT_CATALOGUE.map(u => u.code)`. The option editor's unit field uses `UnitCombobox` from `@stralos-co-nz/ui`; `formatUnit` from the same package is used wherever a unit symbol is displayed.

**Orphan unit handling.** An orphan code is a code in `allowedUnitCodes` that the catalogue no longer carries. In earlier builds, unticking an orphan caused its row to vanish immediately — it was computed from the selection, so removing it from the selection removed it from the visible list, and the user could neither see what they had done nor restore it. `seenOrphans` keeps every orphan on screen for as long as the editor is open, re-checking against the catalogue on each render because a late-loading catalogue can adopt a code that was an orphan at open.

## Company reads: `/parties` register

`listAllClients` and `listAllParties` both read `GET /api/global/parties?role=<role>` rather than the retired `/clients` and `/suppliers` endpoints.

`listAllParties(role: PartyRole)` pages the endpoint to exhaustion and throws rather than returning a truncated result. It is the single read path for every role this feature uses; `listAllClients` wraps it with `role=client` and projects to `ClientSummary`.

The `PartySummary` shape is declared locally in `api.ts`; this feature has not yet switched to the `Party` type exported by `@stralos-co-nz/domain`. Everything downstream is projected through `ClientSummary` (id and name only), so adopting the domain import later is a localised change.

`rateSource` is deliberately absent from `PartySummary`. The list endpoint carries no `rateSource`; declaring it optional would let an absent field read as "this company cannot be priced from" rather than "this field was never returned".

### Suppliers page removal

The Suppliers page (`/suppliers` route) is removed. A supplier is a role on a company in the Companies register at `/companies`. The shell redirects `/suppliers` to `/companies`; an active company with the Supplier role is directly available for supplier-scoped pricing under its company id.

The following were removed with the page — they have no callers remaining:

* `createSupplierBusiness`
* `updateRateSource`
* `retireRateSource`
* `listSuppliers`
* `createRateSource`

What stayed:

* `upsertLocation` (reached from the rate option location picker on the Rates page, its own flow).
* `resolveRateReferences` (resolves the locations and suppliers a set of stored scopes point at — used by the **Rates** tab flat view so a price against a since-retired supplier still shows its name rather than "Unknown company").

Location RENAME and RETIRE had no replacement at the time this page was removed. That gap is tracked with the programme.

## Detail pages

Both detail pages are children of `/rates`, so the shell needs no change. It mounts this remote by `basePath` with `end: false`, and every path below `/rates` resolves here.

### Rate item detail page

**Route:** `RATE_ITEM_DETAIL_ROUTE = "/rates/items/:rateItemId"`

`RateItemDetailPage` (`src/RateItemDetailPage.tsx`) renders:

* A `PageHeader` with the item's name, status badge, and a back link to `/rates` (using `render`, not `href`, to avoid shell re-bootstrap on navigation).
* A key-facts row: category, bulking %, compaction %, max payload (t), and max bin volume (m³). Missing values render as `—`.
* A prices section that mounts `RateRowDetail`, the same expansion the rates list opens, driven by `useRateOptionMachine`. Inline price editing, the quick-entry row, condition re-keying, retire with Undo, and the option editor (`RateOptionEditorModal`) all behave exactly as they do in the rates list, because they are the same components driven by the same machine.

`rateItemDetailPath(rateItemId)` in `src/detailRoutes.ts` builds the URL. The expanded row on `RatesPage` links here via that builder. The back link uses `<Link to="/rates">` (router `Link`) so navigation stays a client-side transition.

Anyone who can edit rates can edit catalogue fields via `RateEditorModal` (the **Edit** button) and view the audit trail via `RateItemHistorySideSheet` (the **History** button). Both are the same components used from the list.

The **Open `<item>`** link that `RateRowDetail` normally renders is opt-in. The rates list and compact card set it; the item page does not, so the detail page never carries a link back to itself.

On load, the page calls `getRateItem`, while `useRateOptionMachine` loads the item's options and categories and reads the supplier/location reference catalogue. A load failure shows an error with a **Retry** button.

#### useRateOptionMachine

`useRateOptionMachine` (`src/useRateOptionMachine.tsx`) is the extracted price-state hook shared between `RatesPage` and `RateItemDetailPage`. It encapsulates all option reads and writes — inline saves, basis changes, condition re-keys, retire calls, the option editor, and the location editor — together with the rules they carry (S5's cost guard, C16's pinned condition token, C23's second lock, the advisory-refresh rule, and the re-key's `allSettled` refresh).

The hook exposes two option-creator entry points:

| Function | Door |
| - | - |
| `openOptionCreator(item)` | Fixed-item door (per-item ⋮ menu, item page header button). Sets `creatorPicking` to `false` and seeds the form for the supplied item. Reads the item’s options first if the host has not already done so. |
| `openOptionCreatorWithPicker()` | Shared-toolbar entry point (**Add rate** button on either catalogue tab). Sets `creatorPicking` to `true`, seeds the form against an empty item id, and loads all active items via `listAllActiveRateItems` into `creatorItems` for the picker. |

The hook accepts five host-specific inputs:

| Input | Purpose |
| - | - |
| `findItem(id)` | Resolves an item id to its `RateItemView`. `RatesPage` looks it up from the summary list; `RateItemDetailPage` compares against the single loaded item. |
| `onWriteSettled()` | Called after any option write settles. `RatesPage` refreshes the summary list; `RateItemDetailPage` re-reads its own item. |
| `onPricesStale()` | Called when the flat price snapshot should be invalidated. `RatesPage` marks its flat rows stale; `RateItemDetailPage` passes a no-op. |
| `readReferenceCatalogue()` | Returns the deduplicated supplier and location catalogue. `RatesPage` holds this ref; `RateItemDetailPage` always fetches fresh. |
| `forgetReferenceCatalogue()` | Drops any cached reference read after a write. The detail page has no cache to clear. |

`RateOptionOverlays` renders the option editor plus the location and supplier creators from the machine. Both hosts mount this component so neither can wire `canRekeyCondition` or the creation hand-off differently.

### Day rate revision detail page

**Route:** `DAY_RATE_REVISION_DETAIL_ROUTE = "/rates/day-rates/schedules/:scheduleId/revisions/:revisionId"`

`DayRateRevisionDetailPage` (`src/DayRateRevisionDetailPage.tsx`) renders:

* A `PageHeader` with the revision's `nameSnapshot`, status badge, identity facts, and a back link to `/rates?view=day-rates`.
* `DayRateRevisionWorkspace` — the **same component** the panel's expand row mounts, keyed by `revision.id`. An edit on this page and an edit made by expanding the panel row are identical operations against the same API endpoints.

`dayRateRevisionDetailPath(scheduleId, revisionId)` in `src/detailRoutes.ts` builds the URL. The panel's expand row links here via that builder.

The page header includes three related links:

* **Open in day rates** (`dayRatesPanelPath`) — returns to the panel row.
* **Review the base** (`dayRateRebaseReviewPath`) — navigates to `DayRateRebaseReviewPage`. Rendered only when `schedule.needsBaseReview` is true.
* **Print / Save PDF** (`dayRatePrintPath`) — opens the print layout.

`dayRatePrintPath` is now defined in `src/detailRoutes.ts` rather than in `DayRateExport.tsx`. This keeps the builder dependency-free so a page that links to the print view does not pull the XLSX writer into its chunk. `DayRateExport` re-exports it, so no other import moved.

On load, the page calls `getDayRateSchedule(scheduleId)` and finds the matching revision by `revisionId`. A missing schedule shows a load error with **Retry**; a missing revision after a successful schedule read shows a not-found message (the revision may have been deleted).

### `detailRoutes.ts`

`src/detailRoutes.ts` is the single source of truth for all three route templates and their corresponding path builders:

| Export | Value |
| - | - |
| `RATE_ITEM_DETAIL_ROUTE` | `"/rates/items/:rateItemId"` |
| `rateItemDetailPath(rateItemId)` | URL-encodes the id into the template |
| `DAY_RATE_REVISION_DETAIL_ROUTE` | `"/rates/day-rates/schedules/:scheduleId/revisions/:revisionId"` |
| `dayRateRevisionDetailPath(scheduleId, revisionId)` | URL-encodes both ids into the template |
| `DAY_RATE_PRINT_ROUTE` | `"/rates/day-rates/revisions/:revisionId/print"` |
| `dayRatePrintPath(revisionId)` | URL-encodes the id into the template |

The module is intentionally dependency-free. Any page that needs to link to a detail page imports only this module, not the page's own component, so the linked page's bundle stays in its own lazy chunk.

`routes.contract.test.ts` asserts each builder against its mounted template to catch path drift before deployment.

## Removed controls

### Rate group picker

The standalone **Rate group** picker is removed. A rate's group is its category's parent. There is nothing for a user to choose; offering the six groups as options would offer a contradiction.

The group still renders as a muted, read-only prefix in the category cell using `formatRateGroupLabel(item.rateGroup)`.

`rateGroupOptions` and `suggestRateGroup` are deleted from `ratesShared.ts`. The suggestion box — which offered a heuristic guess — is removed along with the field it fed.

### Is material checkbox

The **Is material** checkbox is removed. Material behaviour is inferred from `rateGroup`; neither `RateItemEditorValues` nor the write payload carries an `isMaterial` field.

### Removed rate item fields

The **Measured in** and **Code** inputs are absent from `RateEditorModal`. Both fields are also absent from `RateItemEditorValues` and `buildRateItemPayload`, matching the Rates API item contract.

The unit a price is quoted in belongs to the rate option, where each supplier can differ. `newOptionValues` seeds the unit for a new option from the category's first allowed unit code rather than from the item.

Price rows and import review use option `sourceItemCode` and priced `unit`. Grouped item views use `modeUnit`; they do not read item-level code or unit fields.

## Unresolved rates

A rate whose stored `categoryId` no longer resolves to a catalogue row is **unresolved**. This happens when the category is deleted after the rate was saved.

The picker displays the rate's stored category name with an `(unresolved)` suffix via `unresolvedCategoryLabel`. The stored id is held and displayed but cannot be re-selected after a repair. Passing the label as `unknownValueLabel` rather than as a real choice prevents the dangling reference from being picked back after a new one is chosen.

Validation blocks save for an editor until a real category is selected:

* Blank `categoryId` → `"Choose a category."`
* `categoryId` set but not in the catalogue → `"This category no longer exists. Choose another."`

Read-only users cannot fix an unresolved rate and are not blocked by this validation.

## Edit category affordance

**Edit category** is available when an admin has a category selected. The callback receives the selected `RateCategoryDefinition` — the parent category's identity. This distinction was load-bearing in a previous version where sub-category and category shapes had matching field names; it is now unambiguous because the picker directly selects a category row.

### Clickable category name on rate rows

The category name on each rate row in `RateRows` is rendered as a button (using `variant="plain"`) that calls the same `onEditCategory` callback as the **Edit category** button inside the rate editor. This gives admins a second route to the category editor without opening the rate item first.

The category name renders as plain text (no button) in three cases: the admin has no `onEditCategory` handler, `rate.item.category` does not resolve to a `RateCategoryDefinition` in the live catalogue (the category has been deleted), or the user does not have catalogue-edit access. All three fallbacks preserve the category name text — the fallback must not hide it.

## Dialog and side-sheet form layout (FormGrid)

All rate editor forms — `RateEditorModal`, `RateOptionEditorModal`, `DayRateScheduleCreate`, and the modals in `RateReferenceModals` — lay out their fields on a `FormGrid` from `@stralos-co-nz/ui`. `FormGrid` is a twelve-column CSS grid. `FormField` reads its position from that grid automatically via `useInFormGrid`; no `gridColumn` prop is needed on a `FormField` child.

Field width is controlled by the `span` prop on `FormField`:

| `span` value | Columns occupied | Typical use |
| - | - | - |
| (default / full) | 1–12 (full row) | Single-column fields: name, category, notes. |
| `"medium"` | 1–7 (roughly half) | Two-up pairs: Bulking % + Compaction %, Max tonnes + Max m³, Modifier + Priced as. |
| `"short"` | 1–5 (roughly one-third) | Narrower pairs: Valid from + Valid until. |

Non-`FormField` children placed directly inside `FormGrid` receive a single grid column, which is never the intended behaviour. Every non-field child — banners, confirm steps, status text, badges — must carry `gridColumn="1 / -1"` to span the full row.

```tsx theme={null}
<FormGrid as="form" id={formId} onSubmit={...}>
  {/* Non-field content must span the full row */}
  <Box gridColumn="1 / -1">
    <ModalErrorBanner message={error} />
  </Box>

  {/* Full-width field — no span prop */}
  <FormField label="Item name" required error={fieldErrors.name}>
    <Input ... />
  </FormField>

  {/* Two-up pair — span="medium" on each */}
  <FormField span="medium" label="Bulking %" error={fieldErrors.bulkingPercent}>
    <Input ... />
  </FormField>
  <FormField span="medium" label="Compaction %" error={fieldErrors.compactionPercent}>
    <Input ... />
  </FormField>
</FormGrid>
```

Before this pattern, paired fields used `HStack` wrappers inside the form, which caused each field to size itself independently and produced inconsistent widths across dialogs.

## RateReferenceModals

`src/RateReferenceModals.tsx` exports three editors used across the Rates Library and import screens:

* `CategoryEditorModal` — thin wrapper around `SharedCategoryEditorModal` from `@stralos-co-nz/ui-reference-data`, shown as a side sheet. Wraps API errors through `safeMessageText` so internal identifiers cannot appear in the UI.
* `LocationEditorModal` — re-export of `LocationCreationSheet` from `src/RateCreationSheets.tsx`. The sheet renders `LocationPicker` from `@stralos-co-nz/maps-ui` and passes the Google Maps `countryCodes` setting from `geocodeConfig()`. `@stralos-co-nz/maps-ui` 0.10.0 removed its own `LocationEditorModal`.
* `SupplierEditorModal` — re-export of `SupplierCreationSheet` from `src/RateCreationSheets.tsx`.

All three are side sheets, so the category, supplier, and location editors look and behave the same across the Rates Library.

### LocationEditorModal — supplier picker

The Rates Library passes ownership into `LocationEditorModal` as `partyId`: `null` creates an organisation-owned location and a supplier company id creates a supplier-owned location. The picker link that opens the sheet has already established the owner.

#### Location kind default

`defaultLocationKind(partyId)` in `src/RateCreationSheets.tsx` derives a single owner-aware default: `"yard"` for the organisation (`partyId === null`) and `"quarry"` for any supplier. This function is called at every entry point that opens a new-location form. When the owner changes while the kind field has not been explicitly touched by the reviewer (`kindChosen === false`), `onChange` re-applies the default from the new owner. Once the reviewer selects a kind, `setKindChosen(true)` and the kind no longer follows owner changes.

Creating a supplier is a separate `SupplierEditorModal` action. On success the company is added to the live supplier list and selected as a supplier-wide scope (`locationId: null`); creating a location writes its `partyId` in one request and selects the specific new location. Both paths refresh the shared reference catalogue after the write.

### selectNameOnOpen

All three modals accept `selectNameOnOpen?: boolean` (default `false`). When `true`, the modal focuses the name field and selects its current value on open. Use this only when the caller has pre-filled the name from a document the reviewer is looking at — the import's entity section is the only current case. Applying it to an existing record would select a name the reviewer may not intend to replace.

The prop is opt-in and stays that way. The location sheet places the owner picker above the name, so `useNameField` in `RateCreationSheets.tsx` uses a `ref` and calls `.select()` directly on the name input to guarantee the right field is targeted regardless of tab order.

## Physical-field gates

The item editor shows `bulkingPercent` and `compactionPercent` only for a `quarry_materials` category. It shows `maxTonnes` and `maxCubicMetres` only when the selected category has `isCartageVehicle: true`. Changing classification to one that does not license a populated field clears that field in the editor and the API returns the authoritative `clearedFields` list.

## Payload construction

`buildRateItemPayload` in `ratesShared.ts` is the single source of truth for what reaches the wire. Key points:

* `categoryId` is sent as the sole classification input; `category` and `rateGroup` are not sent.
* `code` and `unit` are not part of the item payload. Supplier codes and priced units belong to options.
* The category's parent group decides which **physical fields** the item may carry: `bulkingPercent` and `compactionPercent` under `quarry_materials`; `maxTonnes` and `maxCubicMetres` under a cartage-vehicle category. A field the category does not license is a `422` — unless the same write also changes the category, in which case the server **clears** it and returns the cleared names in `clearedFields`. The retired `isMaterial`, `materialPricingUnit`, and `isDisposal` fields are not written.

## Rate options

Rate options record the per-owner, per-location, per-condition pricing for a rate item. Each option carries a `scope` (own prices or a specific supplier), an optional location, and a condition.

```ts theme={null}
type RateOptionEditorValues = {
  id?: string;
  rateItemId: string;
  /**
   * Whose price this is and at which location.
   * null = the reviewer has not yet answered; ORGANISATION_WIDE = our prices at all our locations.
   * "All locations" (locationId === null) is a real, equal pricing target — not a missing answer.
   */
  scope: RateScope | null;
  sourceItemCode: string;
  sourceItemName: string;         // what the supplier calls this item; "" means not recorded
  condition: string;              // lowercased slug, e.g. "standard" or "night"
  basis: "fixed" | "multiplier";
  basisMultiplier: string;        // decimal string; only used when basis === "multiplier"
  unit: string;
  conversionFactor: string;       // decimal string
  costRate: string;               // decimal string; only used when basis === "fixed"
  provenance: RateOption["provenance"];
  validFrom: string;              // date string "YYYY-MM-DD"
  validFromOriginal: string;      // full ISO timestamp from server
  validTo: string;                // date string; "" means no expiry
  validToOriginal: string | null;
  status: RateOption["status"];
  notes: string;
  needsReview: boolean;
};
```

### Dialog actions: Cancel and Close

`RateEditorModal` is a `SideSheet` at `size="lg"` (640 px wide) that slides in from the right. When the assistant panel is open, from the `md` breakpoint up it is non-modal: no backdrop, no scroll lock, and the page behind stays live and clickable. The sheet opens beside the assistant panel rather than blocking it. `RateEditorModal` uses `SideSheet`'s `actions` prop rather than a hand-built footer. This is what renders a **Cancel** button for editors and a **Close** button for read-only users.

`RateOptionEditorModal` uses `SideSheet`'s `actions` prop in the same way — the cancel affordance is placed in the action row rather than hand-built.

**Editors**: the `actions` object supplies the primary action (**Save item** / **Save option**) and an optional destructive action: **Retire** for an option, or **Delete item** for an item, which only admins see. `DialogCancelButton` is rendered automatically by the action row as the cancel slot. It respects `confirmOnDirtyClose`, so clicking Cancel when the form is dirty triggers the unsaved-changes prompt rather than discarding changes silently.

**Delete and retire use Undo, not a confirm.** Every rate delete and retire acts at once behind an Undo banner: rate item delete (⋮ menu, item editor, item page), price retire (grouped row, Prices view, option editor), and removals inside the pricing rules editor (there Undo restores the draft and the commit writes nothing). The write is the `onCommit` of ui's `useUndoAction`, so **Undo** makes no request, and the write is sent when the Undo window closes or the page unmounts. A failed commit puts the row back and shows an error toast. The banner text names what went (for example `"<item> deleted. An admin can also bring it back later from History."`). `RateOptionRetireDialog` and `InlineDeleteConfirm` were removed. Item delete stays admin-only, and so does restoring a deleted item from History.

**Undo for edits.** `offerEditUndo` in `useRateOptionMachine` offers an **Undo** toast after a price save (inline, basis switch, option editor) or a rate item save (list and item page). Only the last change can be undone: each offer takes the next sequence number, and an older toast's Undo shows **Nothing undone**. The revert writes the earlier values through the same route the change used. An option editor save that also rewrote its pricing rules offers no Undo, because a rule set has no history.

**Read-only users**: `actions` is `undefined` (no primary to offer). A `footer` prop renders a standalone `DialogCancelButton` labelled **Close**. The button goes through the `confirmOnDirtyClose` guard if the form is ever dirty on a read-only view.

This pattern means every editor form has a visible cancel affordance on the left of the action row. The × corner button remains but is no longer the sole dismissal path.

### RateOptionEditorModal

`RateOptionEditorModal` is a `SideSheet` at `size="lg"` (`640px` wide) that slides in from the right. When the assistant panel is open, from the `md` breakpoint up it is non-modal: no backdrop, no scroll lock, and the rates page behind stays live and clickable. This means the sheet opens beside the assistant panel rather than blocking it. The modality is fixed when the sheet opens and does not change on resize. The sheet is not stepped; it is a single form with conditional pricing fields rather than sequential steps, so it mounts no step-announcer live region. This prevents a collision with the screen's existing `role="status"` region.

`RateOptionEditorModal` is opened from four entry points, all for anyone who can edit rates:

* **Edit details…** from a row's overflow menu (⋮) — existing option, full editing.
* **Add rate…** from the per-item overflow menu (⋮) on the rates list (desktop grid and phone card) — new option for that item, no item picker.
* **Add rate** button on the rate item detail page header — new option for that item, no item picker.
* **Add rate** button in the shared toolbar on **Resources** or **Rates** — new option with a **Rate item** `SelectCombobox` at the top. Nothing below the picker renders until an item is chosen; **Save** is disabled with the reason shown. Choosing an item seeds the form exactly as the fixed-item entry points do. **Add new rate item** (a standing entry, plus a typed **Add rate item “…”** row) opens `RateEditorModal` swapped in over the option editor; saving passes the new item back to the picker already chosen; cancelling returns to the picker with its state intact.

The fixed-item entry points (⋮ menu, item page) show no item picker. The shared-toolbar entry point always shows the picker.

`openOptionCreator(item)` in `useRateOptionMachine` handles the fixed-item door. It sets `creatorPicking` to `false` and seeds the form for that item, reading the item's options first if the host has not.

`openOptionCreatorWithPicker()` handles the shared-toolbar entry point. It sets `creatorPicking` to `true`, seeds the form against an empty item id (the editor requires values at all times), and loads all active items via `listAllActiveRateItems` into `creatorItems`. `listAllActiveRateItems` pages `/rate-summaries` to exhaustion — the same endpoint `listAllActiveRateOptionChoices` already shared.

The **Advanced pricing** block is offered while creating an option as well as when editing one, for anyone who can edit rates. A new option has no rule owner or version yet, so `useRateOptionMachine` saves it in one of two ways:

* **Must-pick option** (the draft's base is a rate table or **Priced by step**, `requiresManualPick`): the rules travel in the create `POST`, because the option has no cost and cannot exist without them.
* **Any other option**: the `POST` carries no rules. The machine then reads the new owner's version and writes the rules with `replacePricingRules`. If that second write fails, the option is still saved; the sheet stays open on it with the rules in the form and an explanation, and the next **Save** is an ordinary update.

The base rule choices are **Simple**, **Quantity breaks**, **Rate table**, **Fixed charge** and **Priced by step**. Two PostHog flags, both off unless PostHog says on, gate the choice only: `rate-tables` (`useRateTablesFlag`) offers **Rate table** and its templates, and `stepped-rates` (`useSteppedRatesFlag`) offers **Priced by step**. An option that already holds a rate table or steps keeps the choice and stays editable with its flag off, and the read-only surfaces never consult either flag.

A must-pick option has no **Cost rate** box. A rate-table option shows a note that the table is its price; a stepped option says it is priced by step and that the estimator picks a step on each line. The step editor gives each step a name and an amount, adds steps with **Add step**, and never deletes a saved step: **Stop offering this step** retires it and **Offer it again** brings it back, because estimate lines store their pick by the step's key. A stepped rule cannot keep minimums or loadings; leftover ones from a previous base show an error with a **Remove them** button. In the option grid, `pricingRuleGridDisplay` shows a stepped option's step range and a rate-table option's lowest to highest cell price per unit (`$7.80–$26.00/m`, one figure when every cell agrees), computed with the domain's `matrixPriceRange`. The server's stored cost for a rate-table option is never shown.

Users who cannot edit rates receive a read-only panel; save and retire controls are absent and both handlers also fail closed. For editors it renders:

* **Company — Location**: a read-only label for an existing option, rendered via `scopeLabel`. The pairing is immutable because estimate pins and pricing rules refer to it; moving a price requires retiring the option and adding another. The `required` mark is shown only when creating a new option (when `values.id` is absent); once the option exists the field is static text and carrying a required indicator would ask for input that cannot be given. "All locations" is a valid and equal choice: `locationId === null` is not an absent answer.
* **Modifier**: a `ConditionRekeyControl` for non-Standard options. The Standard option's condition is fixed.
* **Priced as**: `SelectCombobox` between a set price and a multiplier of Standard. These modifier fields are hidden for the Standard option itself.
* **Priced unit**: `SelectCombobox` filtered to `category.allowedUnitCodes`. Disabled when basis is `multiplier` — the multiplier inherits the Standard option's unit automatically.
* **Conversion / Density**: shown only when the unit has a bespoke conversion. The label and visibility come from `conversionFieldFor(rateGroup, unit, storedFactor)`, which returns one of four states: `editable` (a bespoke conversion — label is the kind's own name, e.g. "Density (t/m³)", "Metres per length", "Metres per roll", "m² per roll", "m² per sheet"); `legacy` (a factor saved against a unit that no longer has a conversion — shown read-only with the note "This unit no longer converts. The saved factor is kept and not used in pricing."); `substance` (disposal — shows "— via substance" read-only, because the substance supplies the conversion at use); or `hidden` (all other units — the field does not render). Moving off a unit with a bespoke conversion clears the saved factor, because a hidden field is never saved. A legacy factor is preserved unchanged. Read-only when basis is `multiplier`.
* **Cost rate**: shown when basis is `fixed`. The field is a `MoneyInput` with a `$` prefix and a `/unit` suffix that follows the selected **Priced unit** (for example, the label reads `Cost rate (NZD per t)` when the unit is tonnes). It accepts comma-formatted input (`1,234.5` saves as `1234.5`) and rounds to 2 decimal places on blur, matching the API's 2-dp write rule. Invalid text (negative values, malformed numbers such as `1,5`) is detected via `parseMoneyInput` and refuses the save with `"Enter a valid cost."` — the field reverts to the last valid value on blur. The field was previously a bare decimal input without currency context.
* **Multiplier ×** and **Server-derived cost**: shown when basis is `multiplier`. The derived cost display reads `currentOption.derivedCostRate`.
* **Valid from / Valid until**: `DateField` components rather than native date inputs. Both display day-first (`7 Sep 2026`) and accept day-first typed input, so 3/4/2026 always stores as 3 April regardless of browser locale. The placeholder shows a worked example (`e.g. 3 Apr 2026`) rather than the `d MMM yyyy` format token. Valid until is read-only and has no helper text; only retiring the option sets it.
* **Supplier item code** and **Supplier item name**: shown only when `carriesSupplierItemFields(values.scope)` is true, that is once a supplier's location is picked. Own prices and an unanswered scope show neither. `withScope` clears both values when the editor moves away from a supplier, so a hidden value is never saved. The supplier item name is free text for display and reconciliation only, never a matching key. Its placeholder reads "What the supplier calls this item" and it accepts up to 200 characters; a longer value produces the inline error "Keep the supplier's item name to 200 characters or fewer."
* **Notes**: textarea.
* **Retire**: shown on existing options. Calls `onRetire()`, which closes the sheet and retires the option behind the page's Undo banner.

The sheet does not offer provenance ("Where this price came from") or the review flag. Both values still travel in `values`: a new option saves the seeded `provenance: "manual"` and `needsReview: false`, and an edit keeps what is stored.

The inline quick-add entry row in the expanded rate grid does not include a **Supplier item name** field; only the full **Edit details** modal does. Its supplier code cell is an empty placeholder until a supplier's location is picked.

#### Condition re-keying

Condition identity changes run through `ConditionRekeyControl`, not the ordinary option save. The control previews affected dependants and commits the server's re-key plan. After success, `RatesPage` refreshes the option, its pricing rules, the item option list, and summary aggregates; partial refresh failure is reported explicitly because the re-key itself has already committed.

`ConditionRekeyControl` accepts an optional `onCancelEditing` callback. When present, the control starts in edit mode — the name input is shown immediately with no intermediate read-only label or second **Rename rate type…** button. The host's own **Rename…** menu item sets `rekeyOpen` and passes `onCancelEditing={() => setRekeyOpen(false)}`; the **Cancel** button in the control calls that callback rather than hiding itself. The **Cancel** button renders as an `outline` button, not a text link.

##### ConditionRekeyDialog display

`ConditionRekeyDialog` (`src/ConditionRekeyDialog.tsx`) renders the dry-run plan before the estimator confirms. Condition tokens are displayed as human-readable title-case labels via `conditionLabel(token)`, which delegates to `titleCaseToken` in `ratesShared.ts`. `titleCaseToken` replaces underscores and hyphens with spaces and capitalises each word, so `ui_audit_night` becomes **Ui Audit Night**.

The dialog title reads **Review rate type rename**. Condition tokens are referred to as "rate types" throughout the dialog copy; the term "condition token" does not appear in the UI. The stored token is not shown as "Stored as …" — the dialog uses only the human-readable label.

Dependent rows in the plan are labelled as follows:

* **Mutable rate options** — resolved to item name, supplier name, and location name using the `ConditionRekeyNames` maps. An unresolvable id falls back to a short reference (`Rate option {first 8 chars of id}…`) rather than a bare UUID.
* **Draft and published day-rate loadings** — described by kind (`per-unit loading` or `factor loading`), owner type (`revision` or `schedule line`), and status. No schedule or revision name is included because `ConditionRekeyNames` carries no day-rate schedule maps; showing a truncated database id would look like information without being actionable.
* **Collisions** — described by collision type. Rate-option uniqueness collisions name both the affected and conflicting options; pricing-rule uniqueness collisions use the same kind/owner wording as loading rows plus short id references.

#### Multiplier unit sync

When the basis is `multiplier`, a `useEffect` syncs `unit` and `conversionFactor` from the Standard option for the same owner and location:

```ts theme={null}
const unit = standard?.unit ?? values.unit;
const conversionFactor = formatNumberInput(standard?.conversionFactor);
```

`standard` is the option at the same scope where `condition === "standard"`, `status === "active"`, and `validTo == null`.

### RateOptionEntryRow

`RateOptionEntryRow` is rendered for anyone who can edit rates at the bottom of each expanded rate row's option list. It provides a quick-entry inline form with the same group-specific columns as the option grid:

* Owner — Location (`RateLocationPicker`, own prices first then suppliers alphabetically — see [Rate location picker](#rate-location-picker))
* Modifier (`Combobox` with existing condition tokens as suggestions while allowing custom values; defaults to `"standard"`)
* Unit (`UnitCombobox`; read-only for a multiplier)
* Conversion factor or density — shown only when the selected unit has a bespoke conversion (see `conversionFieldFor`); hidden otherwise
* Basis toggle (`NativeSelect` with **Fixed price** and **× Standard** options) — see [Basis toggle on the entry row](#basis-toggle-on-the-entry-row)
* Cost field with persistent adornment (`$` for fixed, `×` for multiplier)
* Supplier code (text input)
* **Add** button

Pressing Enter in any field submits the entry. Pressing Escape clears the form. After a successful create the row retains its owner and location selection for rapid price-list entry, clears the other values, and returns focus to the first field.

#### Basis toggle on the entry row

The entry row carries an explicit **Fixed price / × Standard** `NativeSelect` toggle. Groups that already render a **Priced as** column show the toggle there; groups without a **Priced as** column (bulk materials, disposal, cartage) show the toggle inside the cost cell, immediately before the amount input.

For groups that do not have a **Priced as** column, the cost input takes the slot immediately before it in the column layout (`blankSlotBeforeCost`), giving it enough width to be usable alongside the basis toggle. Without this, the toggle and the amount input both competed for the single 160 px cost slot and were too narrow to interact with reliably.

The amount input carries a persistent `$` adornment in fixed mode and `×` in multiplier mode. Its accessible name changes with the mode ("New option cost for …" vs "New option multiplier of standard for …"). A hint line below the entry row echoes what pressing Enter will store — for example `= $57.50/t` or `= 1.5 × the Standard price`.

A leading `×` typed directly into the cost box is refused, not reinterpreted. Change the basis using the toggle first.

The entry row receives `createdLocationSelection` from `RatesPage`. When a new location is created and the entry row's current scope matches the same owner, the entry row's scope is updated automatically to point at the new location.

### Rate location picker

The option entry row and editor use the shared `RateLocationPicker` from `@stralos-co-nz/ui-reference-data` (0.3.0 or later; `@stralos-co-nz/ui` removes its copy in 0.73.0). It is fed by `locationPickerData(locations, suppliers)` in `src/rateScope.ts`, which builds two lists from the live catalogue:

* `organisationLocations` — the organisation's own active locations, sorted by name.
* `suppliers` — active supplier companies (`isSupplier: true`), each carrying their own active locations.

The organisation's own prices appear first. Supplier groups follow alphabetically. **All locations** is offered as an equal, valid choice for both the organisation and every supplier — `locationId === null` is not "nothing selected"; it means "this price applies everywhere that owner has a presence".

The picker hangs two create links beneath the field (not inside the dropdown): one for adding a new location (`RateImportLocationDialog`) and one for adding a new supplier company (`RateImportSupplierDialog`). These are shown to anyone who can edit rates. Adding one of the company's own locations still needs an admin (`isCatalogAdmin`); a supplier's location does not.

### Owner and location labelling

Owner and place names are resolved from the live catalogue via `rateScope.ts`:

| Helper | Returns |
| - | - |
| `scopeOwnerLabel(scope, names)` | The organisation's current name for own prices; the supplier's name for supplier prices. Never blank — an unresolved supplier id returns **Unknown company**. |
| `scopePlaceLabel(scope, names)` | The location name when `locationId` is set; **All locations** when `locationId === null`. An unresolved id returns **Retired location**. |
| `scopeLabel(scope, names)` | `{Owner} — {Place}` combined label, or **No location recorded** for a null scope. |

The old `{StoredSourceName} (Internal)` suffix is gone. Own prices now display the organisation's live name directly, matching the way supplier prices display. The previous `sourceDisplayName` helper and the five label helpers it supported (`internalSourceLabel`, `sourceDisplayName`, `sourceWideLabel`, `addOwnLocationLabel`, `ownSourceGroupLabel`) are removed.

For the location editor adapter and its optional country setting, see [Location editor modal](#location-editor-modal).

### RateOptionRow

`RateOptionRow` renders one existing option in the expanded grid. Most cells are `EditableCell` wrappers — click to enter an inline input, Enter to save, Escape to cancel.

Editable fields inline for anyone who can edit rates are basis, multiplier, unit (for fixed-basis options), conversion factor (shown only when `conversionFieldFor` returns `editable` for the option's unit; read-only in `legacy` state, hidden otherwise), cost rate (for fixed-basis options), and supplier code. On an own price the supplier code cell is an empty placeholder with no editor. Condition uses the separate re-key control; it is never smuggled through an ordinary inline update. Read-only users see the same values without edit controls.

The condition cell renders the condition as its title-case label (via `titleCaseToken`). Below the label, users who can edit rates see a **Rename rate type…** button that opens `ConditionRekeyControl`. Non-standard options on the full option editor expose the same control via the **Modifier** field.

#### Priced from column

Every group's option grid includes a **Priced from** column (column id `validFrom`) that renders the option's `validFrom` date and a **Stale price** attention badge when the price is more than 12 calendar months old (`isStalePrice` in `ratesShared.ts`, constant `STALE_PRICE_MONTHS = 12`). The `RatePriceDate` component (`src/RatePriceDate.tsx`) renders both. The stored `needsReview` value is not displayed. A price priced exactly 12 months ago is not stale; the first day it becomes stale is the calendar day after the 12-month anniversary.

#### Rate item history

`RateItemHistorySideSheet` (`src/RateItemHistorySideSheet.tsx`) is a read-only `SideSheet` that opens when the reviewer selects **History** from an item's action menu (⋮). It fetches `GET /rate-items/:id/history` via `getRateItemHistory(rateItemId, { start: 0, length: 100 })` and renders all recorded audit events for the item and its options, newest first.

Each event shows the entity type (item or price option), the action (created, updated, or deleted), and the calendar date. Changed fields are listed as `OldValue → NewValue` using `AUDIT_FIELD_LABELS` for human-readable names. Unrecognised field keys fall back to the raw key name, because the audit log is additive, so a field the sheet does not yet label is still shown. Two filters apply before rendering: fields in `HIDDEN_AUDIT_FIELDS` (`needsReview`) are never listed, including in the restore confirmation, and `SUPPLIER_ITEM_FIELDS` (`sourceItemCode`, `sourceItemName`) are dropped for any option event whose `optionContext` has no supplier (`carriesSupplierItemFields`). An own-price event's context line likewise omits the supplier code.

Field values are formatted before display: money fields use `formatMoney`, date fields use `formatDate`, boolean fields render as `Yes` / `No`, and owner/location IDs are resolved to display names from the `locations` and `suppliers` props passed in from `RatesPage`. A `null` value renders as `—`.

The sheet has no footer and no form. Because there is nothing to write, it carries no dirty guard and no cancel confirmation.

**Restore button alignment.** Each event's **Restore** button is right-aligned within its row (`alignSelf="flex-end"`). This keeps the field-change list on the left and the action on the right, matching the layout convention used by the rest of the side sheet.

`setHistoryItem` in `RatesPage` holds the `{ id, name }` pair for the currently open history sheet. The sheet is conditionally rendered below the main content when `historyItem` is non-null. `onViewHistory` on `ItemActionsMenu` and `RateRowDetail` calls `setHistoryItem({ id: rate.item.id, name: rate.item.name })`.

**Contractor attribution.** `rateHistoryAttribution` (`src/rateHistoryAttribution.ts`) builds a line under an event's heading from `actorOrgName`, `source` and `changedByName`. The line carries `data-testid="rate-history-attribution"`. It names the contractor company with the name stored on the event, so it still reads after the link ends:

| Event | Line shown |
| - | - |
| `source: "copy"` | `Copied from {contractor}`, or `Copied` with no contractor name |
| `source: "refresh"` | `Refreshed from {contractor}`, with ` by {person}` when a person's name is present |
| `source: "manual"` with a contractor name | `Changed by {person} ({contractor})`, or `Changed by someone from {contractor}` |
| `source: "manual"` with no contractor name | No line: changes by the company's own people are not labelled |

`changedByName` is optional on the client type and api-rates does not currently send it, so the person-name variants appear only once it does.

**Changed by filter.** Behind the contractor-companies flag (`useContractorCompaniesFlag`), the sheet shows a **Changed by** `NativeSelect` with **Changed by: Anyone** (no `actor` sent), **Changed by: Our people** (`actor=own`) and one **Changed by: {contractorName}** entry per contractor company (`actor=<contractorOrgId>`). `getOrgContractors()` reads the `contractors` list from `GET /api/global/org/context`; any failure gives an empty list, leaving only the first two entries. With the flag off, the filter is hidden and the list is not fetched.

**Show filter.** A **Show** `NativeSelect` offers **Show: All changes** and **Show: Deleted**. The deleted view filters in the browser over the pages already read; no query parameter is sent to api-rates. It keeps deleted rate item events and retired prices (a rate option `updated` event whose status went to `inactive`). While older pages are unread, it shows "Showing deleted rates from the history loaded so far. Load more to see older ones." with a **Load more** button. The per-item sheet shows its filters too.

### Ellipsis convention on menu items

Menu items that open a dialog or sheet carry a trailing `…` in their label. Items that perform an immediate action or open a read-only panel do not. In this package:

* `Edit…`, `Edit details…`, `Add rate…`: each opens a dialog or side sheet that asks for input.
* `Retire`, `Delete`: act at once behind Undo, so no ellipsis.
* `History`: opens a read-only side sheet; no input is requested, so no ellipsis.

#### No price set badge

When a fixed-basis option's cost is a stored zero (`costRate === 0`), the option row shows a **No price set** badge (`StatusBadge domain="attention" value="unpriced"`) in place of a formatted cost. A stored zero with a tiered pricing rule, a positive loading (`fromUnitPrice > 0`), or a positive minimum charge (`minimumAmount > 0`) is not missing — `isOptionMissingPrice` in `ratePriceRows.ts` requires all three conditions to be absent before treating zero as unpriced. A `null` cost keeps its dash; a non-zero cost renders normally.

#### Schedule/Basis cell and immediate cost display

`pricingRuleGridDisplay` in `src/RateRows.tsx` computes what the **Schedule/Basis** cell renders for a given `PricedRateOption`. It returns `scheduleBasis`, `minimumCharge`, `cost`, and `hasSchedulePopover`.

The `cost` field uses `option.pricing.fromUnitPrice` as its primary source. When `fromUnitPrice` is `null` and all pricing metadata fields are empty — `ruleKind` is `null`, `rules` is empty, `modifiers` is empty, `minimumQuantity` is `null`, and `minimumAmount` is `null` — the function falls back to `effectiveCost(option)` (`derivedCostRate ?? costRate`). This fallback exists specifically for newly created options: the create response carries the saved `costRate` alongside empty pricing metadata, so the cost appears in the grid immediately after creation rather than waiting for a summary refresh.

If any pricing metadata field is populated, `fromUnitPrice` is used exclusively and the stored `costRate` is not shown as a fallback. This prevents a stored scalar from incorrectly overriding a price that is determined by a rule, loading, or minimum.

#### Equivalent column (quarry materials)

Quarry-materials option rows include an **Equivalent** column that shows the same price converted to the other unit — for example, `= $93.00/m³ (at 1.55 t/m³)` alongside a per-tonne price. The column is headed **Equivalent** and states the relationship, muted.

The column was previously headed **Also costs**, which read as an additional charge rather than a unit conversion. `equivalentCostLabel` derives the converted figure from `effectiveCost`, `conversionFactor`, and the option's unit orientation (tonnes-per-cubic-metre). The function returns `"—"` when any input is absent or the conversion would be excluded.
The supplier's item name (`sourceItemName`) is displayed as a secondary line on a supplier-priced option row when it differs from the catalogue item's name. Own prices never show it, because the whole supplier code cell is empty for them (`carriesSupplierItemFields` in `rateScope.ts`). When the stored supplier name matches the catalogue item name (case-insensitive), the row shows nothing for it, because the value would restate what is already visible. `supplierItemNameToShow(sourceItemName, itemName)` in `ratesShared.ts` applies this rule for both the expanded rate grid and the import review table.

The inline unit cell uses `UnitEditableCell`, a `UnitCombobox` (category-scoped picker) rather than a free-text input. This prevents offering units the category does not allow, so an off-category unit cannot be entered from the picker at all. A refusal from the API is translated into a specific error sentence (e.g. "That unit is not allowed for this item's category. Choose one of the listed units.") rather than the generic 422 fallback.

An overflow menu (`⋮`) on each row provides **Edit details** for every user and **Retire** for anyone who can edit rates.

### Basis switching

Switching basis on a `RateOptionRow` immediately sends an update. When switching from `multiplier` to `fixed`, `RatesPage` first re-reads the option and uses the server's current `derivedCostRate` (falling back to `costRate`) rather than posting a value derived from a stale list response. If the re-read or validation fails, the row shows the error and does not guess.

### Option write authority

`applyOptionWrite` in `RatesPage` is the authoritative state update after any option mutation:

```ts theme={null}
const applyOptionWrite = (rateItemId: string, result: RateOptionUpdateResponse) => {
  // Increments the per-item request counter to invalidate any in-flight GET.
  // Updates the item's option map from result and result.changedDependants.
  // An option is kept only if status === "active" and validTo == null.
};
```

`RateOptionUpdateResponse` is `PricedRateOption & { changedDependants?: PricedRateOption[] }`. The `changedDependants` array carries any sibling options whose `derivedCostRate` was affected by the write — for example, a multiplier option whose Standard changed. These dependants are applied to the local map along with the primary result.

After applying the option write, `RatesPage` calls `refreshSummary()` (re-fetches the rate summaries list) to update the aggregate cost column. A failed refresh after a successful mutation is advisory — the authoritative write response remains visible.

### Retire workflow

Retiring is open to anyone who can edit rates. Both entry points, the row overflow menu and the full editor's **Retire** button, call `retireOption` in `useRateOptionMachine`. It closes the option editor if that option is open, hides the option from every list the host renders, and runs `undoAction.run` with the label `"<item> · <place> retired. You can also bring it back later from History."`. The commit calls `retireRateOption(option)` (sets `status: "inactive"` and `validTo` via `upsertRateOption`), then `applyOptionWrite` and `refreshSummary()`. A failed commit shows the option again with a **Rate option not retired** toast. Neither entry point saves an unsaved editor draft first.

**C23 second lock**: `retireOption` also checks `canEditCatalog` before calling the API. If a read-only user reaches the handler (which is unreachable from the normal UI), it posts a `toaster.error` when no editor is open, or sets the editor error banner when the editor is open. This defence-in-depth guard is exercised directly by `RatesPageCatalogGuards.test.tsx`.

### Rate item history side sheet

`RateItemHistorySideSheet` (`src/RateItemHistorySideSheet.tsx`) opens from the rate item row overflow menu. It lists audit events for the item and its price options in reverse chronological order.

**Footer.** `RateItemHistorySideSheet` renders a read-only footer (`actions={{ readOnly: true, cancelLabel: "Close" }}`). The **Close** button dismisses the sheet from the footer; the × corner button remains available.

**Empty state.** When the event list is empty, the sheet renders a bordered `EmptyState` card (matching the empty-state shape used by the calculator and lump-sum sheets) rather than bare text. Changes made before history recording was switched on in an environment were not captured. The empty state reads:

> **No recorded changes yet.**
> Edits to this item and its prices appear here from now on. Changes made before history was switched on were not recorded.

No specific date is shown in the empty state because the recording-start date varies per environment.

**Restore button alignment.** The **Restore** button for each event is right-aligned (`alignSelf="flex-end"`) so it sits flush to the right edge of the event row. Unchanged events (action `"created"`) never show a restore button.

### Option field validation

`collectOptionFieldErrors` in `ratesShared.ts` returns a map of field-level errors:

| Rule | Error message |
| - | - |
| `scope` is `null` (no owner and location chosen) | `"Choose a location."` |
| `sourceItemName` trimmed length exceeds 200 characters | `"Keep the supplier's item name to 200 characters or fewer."` |
| Blank condition | `"Modifier is required."` |
| Condition does not match `^[a-z0-9]+([ _-][a-z0-9]+)*$` | `"Use letters, numbers, spaces, hyphens or underscores."` |
| Basis is fixed and unit not in `allowedUnitCodes` | `"Choose a unit allowed by this category."` |
| Basis is fixed and cost rate missing, negative, > 1,000,000,000, or the `MoneyInput` contains text that `parseMoneyInput` cannot parse (e.g. `"-50"`, `"1,5"`, `"abc"`) | `"Enter a valid cost."` |
| Basis is multiplier and condition is `"standard"` | `"Standard cannot use a multiplier basis."` |
| Basis is multiplier and multiplier ≤ 0 or > 1,000 or blank | `"Enter a multiplier greater than 0."` |
| Basis is fixed, unit has a bespoke conversion (`conversionFieldFor` returns `editable`), and `conversionFactor` is absent | `"A conversion factor is required."` |
| Basis is fixed and `conversionFactor` present (for a bespoke-conversion unit) but ≤ 0 or > 1,000,000 | `"Enter a conversion factor greater than 0."` |
| Basis is fixed and `conversionFactor` non-empty on a disposal category | `"Disposal conversion comes from the substance."` |

### Payload construction for options

`buildRateOptionPayload` in `ratesShared.ts` produces `UpsertRateOptionInput`:

* `condition` is trimmed and lowercased.
* `basis` drives the nulling of the opposing money field: `costRate` is `null` for multiplier; `basisMultiplier` is `null` for fixed.
* A must-pick option (`pricedByRateTable` or `pricedByStep`) is saved with `costRate: ""`, which serialises as `null`, because api-rates refuses a cost beside a rate table or steps.
* `validFrom` and `validTo` preserve the original full ISO timestamp when the date portion has not changed, and produce a UTC midnight ISO string when the date has been edited.
* `scope`, `supplierId`, and `locationId` are written from `values.scope`; a `null` scope falls back to `ORGANISATION_WIDE`. `locationId === null` means All locations and is sent as-is — it is not coerced to an empty string.
* `sourceItemCode` and `sourceItemName` are trimmed, and an empty string becomes `null`. When `carriesSupplierItemFields(values.scope)` is false (an own price), both are sent as `null` whatever the editor holds, so an option saved before the fields were hidden does not re-send an invisible value. Every PUT path (inline cell save, basis change, retire) starts from the served option so an edit to an unrelated field does not clear the stored supplier name.
* `notes` is trimmed; empty string becomes `null`.

### Conversion field state

`conversionFieldFor(rateGroup, unit, storedFactor)` in `ratesShared.ts` determines whether a rate option's conversion field is shown and how. It is called from the option editor modal, the inline grid cell, and the entry row. The same function is used in all three contexts so the editor, grid, and entry row always agree with the domain's `rateOptionConversionKind`.

| Return kind | When | Rendered as |
| - | - | - |
| `editable` | The unit has a bespoke conversion (domain's `rateOptionConversionKind` returns a kind) | Editable field; label is the kind's own name: "Density (t/m³)", "Metres per length", "Metres per roll", "m² per roll", or "m² per sheet" |
| `legacy` | A conversion factor is stored, but the unit no longer has a conversion (the retired metre ↔ each family) | Read-only field; helper text "This unit no longer converts. The saved factor is kept and not used in pricing." The factor is saved back unchanged. |
| `substance` | `rateGroup === "disposal"` | Read-only label "— via substance" |
| `editable` (working days) | Unit is `week` or `month` and the group is not `quarry_materials` or `disposal` | Editable field labelled "Days per week" or "Days per month", with the helper "Leave blank to use the project default, then the company default." Validation allows more than 0 and no more than 7 or 31 days, with up to 2 decimals. |
| `hidden` | All other cases (no bespoke conversion and no stored factor) | Field not rendered |

`withUnit` in `ratesShared.ts` governs what happens when the unit changes. Moving to or from `week` or `month` (outside `quarry_materials`) always clears the factor, because days per period and a physical conversion are different quantities. Moving from a unit in the `editable` state to one in any other state clears the stored factor, because the field disappears and a hidden value is never saved. A factor already in the `legacy` state is preserved regardless of unit change.

## Location editor modal

`LocationEditorModal`, exported from `RateReferenceModals.tsx`, is `LocationCreationSheet` in `src/RateCreationSheets.tsx`. The sheet wraps `LocationPicker` from `@stralos-co-nz/maps-ui` for address search and the map, and passes the feature's geocoding configuration (`geocodeConfig()`). The Rates page's create link beneath the location picker and the import review screen's `RateImportLocationDialog` both use it.

### Address and coordinate requirement

A location needs an address and the coordinates that go with it, because distances are worked out from them. Only a pick from the address search or a dropped pin sets coordinates, so typed text alone is not enough.

The sheet's save action is disabled until `hasPickedCoordinates(values)` is true and the address is not blank. Its disabled reason is one of two sentences from `src/locationAddress.ts`, which match api-global's own refusals word for word:

* `LOCATION_ADDRESS_MISSING`: "Please add the site's address so we can work out distances."
* `LOCATION_COORDINATES_MISSING` (an address with no coordinates): "Please choose the address from the list so we can find it on the map."

`@stralos-co-nz/maps-ui` sets a dropped pin's coordinates at once and looks up its address a moment later, so the save action also waits for the address. `submit` repeats the checks as a backstop.

`locationAddressRequiredMessage(error)` returns the server's sentence for a `422 location_address_required`, and `null` for anything else. It is the one 422 in the location flow whose body is safe to show, because it describes only the caller's own draft; every other error still goes through `safeApiMessage`.

In location pickers, an active location with no stored address shows `NO_ADDRESS_LABEL` ("No address for this site") as its sublabel. These are places saved before the requirement.

### Google Maps configuration

`geocodeConfig()` in `src/geocode.ts` reads the optional `VITE_STRALOS_GEOCODE_COUNTRY` shell environment variable and returns it as the `countryCodes` prop for `LocationPicker`. This restricts address suggestions to the specified country codes. When the variable is absent, the picker uses its own default.

```ts theme={null}
// src/geocode.ts
export type GeocodeConfig = { countryCodes?: string };
export function geocodeConfig(): GeocodeConfig {
  return {
    countryCodes: import.meta.env?.VITE_STRALOS_GEOCODE_COUNTRY,
  };
}
```

`@stralos-co-nz/maps-ui` uses Google Maps for address autocomplete, not the Nominatim-based picker exported by `@stralos-co-nz/ui`. The shell variable `VITE_STRALOS_GEOCODE_URL` (used by the Nominatim picker) has no effect on the rates location editor. Set `VITE_STRALOS_GEOCODE_COUNTRY` to restrict suggestions to a specific country — for example `nz` for New Zealand only.

## Day-rate schedule export

`DayRateExportDialog` in `DayRateExport.tsx` exposes two export options for a day-rate schedule revision: an Excel workbook download and a browser print layout.

### Export dialog: superseded and draft warnings

When the revision being exported is superseded, the dialog shows a warning:

> This revision is superseded — it applied {effectiveFrom} to {effectiveTo}. The print layout is marked Superseded; the Excel workbook is not, so check before you send either to a client.

When `effectiveTo` is absent (the revision is no longer in force but no end date was stored), the sentence reads "no longer in force" without dates. When the revision is a draft, a parallel note states that the revision has not been issued.

`revisionStateLabels` maps `current → "Current"`, `superseded → "Superseded"`, and `draft → "Draft"` for use in the dialog and in the print layout.

### Print layout: superseded/draft notice

When the revision is not current, `DayRatePrintDocument` renders a bordered notice (`role="note"`) above the schedule title. The notice is marked `print-color-adjust: exact` so it survives saving to PDF. On screen the notice uses amber styling (matching the "out of force" convention used elsewhere in the product). On paper (via `@media print`) it falls back to a neutral rule (`background: gray-100; border-color: gray-900`) that survives greyscale printing — the CSS class is `revision-notice`.

| Revision status | Notice heading | Notice body |
| - | - | - |
| `superseded` with `effectiveTo` | **Superseded** | "This revision applied {from} to {to} and is no longer in force." |
| `superseded` without `effectiveTo` | **Superseded** | "This revision is no longer in force." |
| `draft` | **Draft** | "This revision has not been issued." |
| `current` | (no notice) | — |

The notice is also reflected in the **Revision** row of the schedule details: current revisions show `Rev N · Current`; superseded and draft revisions show `Rev N · {State}` in bold. The PDF file name default and the export dialog title include the state label for non-current revisions.

### Print layout: footer format

The print footer reads `Generated {date} · Rev {number}`. The date is cut in the organisation's time zone, matching the date the Excel export of the same revision shows. The print page waits for the organisation-settings zone read (`loadOrganisationTimeZone`) before rendering; if that read fails, it falls back to the zone already held in memory (defaulting to `Pacific/Auckland`). The revision UUID that previously appeared in the footer is removed. A **Back to Day rates** link is included in the print layout but is excluded from the printed output via CSS (`@media print`).

### Shared export endpoint

Both formats POST to the same endpoint:

```text theme={null}
POST /api/rates/day-rate-schedule-revisions/:revisionId/export
```

The `format` field in the JSON body selects the response type:

| `format` | Response |
| - | - |
| `pdf` | `application/json` — `DayRateExportPayload` |
| `xlsx` | `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` — binary workbook |

### XLSX download

`downloadDayRateRevisionXlsx` in `api.ts` handles the binary download path. It sends an explicit `Accept` header to negotiate the XLSX media type:

```text theme={null}
Accept: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Content-Type: application/json
```

The function validates the response `Content-Type` before reading the body as a blob. If the server returns any other media type — for example `application/json` — the function throws with the message `The rates API returned an unexpected export content type.`

### Filename sanitisation

The download filename comes from the `Content-Disposition` response header. The function prefers the RFC 5987 extended form (`filename*=UTF-8''...`) over the plain quoted form (`filename="..."`). After decoding, characters in the ranges `\u0000–\u001f`, `\u007f`, `/`, and `\` are replaced with `-`. The result is guaranteed to end with `.xlsx`.

### Print layout

`getDayRatePrintPayload` fetches the same endpoint with `{ format: "pdf" }` and returns a `DayRateExportPayload`. The payload is runtime-validated by `parseDayRateExportPayload` before being passed to `DayRatePrintDocument`. Validation rejects:

* Schema versions other than `"1"`.
* Lines containing cost or margin keys (`expectedCost`, `marginPct`, `internalCost`) — these must not appear in the issued schedule.
* Flat or fixed-rate lines where `rate` is `null` or exceeds `999,999,999,999.99`.
* Invalid calendar dates for `effectiveFrom` and `effectiveTo`.

**Rate cell formatting.** Each line's rate is formatted in the print layout using `formatCurrencyAmount({ value: line.rate })` — the same formatter used on the rates list, the rebase review, and the export dialog. When the server's `rate` field is `null` (cost-plus lines), the layout falls back to `line.rateText`. The currency is named once in the table heading rather than per-cell. Previously `rateText` was used unconditionally, which rendered flat rates as `NZD 65.00` — a form that disagrees with every other surface in the product.

`DayRatePrintPage` renders the print layout at `/rates/day-rates/revisions/:revisionId/print`.

## API error handling

`src/apiErrorMessage.ts` is the single translator for every user-facing API failure in `stralos-feature-rates`. Every screen passes thrown errors through `safeApiMessage` before showing them. The module has two jobs:

1. A recognised HTTP status is replaced by a curated sentence. The server's body is discarded deliberately — api-rates echoes identifiers (schedule, revision, conflicting row) that can belong to another organisation, so rendering the raw body would leak them into the UI.
2. Everything else keeps its own words with transport noise (status codes, API URLs) stripped. Local validation messages carry no status and pass through untouched.

### safeApiMessage

`safeApiMessage(error, fallback, statusMessages?)` translates any thrown value into one user-facing sentence.

Default status messages:

| Status | Message |
| - | - |
| `400` | Some submitted values are not valid. Review them and try again. |
| `401` | Your sign-in has expired. Sign in again and retry. |
| `403` | You do not have permission to change this. |
| `404` | That record is no longer available. Refresh and try again. |
| `409` | Someone else changed this first. Refresh and try again. |
| `422` | Some submitted values are no longer valid. Review them and try again. |
| `429` | Too many requests at once. Wait a moment and try again. |
| 5xx | The service is unavailable right now. Try again shortly. |
| Unrecognised 4xx | That request could not be completed. Check the values and try again. |

Callers can override any status with a `statusMessages` map. The duplicate rate option path overrides `409` with a message naming the specific conflict (see [Duplicate rate option error](#duplicate-rate-option-error)).

**Browser network failures**: a browser `TypeError` (for example "Failed to fetch" or "Load failed") carries no HTTP status. These errors are engine jargon that mean nothing to a user. `safeApiMessage` recognises a `TypeError` and returns the caller's `fallback` sentence instead of passing the engine's words through to the screen.

**Server reference**: api-common includes a `reference` field on every 500 and 503 response body. `safeApiMessage` appends `Reference: <id>` to the translated sentence when the parsed error body carries one. This is the one identifier a person can quote to support — unlike the rest of the body, it names nothing in anyone's data. Example output: `The service is unavailable right now. Try again shortly. Reference: req-7f3a`

### apiMessageWithServerDetail

`apiMessageWithServerDetail(error, fallback, statusMessages?)` is the deliberate opposite of `safeApiMessage` — it uses the server's own words rather than the curated sentence. Use this only where the body describes the caller's own submission (every import failure: a duplicate code in their spreadsheet, a unit their category forbids). Session failures (401) still use the curated sentence regardless.

### Busy retry

`src/busyRetry.ts` provides `fetchHonouringRetryAfter`, which wraps any authorised fetch call and retries it once when the server asks.

**Rule**: a GET answered with `503` and a `Retry-After` header whose value is a whole-number of seconds ≤ 10 is retried once after that delay. Writes (POST, PUT, PATCH, DELETE) are never retried — a 503 does not confirm whether the write executed.

A `Retry-After` that is absent, non-numeric, or longer than 10 seconds is not honoured — the 503 is surfaced immediately. The cap of 10 seconds reflects the observed api-common pattern of answering `Retry-After: 5` during connection-pool bursts; a longer value indicates a page the person has already abandoned.

The retry is transparent to the user. Both API clients in the feature (`api.ts` and `pricingRulesApi.ts`) wrap their fetch calls with `fetchHonouringRetryAfter` so the retry behaviour applies everywhere reads are made.

### Workspace revalidation event

`src/projectRequestEvents.ts` implements the browser-level contract between the rates feature transport and the workspace remote. `authedFetch` passes responses to `observeProjectRequest`, which dispatches a `stralos:project-request` `CustomEvent` for a successful GET or HEAD, a successful non-OPTIONS mutation, or a `403`/`404` response. Other outcomes do not emit an event.

The event detail shape is fixed at version 1:

```ts theme={null}
{ version: 1, path: string, kind: "read" | "edited" | "refused" }
```

`kind` maps from the response outcome:

| `kind` | When dispatched |
| - | - |
| `"read"` | Response is `ok` and the method is `GET` or `HEAD`. |
| `"edited"` | Response is `ok` and the method is a write (`POST`, `PUT`, `PATCH`, `DELETE`). |
| `"refused"` | Response status is `403` or `404`, regardless of method. |

The event is not dispatched for other failure codes (5xx, `429`, `401`). A `null` kind is computed and silently discarded.

The workspace remote owns revalidation. Feature transports report outcomes only — they carry no response payload, bearer token, or project data in the event. The workspace listens for this event and decides whether to revalidate its project state. Successful reads count: an RLS-filtered list can return `200` with no rows after a membership change, so a `"read"` outcome can also signal that the workspace needs to refresh.

The event is not dispatched outside a browser context (no `window`) or when the pathname has changed between the request being initiated and the response arriving, preventing stale revalidation signals from in-flight requests made on a previous route.

## API types

`RateItemView` includes the direct category reference and its cached display values:

| Field | Type | Meaning |
| - | - | - |
| `categoryId` | `string \| null \| undefined` | `null` = unresolved reference; `undefined` = legacy row with no reference at all. |
| `category` | `string` | Category display name cached on the rate item. |
| `rateGroup` | `string \| null \| undefined` | Server-derived group token. `null` means unresolved. |

`RateCategoryDefinition` is the picker row shape:

```ts theme={null}
type RateCategoryDefinition = {
  id: string;
  name: string;
  parentGroup: RateGroup;
  isCartageVehicle: boolean;
  allowedUnitCodes: string[];
};
```

`UpsertRateCategoryInput` mirrors `RateCategoryDefinition` for writes and includes `allowedUnitCodes` as a required field:

```ts theme={null}
type UpsertRateCategoryInput = {
  id?: string;
  name: string;
  parentGroup: RateGroup;
  isCartageVehicle?: boolean;
  allowedUnitCodes: string[];
};
```

`UpsertRateOptionInput` is the write shape for rate options:

```ts theme={null}
type UpsertRateOptionInput = {
  id?: string;
  rateItemId: string;
  scope: "organisation" | "supplier";  // "organisation" for own prices, "supplier" for a third party
  supplierId?: string | null;          // required when scope === "supplier"; null for own prices
  locationId?: string | null;          // null means All locations (a valid, equal choice)
  sourceItemCode?: string | null;
  sourceItemName?: string | null;   // supplier's own description; null clears the stored value
  condition: string;
  basis: RateOptionBasis;           // "fixed" | "multiplier"
  basisMultiplier?: number | null;
  unit: string;
  conversionFactor?: number | null;
  costRate?: number | null;
  currency?: "NZD";
  provenance?: RateOptionProvenance;
  validFrom?: string;
  validTo?: string | null;
  status?: "active" | "inactive";
  notes?: string | null;
  needsReview?: boolean;
};
```

`RateOptionUpdateResponse` extends `PricedRateOption` with an optional `changedDependants` array:

```ts theme={null}
type RateOptionUpdateResponse = PricedRateOption & { changedDependants?: PricedRateOption[] };
```

`listAllRateCategories` fetches `/api/global/rate-categories` to exhaustion and throws rather than returning a truncated result.

## RateEditorSheet package

`RateEditorSheet` (`src/rateEditor/`) is the Rates page's own editor for a host outside this remote, so a build-up line can become a rate item without leaving the estimate. It is published to GitHub Packages as `@stralos-co-nz/feature-rates`, imported from `@stralos-co-nz/feature-rates/rate-editor`, not as a federation remote: no remote loads another remote. feature-estimate pins `0.3.0` and opens it from a build-up line's **Add rate** and from the save-to-library panel's **Add rate** (`src/createRateFromLine.tsx`).

### Flow

* Step 1, **Add a rate from this line**: the rate item. The name is prefilled from the line, and the reader picks a category. Admins can add a category; members see "Please ask your admin to add a new category.". When the host passes `project`, the sheet asks **Where should this rate go?** with **Rates library** and **This project only**.
* Step 2: the full option editor, with supplier and location create steps, the category's units, and the prefilled unit and cost.
* The new item is a draft (`DRAFT_ITEM_ID`) until its price is saved, so **Cancel** writes nothing and a half-finished item never reaches the library.
* **Rates library** writes the item and option through the Rates page's usual writes, including advanced pricing.
* **This project only** writes one `POST /api/rates/project-rates/project-only` call: always fixed basis, with no advanced pricing and no validity dates.

`prefillOptionValues` keeps the line's unit only when the selected category allows it; otherwise the category's first unit stands and the reader picks. The cost is the line's unit cost, ex GST.

### Props

| Prop | Type | Description |
| - | - | - |
| `prefill` | `RateEditorPrefill` | `{ name, unit, unitCost }` from the build-up line. `unitCost: null` leaves the cost empty. |
| `initialMode` | `RateEditorMode` | Optional, `"library"` or `"project"`. |
| `project` | `{ projectId: string; estimateId?: string }` | Optional. Without it, the library is the only choice and the mode question is hidden. |
| `onSaved` | `(rate: SavedRate) => void` | Called once, after every write has landed. The sheet then closes through `onClose`. |
| `onClose` | `() => void` | The sheet is done (saved, cancelled or closed); the host unmounts it. |

`SavedRate` carries `mode`, `rateItemId`, `rateOptionId`, `name`, `unit`, `costRate` (null for an option priced by step or rate table), `supplierId` and `locationId`.

### Publishing

`scripts/pack-rate-editor.mjs` generates the published manifest from `rate-editor.package.json`. Every shared library (`@stralos-co-nz/base`, `domain`, `maps-ui`, `pricing`, `ui`, `ui-reference-data`, `react`, `react-dom`) is a peer, so the host's copy is the one used, and the pack fails if any bare import in the entry's closure is not a peer. `.github/workflows/publish-rate-editor.yml` publishes on a `rate-editor-v*` tag, kept apart from the remote's own `v*` release tags, and checks that the tag matches the manifest version.


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