Skip to main content
DayRatesPanel provides a read/write view of an organisation’s contractual sell schedules and their revisions. The panel renders inside RatesPage under the Day rates tab. Cost truth remains in the ordinary rate library; a day rate schedule is never a cost source.

Surface placement

RatesPage exposes three tabs: Resources (rates-tab-resources) shows grouped items, Rates (rates-tab-rates) shows individual price options, and Day rates (rates-tab-day-rates) renders DayRatesPanel. The catalogue tabs share filter state; ?view=prices still opens Rates for older links. DayRatesPanel is code-split with React.lazy. While it loads it shows a spinner with the text Loading day rates…. The tab is always visible regardless of whether any schedules exist. canEditSchedules is passed from RatesPage as canEditCatalog (the result of isCatalogAdmin()). When canEditSchedules is not supplied, DayRatesPanel calls isCatalogAdmin() itself.

Data model

Schedule

derivation is a server-computed human-readable string used as a fallback when the panel cannot resolve it from the loaded revision index.

Revision

nameSnapshot, clientNameSnapshot, orgNameSnapshot, and other snapshot fields record the identity at the time the revision was created. The live schedule name can change after a revision is published; the snapshot preserves the name as it was.

API calls

listAllDayRateSchedules pages GET /api/rates/day-rate-schedules with start and length=200 until exhaustion. It requests either scheduleStatus=active or scheduleStatus=archived according to the panel toggle. If paging stalls before totalCount, it throws Day rate schedule paging stopped at N of M. The revision workspace also calls the line, mapping, pricing-rule, margin, and export endpoints. DayRateScheduleCreateDialog, DayRateRevisionDetailsDialog, DuplicateDayRateScheduleDialog, and DayRateRebaseReviewPage own their corresponding mutations; the panel owns list/detail reads, publish/cut, archive, and restore.

State machine

DayRatesPanel runs two independent request lifecycles with monotonically increasing request IDs to prevent stale responses from landing.

List lifecycle

Managed by listRequestIdRef. loadSchedules is called on mount and re-called from the Retry button on a load error.
If a schedule that was expanded is no longer in the refreshed list, expandedId is cleared.

Detail lifecycle

Managed by detailRequestIdRef. loadDetail is called when a schedule row is expanded or a specific revision is opened from a deep link. Publish and cut use their authoritative write responses instead of immediately re-reading from a replica.

Action lifecycle

Managed by actionBusyRef (a ref, not state, to prevent double-submit). commitRevision handles both publish and cut:
  • If revision.status === "draft" → calls publishDayRateRevision(revision.id) → applies withPublishedRevision
  • Otherwise → calls cutDayRateRevision(schedule.id, revision.id) → applies withCutRevision
Both optimistically update the local schedules state without waiting for a detail reload. The detail reload is intentionally skipped after a mutation to prevent a replica snapshot replacing the authoritative write response.

URL-based schedule and revision selection

RatesPage reads two optional query parameters when the Day rates tab is active: Both parameters must be present and valid UUIDs for the selection to take effect. If either is absent or fails UUID validation, DayRatesPanel loads normally with no pre-selected row. A non-UUID value (for example schedule=not-a-uuid) is silently dropped without raising an error. The URL format is:
DayRateImportPage navigates to this URL after a successful import so the user lands directly on the newly created draft revision. The selection effect runs inside DayRatesPanel via the initialScheduleId and initialRevisionId props. Once the schedule list has loaded, the panel:
  1. Sets expandedId to initialScheduleId.
  2. Sets selectedBySchedule[initialScheduleId] to initialRevisionId.
  3. Calls loadDetail(initialScheduleId, initialRevisionId) to fetch the full revision.
A selectionKey string (${scheduleId}\0${revisionId}) prevents re-running this effect when an unrelated state change causes a re-render with the same initial props. Clearing both parameters (navigating to /rates?view=day-rates) resets the selection state. The schedule and revision parameters are URL inputs parsed only by RatesPage. The filter serialiser in RatesPage does not run while either routeSurface or activeSurface is day-rates, so a filter write cannot overwrite these parameters while the Day rates tab is active.

Revision selection

selectedRevisionId resolves which revision to show in the detail panel:
  1. If the caller specifies a preferredRevisionId and that revision exists on the schedule, use it.
  2. Otherwise, prefer draftRevision, then currentRevision, then the first item in revisions.
When a schedule is expanded for the first time, selection defaults to the draft (if any), then the current revision.

Derivation display

selectedDerivation computes the human-readable derivation string shown in the Derivation column and in the detail panel: The sign uses − (Unicode minus) for negative adjustments and + for non-negative. Zero is displayed as + 0%. The revision index (RevisionIndex) is built from the full loaded schedule list, so derivations from a different schedule’s revision are resolved correctly when both schedules are loaded.

Review detection

reviewRevisions selects non-superseded revisions whose server-computed needsBaseReview field is true. The API compares the pinned baseRevisionId with the base schedule’s current revision and the revision’s optional baseAckRevisionId, so the frontend does not recreate that cross-schedule rule from a potentially partial list. Review-flagged revisions appear in the status cell as amber Review required · Rev N buttons. Multiple revisions on the same schedule can carry the flag simultaneously.

Status cell content

The status cell of each schedule row shows:
  • A Current · Rev N button if currentRevision is set. Click to expand and jump to that revision.
  • A Draft · Rev N outline button if draftRevision is set.
  • One amber Review required · Rev N button per flagged revision.
  • No revisions (muted text) if none of the above apply.

Detail panel

Expanding a schedule row opens a detail sub-row that spans all six table columns. The panel shows:
  • A heading with the selected revision’s number, status, and effective-from date formatted as d MMM YYYY in NZ locale.
  • The derivation string.
  • An amber warning when the draft’s derivation differs from the current revision’s derivation.
  • An amber warning when the selected revision requires base review.
  • A revision selector (NativeSelect from @stralos-co-nz/ui) listing all revisions sorted newest-first. The themed component is used rather than a bare <select> so the dropdown matches the product’s design system in all colour modes.
  • A Publish button when the selected revision is a draft (admin only).
  • A New revision button when the selected revision is published (admin only).
  • An Edit details dialog for schedule identity and draft dates or terms (admin only).
  • A Review base changes link that navigates to DayRateRebaseReviewPage when the selected revision requires review (admin only).
  • The revision line and pricing-rule workspace, followed by export controls.
  • A Schedule details disclosure containing the revision identity snapshot and terms. This uses Collapsible.Root from @stralos-co-nz/ui rather than a native <details> element so the disclosure uses the themed chevron (ChevronRight rotated 90° when open) and the design-system typography, not the browser’s own ▸ marker and system font.

Identity snapshot fields

Read-only states

A published (current or superseded) revision renders Issued revisions can’t be edited. Use New revision to change rates. Non-admins see This revision is read only. and no mutation buttons. The schedule header also renders Read only · organisation admins manage revisions for non-admins. Export remains available.

Issued revision — fixed fields display

When a revision is issued, DayRateRevisionDetails renders the client, effective dates, and terms as plain text in a definition list (<dl>) rather than as read-only form inputs. A read-only <input> is visually identical to an editable one (white box, calendar icon) and appeared directly under a sentence stating those fields are fixed. Plain text removes the contradiction. The fields shown are: Client, Effective from, Effective to (or “Open-ended”), and Terms.

Error handling

safeApiMessage maps known statuses to non-enumerating user messages. Restore additionally branches on typed ApiResponseError codes so a name collision can link to the active blocking schedule without parsing prose.

Themed form controls

Radio buttons and checkboxes throughout DayRateRules, DayRateInheritance, and DayRateRebaseReviewPage use CheckField and RadioField from src/ThemedChoice.tsx rather than native <input type="checkbox|radio"> elements. The browser-default controls ignore the design system’s theme tokens and rendered as grey system widgets beside the petrol-coloured themed controls. CheckField wraps Checkbox.Root from @stralos-co-nz/ui; RadioField wraps RadioGroup.Root. Each still renders a hidden input with role=checkbox or role=radio so tests and screen readers find the same control they always did.

Date formatting

All dates are formatted with Intl.DateTimeFormat("en-NZ", { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" }) to prevent timezone shift on YYYY-MM-DD strings. The dayBefore helper subtracts one UTC day from a YYYY-MM-DD string and is used when closing the effective window of the previous current revision on publish.

Date inputs in the revision details drawer

The Effective from and Effective to fields in DayRateRevisionDetailsDialog use the DateField component (from @stralos-co-nz/ui), not native <input type="date"> elements. A native date input follows the browser’s locale — on a US-locale machine, 03/04/2026 stores 4 March, not 3 April. DateField enforces day-first input consistently. The placeholder on both fields is a worked example (e.g. 3 Apr 2026) derived from DATE_INPUT_EXAMPLE in dayRateDates.ts. The example date, 3 April, is deliberate: a month-first misreading of 3/4/2026 turns it into 4 March, making the ambiguity visible. DATE_INPUT_EXAMPLE uses formatDayRateDate("2026-04-03") so the example and the formatter cannot drift apart. These three forms are SideSheet components (size="lg", 640 px wide) anchored to the right edge of the screen. When the assistant panel is open, from the md breakpoint up they are non-modal: no backdrop, no scroll lock, and the page behind stays live. This means they open beside the assistant panel rather than blocking it. Below md they become full-screen and modal regardless. The modality is fixed at the frame the sheet opens — it does not change if the viewport is resized while the sheet is open.

Optimistic revision updates

withCutRevision prepends the new draft revision to the schedule’s revisions array and sets draftRevision. It does not change currentRevision. withPublishedRevision sets the published revision as currentRevision, sets draftRevision: null, and closes the previously current revision’s effectiveTo to the day before the new revision’s effectiveFrom (only if the previous effectiveTo was null or later than that closing date). Both helpers sort revisions descending by revisionNumber.

Create, duplicate, archive, and restore

Admins can open DayRateScheduleCreateDialog from + New schedule. It creates an internally authored schedule, either empty or inherited at 0% from an active schedule’s current revision. Client-authored workbooks use the separate Import Schedule of Rates route. The + New schedule, Import Schedule of Rates, and Show archived schedules controls all appear on the DayRatesPanel title row rather than on a separate action row. Keeping all three on one row avoids a dedicated header row that added padding and a border without functional benefit. The ordering follows ghost → outline → solid: Show archived schedules (ghost, a lens on the list) → Import Schedule of Rates (outline) → + New schedule (solid, the primary action). Import Schedule of Rates is hidden when the archived view is active. The Effective from field uses DateField rather than <Input type="date">. A native date input renders in the browser’s locale, which showed 08/31/2026 on an NZ product. DateField displays day-first (7 Sep 2026), accepts day-first typed input (7/9/2026 parses as 7 September), and returns an ISO date (YYYY-MM-DD) that the API accepts. DuplicateDayRateScheduleDialog offers copy and inheritance modes from a published source. Archive is a confirmed DELETE, followed by local removal from the active list. The archived view is a separate paged request. Restore removes the row from that view and reports any active dependant revisions returned by the API. A day_rate_schedule_name_taken conflict exposes Show blocking schedule; day_rate_schedule_restore_unstable gives a retry message without claiming a change landed.

Revision detail page

DayRateRevisionDetailPage (src/DayRateRevisionDetailPage.tsx) is a standalone page for one revision, mounted at:
It is documented in detail under Day rate revision detail page in the rates editor frontend doc. The detail page and DayRatesPanel each fetch the schedule and derive their own revision object, then mount the same DayRateRevisionWorkspace component against the same endpoints. This keeps their editing behaviour aligned. The panel’s expanded row links to the revision detail page via dayRateRevisionDetailPath(scheduleId, revisionId) from src/detailRoutes.ts. The expansion itself stays as the inline preview.

Rebase review page

DayRateRebaseReviewPage replaces the former RebaseReviewDialog. It is a full route rather than an overlay, mounted at:
This is a child path of /rates, so the shell needs no change. Navigating to this route leaves the rates surface entirely; the page carries a Back link that returns to /rates?view=day-rates&schedule=<id>&revision=<id>.

Route and navigation

DayRatesPanel navigates to the review page via dayRateRebaseReviewPath(scheduleId, revisionId) (defined in dayRateReviewRoute.ts). After a successful rebase or acknowledgement, the page navigates back to dayRatesPanelPath(scheduleId, revisionId), landing directly on the affected revision.

Removal tick persistence

Removal ticks are written to sessionStorage as the reviewer makes selections, so a tick survives a browser refresh or navigating away and back. Each entry is keyed by schedule and revision and stamped with targetBaseRevisionId. On reload, persisted ticks are only restored when the stamp matches the current preview’s targetBaseRevisionId; ticks placed against a base revision that has since moved are discarded rather than silently re-applied to different outcomes. On a 409 conflict from applyDayRateRebase, the page refreshes the preview and clears all stored ticks. The error message tells the reviewer the base or schedule changed and prompts them to check every outcome again. If the browser is in a privacy mode that refuses storage access, tick persistence is silently skipped; the review still works but ticks are lost on navigation.

All-unchanged rebase

When the base has a newer revision but every line and pricing rule outcome is unchanged — the base moved but nothing on this schedule changed with it — the page computes nothingChanges: true and renders a single status box:
The base has a newer revision, with no rate changes. No lines or pricing rules were added, updated, removed or overridden. Acknowledge to clear the review, or create a draft to follow the newer base.
The eleven empty outcome-group headings that would otherwise appear are suppressed entirely. This message replaces them so the reviewer sees one clear statement rather than a list of empty sections under an amber “Review required” heading. nothingChanges is true when the preview is loaded and every bucket in DAY_RATE_REBASE_BUCKETS other than unchanged is empty, and every bucket in DAY_RATE_RULE_REBASE_BUCKETS other than unchanged is also empty.

Acknowledgement path

When the displayed revision is not the revision in force, the page resolves acknowledgementBaseRevisionId by fetching all schedules and finding the base schedule’s currentRevision. If the base schedule’s current revision is unavailable, the Acknowledge button remains disabled with a status message explaining why.

Pricing-rule merge heading

The Pricing-rule merge section heading is only rendered when there is at least one rule bucket with content, or when emptyRuleTitles is non-empty and the rebase is not a no-op. When the rebase has no pricing-rule changes in any group, the heading is omitted entirely so it does not appear over an empty section.

Error states

Side-sheet button contracts

The three day-rate create/edit forms are SideSheet components. They use two patterns for their primary action button. DayRateRevisionDetailsDialog and DuplicateDayRateScheduleDialog use the footer prop with SubmitButton bound to the form by id. The form lives in the sheet body; the button sits in the footer and submits by form association:
DayRateScheduleCreateDialog uses the actions prop. SideSheet.actions.primary.onClick calls requestFormSubmit(formId), which calls HTMLFormElement.requestSubmit() on the form by id. This keeps a single onSubmit path and preserves native validation:
requestFormSubmit is defined in RateAtoms.tsx. Use form={formId} on SubmitButton rather than an onClick handler — two submit paths (the button and the form’s own onSubmit) would drift independently.