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

# Day rate lines frontend

> Line editing, mapping catalogue, margin refresh, and mutation state in stralos-feature-rates

`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](https://help.stralos.app/internal/day-rate-schedules-frontend.md) for schedule selection, revision lifecycle, inheritance, archive, and restore state.

## Component contract

```ts theme={null}
type DayRateLinesProps = {
  margin: DayRateRevisionMargin | null;
  loading: boolean;
  error: string | null;
  onRetry(): void;
  edit?: DayRateLinesEdit;
};

type DayRateLinesEdit = {
  scope: EditScope;
  lines: DraftLine[];
  errors: FieldErrors;
  busy: boolean;
  onChange(lines: DraftLine[]): void;
};
```

The schedule page (`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 with `getDayRateRevisionMargin` 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.

Catalogue calls run in parallel. Stored mapping targets that are absent from the active catalogues are fetched individually with `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 — with `saveDayRateScheduleEdit`. **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 a `ConfirmDialog` 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:

1. Prefer a Standard-condition option.
2. Prefer organisation-scoped pricing — keyed on `scope === "organisation"`, not on a display name.
3. Break remaining ties by option id.

An existing mapping to a different option for the same item keeps the stored id while displaying the item label. A target outside the selectable active set is inserted under **Stored** so the controlled value remains visible. A chosen mapping is staged in the edit's `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 a `DataTable` 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 uses `NativeSelect.Root` (`NativeSelect.Field` / `NativeSelect.Indicator`). Two options are available:

| Value | Display |
| - | - |
| `additive_per_unit` | `+ $ per unit` |
| `multiplicative` | `× factor` |

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.

## API calls

| Function | HTTP | Endpoint |
| - | - | - |
| `getDayRateRevisionMargin` | GET | `/api/rates/day-rate-schedule-revisions/:id/margin` |
| `saveDayRateScheduleEdit` | PUT | `/api/rates/day-rate-schedules/:scheduleId/revisions/:revisionId/edit` |
| `listRateSummaries` | GET (paged) | `/api/rates/rate-summaries` |
| `getRateOptionMappingChoice` | GET | `/api/rates/rate-options/:id` plus its rate item |
| `listAllActiveActivities` | GET (paged) | `/api/activities/activities` |
| `getActivityMappingOption` | GET | `/api/activities/activities/:id` |


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