Skip to content

Commit 7e297fa

Browse files
Rylan-cgiclaude
andcommitted
fix(schedule10): apply the Story 11.3 code review findings
Adversarial code review of Story 11.3 raised 31 findings (6 high, 16 medium, 9 low). Six needed a product call and were decided by Scho; the other 25 were patched. All 30 resulting patches are here. Behavioural fixes: - The "unsaved data will be lost" confirmation on Enter Road Data navigated without discarding the edit. Returning via Back reopened the panel still holding it, and Save then wrote it against a freshly re-read revisionCount, so the lock passed and data the user was told was discarded persisted. - A stored supply block absent from the CATALOGUE rendered blank: the option list was built by filtering the served list, so a code that is not a row yielded nothing. ab7dea8 fixed only the in-catalogue case. - Any TSA interaction dropped a cross-TSA supply block. The clear was gated on the prefix not matching, with no check that the TSA had changed, so re-selecting the SAME TSA cleared it; and clearing the control left an orphan block, since startsWith('') is always true. - Check Status discarded road attribution. The backend prefixes roadName and subzone with the page label only, so on a multi-road page the flattened line could not say which road failed; the roadDetailLabel is now prefixed. - Read-only REMOVED Save from the DOM, contradicting AC11 and deviation 7, which both require every write control rendered and disabled. The test that codified the removal was flipped. - A null revisionCount made Save a silent no-op with no banner at all. - Ballast method N/D coercion is now mirrored in the form and the request body (material forced to NA, and on N the dimensions, actualCost and otherTransfer zeroed but ttTransfer kept, per the server's own asymmetry). - Road Group is blanked when the location is edited rather than showing a value the server derived from the location as stored. - The TFL advisory was unreachable behind maxLength={2} while a blank TFL was ungated; the comments byte cap used 3500 where the column allows 4000, so a legal save was blocked; an over-length road name returned the off-list message instead of the server's; a de-listed BEC rendered its raw catalogue id; five derived money cells inverted the legacy masks; a page edit did not clear a stale check-status result; the ballast material hint fired before any method was chosen; and leaving the road level stranded an open delete confirmation whose Yes then did nothing. Shared components, additively and with no change for other schedules: - CodeComboBox gains an opt-in matchMode prop defaulting to today's substring match, so Schedules 2, 4 and 8 filter exactly as before. Schedule 10's BEC Zone opts into prefix, which is what AC5 specifies. - number.ts gains fmtWholeCost, the mask.int.7digits (#,###,##0) formatter the derived integer totals needed. Test coverage: - millId and year were asserted on 2 of 9 endpoints. MSW matches a path regardless of query string, so dropping the query passed every test and would have 400'd every write in production. All 9 are now pinned. - The two new backend code lists shipped with zero coverage: deleting either @query body, or swapping the two toCodes(...) arguments in the assembler, left the suite green. Both lists are now pinned, including a transposition guard. - Writing that coverage showed the referenced-union leg on both queries had no coverage AND COULD NOT BE GIVEN ANY as seeded. It reads "WHERE <date window> OR code IN (<referenced>)", so it can only rescue a code that HAS a row and fell outside the window; V20260819 deliberately left 16Z out of its code table on the stated grounds that this kept it representative of the rows motivating that leg, which is backwards. 16Z is now seeded EXPIRED and referenced by page 8904, making the leg falsifiable in both directions. 99A stays absent to pin the boundary that justifies synthesising a stored code client-side. - Road-detail delete had no test at all. Three vacuous assertions were replaced. Range constants were pinned at one bound only, and stSurfaceWidth, ROAD_NAME_MAX and TFL_MAX were untested entirely. - AC14's nav-position and not-admin-only halves were unasserted, and AC15's accessibility assertions were never written. Both are covered now. - A source-level tripwire guards the shared ComboBox rule in _overrides.scss. jsdom does not apply the SCSS, so no rendering test can observe the computed height; this is a tripwire, not a behaviour test. Verified: frontend 165 Schedule 10 tests green, full suite 979/980 with the one failure pre-existing (Schedule8 Helicopter, passes 86/86 in isolation); backend 262 unit and 87 integration tests green; lint and Prettier clean. NOT verified in a browser -- that belongs to Story 11.4 with the axe sweep. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b03d83e commit 7e297fa

13 files changed

Lines changed: 1212 additions & 83 deletions

File tree

backend/src/test/java/ca/bc/gov/nrs/ilcr/schedule10/Schedule10DocumentIT.java

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package ca.bc.gov.nrs.ilcr.schedule10;
22

3+
import static org.hamcrest.Matchers.contains;
34
import static org.hamcrest.Matchers.hasItem;
45
import static org.hamcrest.Matchers.hasSize;
56
import static org.hamcrest.Matchers.is;
@@ -329,6 +330,76 @@ void codeLists_areYearFilteredAndOmitRemovedFields() throws Exception {
329330
.andExpect(jsonPath("$.codeLists.soilMoistureCodes").doesNotExist());
330331
}
331332

333+
@Test
334+
@DisplayName("the TSA and supply-block lists are served, year-filtered, and not transposed")
335+
void tsaAndSupplyBlockLists_areServedAndYearFiltered() throws Exception {
336+
// Both lists shipped with NO coverage at all: deleting either @Query body, or swapping the two
337+
// toCodes(...) arguments in the assembler -- they are both List<CodeDescriptionDto>, so it
338+
// compiles -- left the suite green. Each assertion below fails under one of those mutations.
339+
mockMvc.perform(get(ENDPOINT).param("millId", "710").param("year", "2021")
340+
.accept(MediaType.APPLICATION_JSON))
341+
.andExpect(status().isOk())
342+
// Present and populated, so deleting a @Query body reds this.
343+
.andExpect(jsonPath("$.codeLists.tsaNumbers").isArray())
344+
.andExpect(jsonPath("$.codeLists.supplyBlocks").isArray())
345+
.andExpect(jsonPath("$.codeLists.tsaNumbers[?(@.code == '01')]", hasSize(1)))
346+
.andExpect(jsonPath("$.codeLists.tsaNumbers[?(@.code == '16')]", hasSize(1)))
347+
.andExpect(jsonPath("$.codeLists.supplyBlocks[?(@.code == '01A')]", hasSize(1)))
348+
.andExpect(jsonPath("$.codeLists.supplyBlocks[?(@.code == '16G')]", hasSize(1)))
349+
// The descriptions are what the control DISPLAYS, so they are part of the contract.
350+
.andExpect(jsonPath("$.codeLists.tsaNumbers[?(@.code == '01')].description",
351+
contains("Arrow TSA")))
352+
.andExpect(jsonPath("$.codeLists.supplyBlocks[?(@.code == '01A')].description",
353+
contains("Arrow TSA Block A")))
354+
// TRANSPOSITION GUARD: a TSA code must never appear in the block list, or the reverse. A
355+
// swap of the two assembler arguments compiles and is caught only here.
356+
.andExpect(jsonPath("$.codeLists.tsaNumbers[?(@.code == '01A')]", hasSize(0)))
357+
.andExpect(jsonPath("$.codeLists.tsaNumbers[?(@.code == '16G')]", hasSize(0)))
358+
.andExpect(jsonPath("$.codeLists.supplyBlocks[?(@.code == '01')]", hasSize(0)))
359+
.andExpect(jsonPath("$.codeLists.supplyBlocks[?(@.code == '16')]", hasSize(0)))
360+
// V20260819 seeds '90','Retired TSA' expiring 2010-12-31 stating it exists "to pin that the
361+
// year filter drops a code". Nothing pinned it until now: no stored 2021 page references
362+
// '90', so neither leg of the predicate can rescue it.
363+
.andExpect(jsonPath("$.codeLists.tsaNumbers[?(@.code == '90')]", hasSize(0)));
364+
}
365+
366+
@Test
367+
@DisplayName("an expired block a stored page references is rescued by the referenced-union leg")
368+
void supplyBlocks_referencedUnionRescuesAnExpiredCode() throws Exception {
369+
// The union leg on both queries had NO coverage: it can only rescue a code that HAS a row and
370+
// fell outside the date window, and no fixture created that shape until V20260819 was corrected
371+
// to seed '16Z' expired (see that file's CORRECTED note). Page 8904 references it, on mill 712.
372+
mockMvc.perform(get(ENDPOINT).param("millId", "712").param("year", "2021")
373+
.accept(MediaType.APPLICATION_JSON))
374+
.andExpect(status().isOk())
375+
// Drop the `OR TSB_NUMBER_CODE IN (...)` leg and this goes to 0.
376+
.andExpect(jsonPath("$.codeLists.supplyBlocks[?(@.code == '16Z')]", hasSize(1)));
377+
378+
// Scoped to the MILL and the YEAR: mill 710 references no such block, so the expired code stays
379+
// dropped there. Remove the date predicate and this goes to 1.
380+
mockMvc.perform(get(ENDPOINT).param("millId", "710").param("year", "2021")
381+
.accept(MediaType.APPLICATION_JSON))
382+
.andExpect(status().isOk())
383+
.andExpect(jsonPath("$.codeLists.supplyBlocks[?(@.code == '16Z')]", hasSize(0)));
384+
}
385+
386+
@Test
387+
@DisplayName("a code absent from its table entirely is not served, however many pages reference it")
388+
void codeLists_cannotServeACodeWithNoRow() throws Exception {
389+
// Page 8903 (mill 712) stores TSA '99' / TSB '99A', neither of which has a row in its code table.
390+
// The union leg selects FROM the code table, so it cannot invent one. This is the contract
391+
// boundary that makes the FRONTEND synthesise a stored code as its own option (review H2) — pinned
392+
// here so nobody "fixes" the client by pointing at a backend guarantee that does not exist.
393+
mockMvc.perform(get(ENDPOINT).param("millId", "712").param("year", "2021")
394+
.accept(MediaType.APPLICATION_JSON))
395+
.andExpect(status().isOk())
396+
.andExpect(jsonPath("$.codeLists.tsaNumbers[?(@.code == '99')]", hasSize(0)))
397+
.andExpect(jsonPath("$.codeLists.supplyBlocks[?(@.code == '99A')]", hasSize(0)))
398+
// The page itself still lists, carrying its stored location verbatim.
399+
.andExpect(jsonPath("$.pages[?(@.pageId == 8903)].tsaNumber", contains("99")))
400+
.andExpect(jsonPath("$.pages[?(@.pageId == 8903)].tsbNumberCode", contains("99A")));
401+
}
402+
332403
@Test
333404
@DisplayName("BEC is served structurally, gated by the surviving BR-06 xref (deviation (e))")
334405
void becIsStructuralAndXrefGated() throws Exception {

backend/src/test/resources/db/V20260819__tsa_tsb_code_table_dates_and_seeds.sql

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,20 @@ ALTER TABLE THE.TSB_NUMBER_CODE ADD (
2626
-- The TSA numbers and supply blocks the Schedule 10 read fixtures store on their pages, so each
2727
-- stored location resolves to an option in its own dropdown.
2828
--
29-
-- '99'/'99A' and '16Z' are deliberately NOT seeded: they are the unmapped-TSA and no-TSB-branch
30-
-- fixtures, and leaving them out of the code tables keeps them representative of the legacy rows
31-
-- that motivated the referenced-union leg on every list.
29+
-- '99'/'99A' are deliberately NOT seeded: they are the unmapped-TSA fixtures, and a code with no row
30+
-- at all is a real delivery state the dropdown has to survive.
31+
--
32+
-- CORRECTED 2026-08-20 (code review H5): this block previously left '16Z' unseeded too, on the stated
33+
-- grounds that doing so kept it "representative of the legacy rows that motivated the referenced-union
34+
-- leg". That is backwards. The union leg reads
35+
-- SELECT ... FROM THE.TSB_NUMBER_CODE WHERE <date window> OR TSB_NUMBER_CODE IN (<referenced>)
36+
-- so it can only rescue a code that HAS a row and fell outside the date window. A code absent from the
37+
-- table can never be selected by it, however many pages reference it — so the unseeded fixtures
38+
-- exercised nothing, and the leg had no coverage on either list. '16Z' is now seeded EXPIRED and is
39+
-- referenced by page 8904 (mill 712), which is the one shape that does exercise it.
40+
--
41+
-- A code that is absent ENTIRELY still cannot be served, which is why the FRONTEND synthesises the
42+
-- stored code as its own option (review H2) rather than relying on the backend for it.
3243
INSERT INTO THE.TSA_NUMBER_CODE (TSA_NUMBER, DESCRIPTION, EFFECTIVE_DATE, EXPIRY_DATE, UPDATE_TIMESTAMP)
3344
VALUES ('01', 'Arrow TSA', DATE '1900-01-01', DATE '9999-12-31', SYSDATE);
3445
INSERT INTO THE.TSA_NUMBER_CODE (TSA_NUMBER, DESCRIPTION, EFFECTIVE_DATE, EXPIRY_DATE, UPDATE_TIMESTAMP)
@@ -42,3 +53,9 @@ INSERT INTO THE.TSB_NUMBER_CODE (TSB_NUMBER_CODE, DESCRIPTION, EFFECTIVE_DATE, E
4253
VALUES ('01A', 'Arrow TSA Block A', DATE '1900-01-01', DATE '9999-12-31', SYSDATE);
4354
INSERT INTO THE.TSB_NUMBER_CODE (TSB_NUMBER_CODE, DESCRIPTION, EFFECTIVE_DATE, EXPIRY_DATE, UPDATE_TIMESTAMP)
4455
VALUES ('16G', 'Lakes TSA Block G', DATE '1900-01-01', DATE '9999-12-31', SYSDATE);
56+
57+
-- An EXPIRED block that page 8904 (mill 712) still references: the only fixture that makes the
58+
-- referenced-union leg falsifiable. Out of the date window, so the date predicate alone drops it; in
59+
-- the referenced set for mill 712, so the union leg brings it back for that mill and no other.
60+
INSERT INTO THE.TSB_NUMBER_CODE (TSB_NUMBER_CODE, DESCRIPTION, EFFECTIVE_DATE, EXPIRY_DATE, UPDATE_TIMESTAMP)
61+
VALUES ('16Z', 'Lakes TSA Block Z (retired)', DATE '1900-01-01', DATE '2010-12-31', SYSDATE);

frontend/src/components/core/CodeComboBox.tsx

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ export interface ComboOption {
77
readonly description: string
88
}
99

10+
/**
11+
* How typed text is matched against an option's description.
12+
*
13+
* `substring` is the historical behaviour every caller had before this prop existed and stays the
14+
* default, so Schedules 2, 4 and 8 filter exactly as they always have. `prefix` is opt-in for the
15+
* controls whose AC specifies it — Schedule 10's BEC Zone (AC5), where typing `SBS` must offer the
16+
* `SBS*` zones and not every zone containing those letters.
17+
*/
18+
export type ComboMatchMode = 'substring' | 'prefix'
19+
1020
interface CodeComboBoxProps {
1121
id: string
1222
titleText: string
@@ -16,6 +26,8 @@ interface CodeComboBoxProps {
1626
selectedCode: string
1727
/** Called with the chosen option's code ('' when cleared). */
1828
onSelect: (code: string) => void
29+
/** Defaults to `substring`, the behaviour every pre-existing caller relies on. */
30+
matchMode?: ComboMatchMode
1931
disabled?: boolean
2032
invalid?: boolean
2133
invalidText?: string
@@ -34,12 +46,18 @@ const CodeComboBox: FC<CodeComboBoxProps> = ({
3446
items,
3547
selectedCode,
3648
onSelect,
49+
matchMode = 'substring',
3750
disabled,
3851
invalid,
3952
invalidText,
4053
className,
4154
}) => {
4255
const selectedItem = items.find((option) => option.code === selectedCode) ?? null
56+
const matches = (description: string, typed: string): boolean => {
57+
const haystack = description.toLowerCase()
58+
const needle = typed.toLowerCase()
59+
return matchMode === 'prefix' ? haystack.startsWith(needle) : haystack.includes(needle)
60+
}
4361
return (
4462
<ComboBox<ComboOption>
4563
id={id}
@@ -49,14 +67,15 @@ const CodeComboBox: FC<CodeComboBoxProps> = ({
4967
items={items}
5068
itemToString={(item) => item?.description ?? ''}
5169
selectedItem={selectedItem}
52-
// Autocomplete: typing filters the list by a case-insensitive match on the description. Show the
53-
// whole list when nothing is typed OR when the input still equals the current selection (menu
54-
// just opened) — otherwise a selected value would filter the list down to itself and hide the
55-
// other options. Explicit so filtering doesn't depend on the Carbon default.
70+
// Autocomplete: typing filters the list by a case-insensitive match on the description, of the
71+
// kind `matchMode` names. Show the whole list when nothing is typed OR when the input still
72+
// equals the current selection (menu just opened) — otherwise a selected value would filter the
73+
// list down to itself and hide the other options. Explicit so filtering doesn't depend on the
74+
// Carbon default.
5675
shouldFilterItem={({ item, inputValue }) =>
5776
!inputValue ||
5877
inputValue === selectedItem?.description ||
59-
(item?.description ?? '').toLowerCase().includes(inputValue.toLowerCase())
78+
matches(item?.description ?? '', inputValue)
6079
}
6180
disabled={disabled}
6281
invalid={invalid}

frontend/src/components/schedule10/RoadDetailFields.tsx

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@ import type { FC, ReactNode } from 'react'
22
import { Select, SelectItem, TextArea, TextInput } from '@carbon/react'
33
import type { CodeDescription, Schedule10CodeLists } from '@/interfaces/Schedule10Response'
44
import CodeComboBox from '@/components/core/CodeComboBox'
5+
import type { ComboMatchMode } from '@/components/core/CodeComboBox'
56
import CommaNumberInput from '@/components/core/CommaNumberInput'
6-
import { fmtCurrency, fmtNumber } from '@/utils/number'
7+
import { fmtCurrency, fmtWholeCost } from '@/utils/number'
78
import type { MaskedField, RoadDetailErrors, RoadDetailFormValues } from './validation'
89
import {
910
COMMENTS_MAX,
1011
ROAD_NAME_MAX,
12+
ballastForcesMaterialNa,
1113
ballastMaterialRequired,
14+
ballastZeroesFigures,
1215
previewCostPerVolumePerLength,
1316
previewMaterialTotal,
1417
previewStabilizingCostPerLength,
@@ -70,15 +73,20 @@ const RoadDetailFields: FC<RoadDetailFieldsProps> = ({
7073
const id = (name: string) => `${idPrefix}-${name}`
7174

7275
// A stored classification may have been de-listed since it was saved, in which case it is absent
73-
// from the offerable list. Appending it keeps the field showing what the row actually holds
74-
// instead of appearing unselected.
76+
// from the offerable list. Appending it keeps the field showing what the row actually holds instead
77+
// of appearing unselected — and it is appended with the row's OWN label, which the response carries
78+
// on `becClassification` even for a de-listed value. Falling back to the catalogue id put a bare
79+
// number where every other option reads `SBSmk1`.
7580
const becOptions: CodeDescription[] = codeLists.becClassifications.map((bec) => ({
7681
code: String(bec.biogeoclimaticCatalogueId),
7782
description: bec.label ?? String(bec.biogeoclimaticCatalogueId),
7883
}))
7984
const selectedBec = form.becbiogeoCatalogueId
8085
if (selectedBec !== '' && !becOptions.some((option) => option.code === selectedBec)) {
81-
becOptions.push({ code: selectedBec, description: selectedBec })
86+
becOptions.push({
87+
code: selectedBec,
88+
description: form.becbiogeoLabel === '' ? selectedBec : form.becbiogeoLabel,
89+
})
8290
}
8391

8492
const describe = (options: readonly CodeDescription[], code: string): string =>
@@ -136,6 +144,7 @@ const RoadDetailFields: FC<RoadDetailFieldsProps> = ({
136144
key: keyof RoadDetailFormValues,
137145
label: string,
138146
options: readonly CodeDescription[],
147+
opts: { readonly matchMode?: ComboMatchMode; readonly disabled?: boolean } = {},
139148
): ReactNode =>
140149
readOnly ? (
141150
readOnlyField(label, form[key] === '' ? '' : describe(options, form[key]))
@@ -146,14 +155,20 @@ const RoadDetailFields: FC<RoadDetailFieldsProps> = ({
146155
titleText={label}
147156
items={[...options]}
148157
selectedCode={form[key]}
149-
disabled={disabled}
158+
matchMode={opts.matchMode}
159+
disabled={disabled || (opts.disabled ?? false)}
150160
invalid={Boolean(errors[key])}
151161
invalidText={errors[key]}
152162
onSelect={(code) => onChange(key, code)}
153163
/>
154164
</Field>
155165
)
156166

167+
// `N` and `D` both have their material forced to `NA`; `N` additionally has its dimensions and two
168+
// of its three costs zeroed, which `buildStabilizing` now sends rather than leaving to the server.
169+
const materialForced = ballastForcesMaterialNa(form.stBallastMethodCode)
170+
const figuresZeroed = ballastZeroesFigures(form.stBallastMethodCode)
171+
157172
const endHaulRate = previewCostPerVolumePerLength(
158173
form.lessEndHaul,
159174
form.endHaulVolume,
@@ -173,7 +188,8 @@ const RoadDetailFields: FC<RoadDetailFieldsProps> = ({
173188
{combo('roadLifetimeCode', 'Road Type', codeLists.roadLifetimes)}
174189

175190
<SubHeading>Moisture</SubHeading>
176-
{combo('becbiogeoCatalogueId', 'BEC Zone', becOptions)}
191+
{/* AC5: prefix match, not substring — typing `SBS` must not offer `ESSFmc`. */}
192+
{combo('becbiogeoCatalogueId', 'BEC Zone', becOptions, { matchMode: 'prefix' })}
177193
{combo('relSoilMoistRgmClsCode', 'RSMR Class', codeLists.rsmrClasses)}
178194

179195
<SubHeading>Shoulder</SubHeading>
@@ -196,28 +212,32 @@ const RoadDetailFields: FC<RoadDetailFieldsProps> = ({
196212
{numeric('sgActualCost', 'Sub-Grade Actual Cost', '$')}
197213
{numeric('sgTtTransfer', 'Sub-Grade TtT Transfer', '$')}
198214
{numeric('sgOtherTransfer', 'Sub-Grade Other Transfer', '$')}
199-
<Derived label="Total Costs ($)" value={fmtCurrency(previewSubGradeTotalCosts(form))} />
215+
<Derived label="Total Costs ($)" value={fmtWholeCost(previewSubGradeTotalCosts(form))} />
200216
{numeric('lessBridges', 'Less Bridges', '$')}
201217
{numeric('lessCulverts', 'Less Culverts', '$')}
202218
{numeric('lessLandings', 'Less Landings', '$')}
203219
{numeric('lessEndHaul', 'Less End Haul', '$')}
204220
{numeric('lessOverland', 'Less Overland', '$')}
205221
{numeric('lessOtherEng', 'Less OtherEng', '$')}
206-
<Derived label="Total ($)" value={fmtCurrency(previewSubGradeTotal(form))} />
222+
<Derived label="Total ($)" value={fmtWholeCost(previewSubGradeTotal(form))} />
207223
<Derived label="$/km" value={fmtCurrency(previewSubGradeCostPerLength(form))} />
208224
</Section>
209225

210226
<Section heading="Additional Stabilizing">
211227
{combo('stBallastMethodCode', 'Ballast Method Code', codeLists.ballastMethods)}
212228
{numeric('stLength', 'Additional Stabilizing Length', 'km')}
213229
{numeric('stSurfaceWidth', 'Additional Stabilizing Surface Width', 'm')}
214-
{combo('stBallastMaterialCode', 'Type', codeLists.ballastMaterials)}
230+
{/* The server replaces the material with `NA` on both `N` and `D`, so offering a choice
231+
here only invites one that will be discarded. */}
232+
{combo('stBallastMaterialCode', 'Type', codeLists.ballastMaterials, {
233+
disabled: materialForced,
234+
})}
215235
{numeric('stDepth', 'Depth', 'm')}
216236
{numeric('stDistanceToSource', 'Distance to Source', 'km')}
217237
{numeric('stActualCost', 'Additional Stabilizing Actual Costs', '$')}
218238
{numeric('stTtTransfer', 'Additional Stabilizing TtT Transfer', '$')}
219239
{numeric('stOtherTransfer', 'Additional Stabilizing Other Transfer', '$')}
220-
<Derived label="Total ($)" value={fmtCurrency(previewStabilizingTotal(form))} />
240+
<Derived label="Total ($)" value={fmtWholeCost(previewStabilizingTotal(form))} />
221241
<Derived label="$/km" value={fmtCurrency(previewStabilizingCostPerLength(form))} />
222242
</Section>
223243
</div>
@@ -248,14 +268,14 @@ const RoadDetailFields: FC<RoadDetailFieldsProps> = ({
248268
<div className="schedule-10__fields">
249269
{numeric('endHaulDistance', 'End Haul Distance', 'km')}
250270
{numeric('endHaulVolume', 'End Haul Volume', 'm3')}
251-
<Derived label="$/m3/km" value={fmtNumber(endHaulRate)} />
271+
<Derived label="$/m3/km" value={fmtCurrency(endHaulRate)} />
252272
</div>
253273

254274
<h4 className="schedule-10__detail-heading">Overland</h4>
255275
<div className="schedule-10__fields">
256276
{numeric('overlandDistance', 'Overland Distance', 'km')}
257277
{numeric('overlandVolume', 'Overland Volume', 'm3')}
258-
<Derived label="$/m3/km" value={fmtNumber(overlandRate)} />
278+
<Derived label="$/m3/km" value={fmtCurrency(overlandRate)} />
259279
</div>
260280

261281
<div className="schedule-10__comments">
@@ -282,9 +302,19 @@ const RoadDetailFields: FC<RoadDetailFieldsProps> = ({
282302
)}
283303
</div>
284304

285-
{!readOnly && ballastMaterialRequired(form.stBallastMethodCode) && (
305+
{/* Only once a method is actually chosen: a BLANK code lands in the `C` branch server-side, so
306+
`ballastMaterialRequired('')` is true and the hint fired on every untouched new road. */}
307+
{!readOnly &&
308+
form.stBallastMethodCode.trim() !== '' &&
309+
ballastMaterialRequired(form.stBallastMethodCode) && (
310+
<p className="schedule-10__hint">
311+
A material Type is required for this Additional Stabilizing code.
312+
</p>
313+
)}
314+
{!readOnly && figuresZeroed && (
286315
<p className="schedule-10__hint">
287-
A material Type is required for this Additional Stabilizing code.
316+
This Additional Stabilizing code stores its dimensions, actual cost and other transfer as
317+
zero.
288318
</p>
289319
)}
290320
</div>

frontend/src/components/schedule10/RoadDetailPage.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -153,11 +153,10 @@ const RoadDetailPage: FC<RoadDetailPageProps> = ({
153153
onMask={onMask}
154154
/>
155155
<div className="schedule-10__panel-actions">
156-
{!readOnly && (
157-
<Button kind="primary" disabled={controlsDisabled} onClick={onSave}>
158-
Save
159-
</Button>
160-
)}
156+
{/* AC11 and deviation 7: rendered and disabled outside Draft, never removed. */}
157+
<Button kind="primary" disabled={controlsDisabled || readOnly} onClick={onSave}>
158+
Save
159+
</Button>
161160
<Button kind="secondary" onClick={onCloseForm}>
162161
Close
163162
</Button>

0 commit comments

Comments
 (0)