DayRateLines renders the line and margin table for one DayRateRevision. DayRateRevisionWorkspace owns the pricing-rule authoring model and supplies the line component with rule decorations, then DayRatesPanel mounts the workspace for the selected revision.
The table is Table.ScrollArea + Table.Root + VirtualTableBody from the design library, not DataTable. DataTable’s composition — count line, sections, kebab column, skeleton, density, expander — is unused here. Every one of the six columns needs a render because four carry live controls that swap on a per-row editing flag; moving that state to the parent to gain the DataTable abstraction adds no benefit beyond what the theme’s table slot recipe already provides.
VirtualTableBody from @stralos-co-nz/ui 0.65.0 replaces a plain <tbody> in the line table. It renders only the rows currently on screen plus a margin, keeping the DOM shallow for long revisions. The columnCount prop is 6 on draft revisions (which include an Actions column) and 5 on current and superseded revisions. Virtualisation is transparent to users — the scrollbar spans the full line list and every row is reachable by dragging it.
See Day rate schedules frontend for schedule selection, revision lifecycle, inheritance, archive, and restore state.
Component contract
DayRateSchedulePage.tsx) owns the lines and every write. Without edit the table is in view mode: every cell is text and nothing can save, Maps to included. With edit, edit.scope says what the revision lets Edit change, including whether mappings are editable.
Data loading
The page loads the line list, resolved cost, sell rate, margin, and unavailability reason withgetDayRateRevisionMargin and passes it in as margin. The table’s own requests are the catalogue reads behind Maps to:
listAllActiveRateOptionChoices()loads active rate items and their active, open-ended options.listAllActiveActivities()pages active activities.
getRateOptionMappingChoice or getActivityMappingOption; targetRequestsRef prevents duplicate recovery calls.
The rate catalogue helper pages rate summaries and builds option choices directly from the summary response, without fetching per-item options separately. The activity helper also pages to totalCount. An empty page before the advertised count is treated as a paging error rather than a partial result.
Saving
The table never writes. Line adds, updates, deletes, and mapping changes are staged in the page’s edit, and Save changes sends them all at once — all or nothing — withsaveDayRateScheduleEdit. Discard drops every staged change. The per-line create, update, delete, and mapping wrappers this replaced were deleted in feature-rates #345.
Draft line editing
Delete opens aConfirmDialog titled with the line’s name (“Delete … ?”) and the message “The line leaves this draft revision when you save changes. Once saved, this cannot be undone.” Delete line removes the line from the staged edit; Discard brings it back. A draft line has never been issued, so once saved api-rates removes it outright.
The visible columns are Code, Maps to, Unit, Internal cost, Day rate, and, on drafts, Actions. The Internal cost and Day rate columns carry data-numeric and are right-aligned with tabular figures. The stored description mirrors the mapped target name because the mapping is the displayed line identity and the description is still needed for issued output.
The table uses Table.ScrollArea + Table.Root + VirtualTableBody from the design library rather than DataTable. Its composition — live controls in four of six columns while editing, custom loading and empty states — does not fit DataTable’s independent cell-render model. The library’s table slot recipe provides the header styling, hairlines, and numeric alignment. Internal cost and Day rate carry data-numeric, so both columns are right-aligned with tabular figures. verticalAlign="top" is set on each cell directly because the recipe default of middle misaligns rows where the Code cell carries multiple provenance pills.
Add line appends a blank draft line to the staged edit. A line with no description shows New line as its name.
Mapping picker
MappingPicker uses one Combobox, with Rates and Activities groups. It does not accept custom values.
Rates are grouped by rate item so the user sees one row per item rather than a supplier/location option. The stored API contract still requires a rateOptionId, so representativeOptionFor chooses deterministically:
- Prefer a Standard-condition option.
- Prefer organisation-scoped pricing — keyed on
scope === "organisation", not on a display name. - Break remaining ties by option id.
mappings list as { lineId, rateOptionId, activityId }, with both ids null for the clear row, and saves with the rest of the edit.
Setting a mapping does not silently rewrite the line’s unit or description. The draft exposes two optional actions: Use the mapped unit (when the target’s unit differs from the draft’s current unit) and Use the mapped name as the description (when the target’s name differs). Both buttons update only the draft; neither fires a write. The unit and description are sent as part of the line PUT only when Save is clicked. On a published or superseded revision the mapping PUT is the only write; the line’s contractual fields are not touched.
Pricing and margin display
aggregateMargin returns a percentage only when the revision has at least one line, every line is mapped, and every line has non-null cost, sell rate, and line margin with no unavailableReason. It sums sell and cost values before calculating the rounded percentage; it does not average the line percentages.
Flat and fixed rows display their contractual amount. Cost-plus rows use costPlusRateText: a Standard base rule at line scope wins over the revision-scope rule and renders Cost + N%. If neither scope has a usable cost-plus rule, the row renders Cost plus — rule unavailable.
Standard minimum-quantity and minimum-amount rules appear as chips beneath the line code. The chip distinguishes local overrides and rules re-keyed since copy. The table also states that mappings, expected costs, and margins are excluded from the issued schedule.
Pricing rule editor (DayRateRules)
DayRateRevisionWorkspace owns the pricing-rule authoring model and mounts the rule editor in two tabs: Schedule-wide and Per-line. Both tabs render RuleSetEditor, which contains the condition loadings section. The tabs are hidden rather than unmounted, so drafts survive a tab switch.
Single Save and the failure rule
There is one Save pricing rules button in the editor footer, shared across both tabs. Each section (PricingRuleEditorSection) registers its dirty state, its readiness, and its write callback with the footer via RuleSectionsSave rather than the footer reaching into the sections. This keeps each section’s per-line selection, filter, and draft state local.
Sections run in order: schedule-wide first (per-line rules inherit from them). If a section fails, the run stops. The page stays dirty, every draft is kept exactly as typed, and the failing section is named in a role="alert" on the page. Nothing reports a clean save until all sections succeed.
The Save pricing rules button is aria-disabled (and disabled) when no section is dirty or when a dirty section is not yet ready to submit (for example, while a serialisation check is pending). The busy guard on the footer prevents a double-submit.
Every gate that existed before — serialisation errors, the coined-token confirmation, the 409 stale reload — remains beside the fields it belongs to, inside the section that owns it.
Condition loadings table
Condition loadings (modifier rules) are displayed as aDataTable with one column per field: Condition, Basis, Value, Label, and a Remove action column for editable views. Each loading is one table row; the column headers are stated once.
Previous builds rendered loadings as stacked bordered cards where each card repeated all four field labels. The table layout lets reviewers scan values down a column across multiple loadings.
DataTable is used here because every control in these rows is driven by the parent component through changeModifier and onChange. There is no per-row state, no ref, and no focus handoff to lift, so cells rendered by independent functions cost nothing. This is unlike the schedule-lines table, where shared per-row state prevents DataTable use.
The ordinal for each loading row is carried on the row object rather than derived at render time. This is required because DataTable hands a render function a row, not a position, and the accessible name of every control (Loading N …) must count the loadings specifically, not the surrounding draft entries.
Basis field
The basis picker usesNativeSelect.Root (NativeSelect.Field / NativeSelect.Indicator). Two options are available:
The additive option is only shown when
additiveAvailable is true (base rule is not fixed) or when the existing row already carries modifierBasis === "additive_per_unit". This prevents the picker from showing a blank or switching silently when a fixed base is selected.
Constraint: fixed base and additive loadings
An additive modifier (modifier_additive slot) is rejected by validatePricingRuleSet when the base rule is fixed. If a line already carries a per-unit loading and the user switches to a fixed base, a stranded-additive warning appears with a Remove it button. The basis picker also provides a route out of stranding — switching a stranded row’s basis from additive to multiplicative resolves the conflict without removing the loading.

