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 insrc/api.ts read the role from the shell’s stored user. api-rates and api-global enforce the same rules.
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 sharedDataTable 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 byGRID_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 themd 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 declarednumeric: 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
costRateis 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
costRatebut its options include stepped options, the cell shows the steps instead: one stepped option reads3 steps · $250.00–$900.00(formatSteppedSummary), and several readPriced by step · $250.00–$900.00. A step is a lump sum, so it never contributes to the per-unit range. - When
costRateis present andmin === max, the cell shows the single price with its unit suffix ($57.50/t). - When
costRateis present andmin !== 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 anHStack; “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’smodeUnit— the two columns (UnitandCost ($)) 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
minandmaxare 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 useDataTable’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 (keycompanyLocation, 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.
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)
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.
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:
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.
sortByis forwarded tolistRateSummaries, so the virtual list pages in api-rates’sortBy=companyLocationorder (see Rate summaries). - Rates sorts client-side because every price is already loaded.
visiblePriceRowsappliessortRatePriceRowsByCompanyLocation(filtered, sortDir)whensortByiscompanyLocation; 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.
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
similarRatesprop is set, the item is new (values.idis absent) and the reader can edit the catalogue. The Rates page passes{}(library only).RateEditorSheetpasses{ projectId, estimateId? }when its host names a project, so the project’s own rates are searched too, whichever mode is chosen. useSimilarRates(name, scope)insrc/similarRatesWarning.tsxwaitsSIMILAR_RATES_DEBOUNCE_MS(400 ms), then callsfindSimilarRateItems, which readsGET /api/rates/rate-items/similar. 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.SimilarRatesWarningrenders 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: .”; several read “These rates may already be here:” with a list. Text uses thestatus.warning.fgtoken.similarRateLabel(match)formats each entry as the name, then its companies (up to three, otherwise ” and N more”), “your own rate” when only the organisation prices it, and “this project” for a project rate. For exampleAP40 Basecourse (Example Quarries, this project).
@stralos-co-nz/feature-rates 0.3.0.
Duplicate rate option error
A409 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:
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 UUIDlabel— the category namegroup— the parentRateGroupdisplay label fromRATE_GROUP_LABELS
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:
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
categoryIdchosen →"Choose a category." categoryIdset but not in the catalogue →"This category no longer exists. Choose another."
collectOptionFieldErrors — see 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 inCategoryEditorModal 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:
createSupplierBusinessupdateRateSourceretireRateSourcelistSupplierscreateRateSource
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”).
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
PageHeaderwith the item’s name, status badge, and a back link to/rates(usingrender, nothref, 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 byuseRateOptionMachine. 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:
The hook accepts five host-specific inputs:
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
PageHeaderwith the revision’snameSnapshot, status badge, identity facts, and a back link to/rates?view=day-rates. DayRateRevisionWorkspace— the same component the panel’s expand row mounts, keyed byrevision.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 toDayRateRebaseReviewPage. Rendered only whenschedule.needsBaseReviewis 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:
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 usingformatRateGroupLabel(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 fromrateGroup; neither RateItemEditorValues nor the write payload carries an isMaterial field.
Removed rate item fields
The Measured in and Code inputs are absent fromRateEditorModal. 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 storedcategoryId 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." categoryIdset but not in the catalogue →"This category no longer exists. Choose another."
Edit category affordance
Edit category is available when an admin has a category selected. The callback receives the selectedRateCategoryDefinition — 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 inRateRows 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:
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.
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 aroundSharedCategoryEditorModalfrom@stralos-co-nz/ui-reference-data, shown as a side sheet. Wraps API errors throughsafeMessageTextso internal identifiers cannot appear in the UI.LocationEditorModal— re-export ofLocationCreationSheetfromsrc/RateCreationSheets.tsx. The sheet rendersLocationPickerfrom@stralos-co-nz/maps-uiand passes the Google MapscountryCodessetting fromgeocodeConfig().@stralos-co-nz/maps-ui0.10.0 removed its ownLocationEditorModal.SupplierEditorModal— re-export ofSupplierCreationSheetfromsrc/RateCreationSheets.tsx.
LocationEditorModal — supplier picker
The Rates Library passes ownership intoLocationEditorModal 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 acceptselectNameOnOpen?: 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 showsbulkingPercent 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:
categoryIdis sent as the sole classification input;categoryandrateGroupare not sent.codeandunitare 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:
bulkingPercentandcompactionPercentunderquarry_materials;maxTonnesandmaxCubicMetresunder a cartage-vehicle category. A field the category does not license is a422— unless the same write also changes the category, in which case the server clears it and returns the cleared names inclearedFields. The retiredisMaterial,materialPricingUnit, andisDisposalfields are not written.
Rate options
Rate options record the per-owner, per-location, per-condition pricing for a rate item. Each option carries ascope (own prices or a specific supplier), an optional location, and a condition.
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
SelectComboboxat 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) opensRateEditorModalswapped 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.
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 createPOST, because the option has no cost and cannot exist without them. - Any other option: the
POSTcarries no rules. The machine then reads the new owner’s version and writes the rules withreplacePricingRules. 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.
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. Therequiredmark is shown only when creating a new option (whenvalues.idis 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 === nullis not an absent answer. - Modifier: a
ConditionRekeyControlfor non-Standard options. The Standard option’s condition is fixed. - Priced as:
SelectComboboxbetween a set price and a multiplier of Standard. These modifier fields are hidden for the Standard option itself. - Priced unit:
SelectComboboxfiltered tocategory.allowedUnitCodes. Disabled when basis ismultiplier— 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); orhidden(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 ismultiplier. - Cost rate: shown when basis is
fixed. The field is aMoneyInputwith a$prefix and a/unitsuffix that follows the selected Priced unit (for example, the label readsCost rate (NZD per t)when the unit is tonnes). It accepts comma-formatted input (1,234.5saves as1234.5) and rounds to 2 decimal places on blur, matching the API’s 2-dp write rule. Invalid text (negative values, malformed numbers such as1,5) is detected viaparseMoneyInputand 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 readscurrentOption.derivedCostRate. - Valid from / Valid until:
DateFieldcomponents 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 thed MMM yyyyformat 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.withScopeclears 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.
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 throughConditionRekeyControl, 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
ConditionRekeyNamesmaps. 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 loadingorfactor loading), owner type (revisionorschedule line), and status. No schedule or revision name is included becauseConditionRekeyNamescarries 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 ismultiplier, a useEffect syncs unit and conversionFactor from the Standard option for the same owner and location:
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) - Modifier (
Comboboxwith 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 (
NativeSelectwith Fixed price and × Standard options) — see Basis toggle on the entry row - Cost field with persistent adornment (
$for fixed,×for multiplier) - Supplier code (text input)
- Add button
Basis toggle on the entry row
The entry row carries an explicit Fixed price / × StandardNativeSelect 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 sharedRateLocationPicker 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.
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 viarateScope.ts:
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.
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 idvalidFrom) 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:
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: 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 aRateOptionRow 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:
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, callretireOption 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:
Payload construction for options
buildRateOptionPayload in ratesShared.ts produces UpsertRateOptionInput:
conditionis trimmed and lowercased.basisdrives the nulling of the opposing money field:costRateisnullfor multiplier;basisMultiplierisnullfor fixed.- A must-pick option (
pricedByRateTableorpricedByStep) is saved withcostRate: "", which serialises asnull, because api-rates refuses a cost beside a rate table or steps. validFromandvalidTopreserve 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, andlocationIdare written fromvalues.scope; anullscope falls back toORGANISATION_WIDE.locationId === nullmeans All locations and is sent as-is — it is not coerced to an empty string.sourceItemCodeandsourceItemNameare trimmed, and an empty string becomesnull. WhencarriesSupplierItemFields(values.scope)is false (an own price), both are sent asnullwhatever 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.notesis trimmed; empty string becomesnull.
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.
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 untilhasPickedCoordinates(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.
@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 to . 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.
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 readsGenerated {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:format field in the JSON body selects the response type:
XLSX download
downloadDayRateRevisionXlsx in api.ts handles the binary download path. It sends an explicit Accept header to negotiate the XLSX media type:
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 theContent-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
rateisnullor exceeds999,999,999,999.99. - Invalid calendar dates for
effectiveFromandeffectiveTo.
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:
- 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.
- 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:
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).
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:
kind maps from the response outcome:
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:
RateCategoryDefinition is the picker row shape:
UpsertRateCategoryInput mirrors RateCategoryDefinition for writes and includes allowedUnitCodes as a required field:
UpsertRateOptionInput is the write shape for rate options:
RateOptionUpdateResponse extends PricedRateOption with an optional changedDependants array:
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-onlycall: 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
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.
