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 bylistRequestIdRef. loadSchedules is called on mount and re-called from the Retry button on a load error.
expandedId is cleared.
Detail lifecycle
Managed bydetailRequestIdRef. 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 byactionBusyRef (a ref, not state, to prevent double-submit). commitRevision handles both publish and cut:
- If
revision.status === "draft"→ callspublishDayRateRevision(revision.id)→ applieswithPublishedRevision - Otherwise → calls
cutDayRateRevision(schedule.id, revision.id)→ applieswithCutRevision
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:
- Sets
expandedIdtoinitialScheduleId. - Sets
selectedBySchedule[initialScheduleId]toinitialRevisionId. - Calls
loadDetail(initialScheduleId, initialRevisionId)to fetch the full revision.
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:
- If the caller specifies a
preferredRevisionIdand that revision exists on the schedule, use it. - Otherwise, prefer
draftRevision, thencurrentRevision, then the first item inrevisions.
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
currentRevisionis set. Click to expand and jump to that revision. - A Draft · Rev N outline button if
draftRevisionis 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 YYYYin 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 (
NativeSelectfrom@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
DayRateRebaseReviewPagewhen 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.Rootfrom@stralos-co-nz/uirather than a native<details>element so the disclosure uses the themed chevron (ChevronRightrotated 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 throughoutDayRateRules, 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 withIntl.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 inDayRateRevisionDetailsDialog 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 openDayRateScheduleCreateDialog 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:
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:
/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 tosessionStorage 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 isunchanged — 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 resolvesacknowledgementBaseRevisionId 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 whenemptyRuleTitles 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 areSideSheet 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.
