From c60fba4fe930721b03b5037e1a42b1012bbf91ff Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Mon, 27 Jul 2026 17:36:11 +0100 Subject: [PATCH 1/4] add value column in planning parameters column and slideover --- .../supply-chain-dataset-import.test.ts | 2 +- .../pages/supply-chain/shared/cost.test.ts | 42 +++++++++++ .../src/pages/supply-chain/shared/cost.ts | 25 +++++++ .../docs-modal/docs-content/site-overview.tsx | 8 ++ .../docs-modal/docs-content/step-detail.tsx | 6 ++ .../shared/site-aggregation.test.ts | 10 +++ .../step-detail-panel/timing-metrics.tsx | 75 ++++++++++++------- .../supply-chain/shared/supplier-otif.test.ts | 2 +- .../src/pages/supply-chain/shared/types.ts | 21 +++++- .../product/production-schedule.test.tsx | 2 +- .../product/production-schedule/model.test.ts | 2 +- .../site/opportunities.test.ts | 1 + .../site/planning-table.tsx | 23 +++++- .../site/shared/helpers.test.ts | 38 ++++++++++ .../site/shared/helpers.ts | 7 ++ .../site/shared/row-types.ts | 2 + .../site/use-site-overview-rows.ts | 23 +++++- .../src/production-schedule/schema.ts | 3 +- 18 files changed, 251 insertions(+), 41 deletions(-) create mode 100644 apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/shared/helpers.test.ts diff --git a/apps/hash-api/src/seed-data/supply-chain-dataset-import.test.ts b/apps/hash-api/src/seed-data/supply-chain-dataset-import.test.ts index d9130f8b533..eb662cb7c93 100644 --- a/apps/hash-api/src/seed-data/supply-chain-dataset-import.test.ts +++ b/apps/hash-api/src/seed-data/supply-chain-dataset-import.test.ts @@ -51,7 +51,7 @@ describe("supply-chain dataset import", () => { writeJson( path.join(sourceDir, "demo-product", "production_schedule.json"), { - schema_version: "1.1", + schema_version: scheduleVersion === "1.2" ? "1.2" : "1.1", artifact_type: "production_schedule", artifact_version: scheduleVersion, product_id: "demo-product", diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/cost.test.ts b/apps/hash-frontend/src/pages/supply-chain/shared/cost.test.ts index 545e5e01f67..51d541c4fd2 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/cost.test.ts +++ b/apps/hash-frontend/src/pages/supply-chain/shared/cost.test.ts @@ -6,6 +6,7 @@ import { computeDailyCost, computeMonthlyCost, computePeriodCost, + computePeriodMaterialValue, costRatePerKgDay, formatCost, formatNumber, @@ -95,6 +96,47 @@ describe("computePeriodCost", () => { }); }); +describe("computePeriodMaterialValue", () => { + afterEach(() => vi.useRealTimers()); + + const materialValue = { + unit_cost: 2.5, + currency: "USD", + unit_cost_source: "Database", + uom: "KG", + monthly: [ + { month: "2025-07", quantity: 10 }, + { month: "2026-01", quantity: 20 }, + { month: "2026-02", quantity: 30 }, + { month: "2026-05", quantity: 40 }, + { month: "2026-06", quantity: 50 }, + { month: "2026-07", quantity: 60 }, + ], + }; + + it("sums quantities in the selected whole-calendar-month range", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-27T12:00:00Z")); + + expect(computePeriodMaterialValue(materialValue, "3m")).toBe( + (40 + 50 + 60) * 2.5, + ); + expect(computePeriodMaterialValue(materialValue, "6m")).toBe( + (30 + 40 + 50 + 60) * 2.5, + ); + expect(computePeriodMaterialValue(materialValue, "12m")).toBe( + (20 + 30 + 40 + 50 + 60) * 2.5, + ); + }); + + it("returns null only when material-value data is unavailable", () => { + expect(computePeriodMaterialValue(null, "3m")).toBeNull(); + expect( + computePeriodMaterialValue({ ...materialValue, monthly: [] }, "3m"), + ).toBe(0); + }); +}); + describe("formatCost / formatNumber", () => { // Pin locale so thousands/decimal separators are deterministic. afterEach(() => vi.unstubAllGlobals()); diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/cost.ts b/apps/hash-frontend/src/pages/supply-chain/shared/cost.ts index 9bcd7b1ecc8..061685c06f2 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/cost.ts +++ b/apps/hash-frontend/src/pages/supply-chain/shared/cost.ts @@ -1,5 +1,10 @@ import { createContext, useContext } from "react"; +import { cutoffForRange } from "./time-range"; + +import type { TimeRange } from "./time-range"; +import type { MaterialValueData } from "./types"; + export const DEFAULT_WACC = 0.1; export const DEFAULT_STORAGE_COST = 0.4; // currency units/tonne/day export const DEFAULT_CURRENCY = "USD"; @@ -170,6 +175,26 @@ export function computePeriodCost( ); } +/** + * Standard material value passing through a step in the selected whole-calendar + * month range. This deliberately uses the independent material-flow series, + * rather than timing observations affected by outlier or procurement controls. + */ +export function computePeriodMaterialValue( + materialValue: MaterialValueData | null | undefined, + timeRange: TimeRange, +): number | null { + if (!materialValue) { + return null; + } + const cutoff = cutoffForRange(timeRange); + const quantity = materialValue.monthly.reduce( + (sum, bucket) => (bucket.month >= cutoff ? sum + bucket.quantity : sum), + 0, + ); + return quantity * materialValue.unit_cost; +} + /** Recompute daily carrying cost from mean quantity and unit price. */ export function computeDailyCost( meanQty: number | null | undefined, diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/site-overview.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/site-overview.tsx index 45c514b71f8..a4872a53c22 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/site-overview.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/site-overview.tsx @@ -169,6 +169,14 @@ export const siteOverviewSection: DocSectionDef = { {" "} menu.

+

+ Value is the actual quantity that passed through the + step in the selected period, multiplied by its unit cost, to help in + understanding the scale of impact of miscalibrated parameters. + Depending on the step, quantity means material received, produced, + released from QA or transported. It is not carrying cost or + estimated savings, and outlier controls do not change it. +

), }, diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/step-detail.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/step-detail.tsx index 3ee0384a6e6..d6699c1ac79 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/step-detail.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/step-detail.tsx @@ -49,6 +49,12 @@ export const stepDetailDoc: DocEntry = { difference from observed timing and the source note where the plan is not the default planned-delivery-time field. +
  • + Value, when available, is the actual quantity received, + produced, released or transported during the selected period + multiplied by the unit cost, to help in understanding the scale of + impact of miscalibrated parameters. +
  • Evidence and actions

    diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/site-aggregation.test.ts b/apps/hash-frontend/src/pages/supply-chain/shared/site-aggregation.test.ts index 15063b54875..888b60ef2b7 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/site-aggregation.test.ts +++ b/apps/hash-frontend/src/pages/supply-chain/shared/site-aggregation.test.ts @@ -75,6 +75,13 @@ describe("deduplicateNodes", () => { plant: "PL-A", supplier_id: "A", receipt_basis: "ordinary", + material_value: { + unit_cost: 2, + currency: "USD", + unit_cost_source: "Database", + uom: "KG", + monthly: [{ month: "2026-01", quantity: 100 }], + }, }); const supplierB = node({ ...supplierA, @@ -110,6 +117,9 @@ describe("deduplicateNodes", () => { expect( procurement.find((row) => row.supplier_id === "B")?.products, ).toHaveLength(1); + expect( + procurement.find((row) => row.supplier_id === "A")?.material_value, + ).toEqual(supplierA.material_value); }); it("merges shared raw-material nodes across products by material, plant, type, and series", () => { diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/timing-metrics.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/timing-metrics.tsx index e972b9379a7..703ed6963f0 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/timing-metrics.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/timing-metrics.tsx @@ -5,6 +5,7 @@ import { css, cx } from "@hashintel/ds-helpers/css"; import { isDwellType } from "../categories"; import { useCostParams, + computePeriodMaterialValue, computePeriodCost, formatCost, formatNumber, @@ -45,6 +46,13 @@ const cellCenter = css({ flexDirection: "column", }); const cellPlain = css({ flex: "1", p: "3" }); +const cellSeparated = css({ + flex: "1", + p: "3", + borderLeftWidth: "1px", + borderLeftStyle: "solid", + borderLeftColor: "bd.subtle", +}); const label = css({ textStyle: "xs", fontWeight: "medium", @@ -74,11 +82,6 @@ const valueBase = css({ }); const valueDanger = css({ color: "status.error.fg.body" }); const valueStrong = css({ color: "fg.max" }); -const valueMuted = css({ - textStyle: "base", - fontWeight: "medium", - color: "fg.subtle", -}); const badge = css({ textStyle: "xs", fontWeight: "medium", @@ -105,10 +108,13 @@ const badgeNeutral = css({ borderColor: "bd.subtle", borderRadius: "sm", px: "1.5", - h: "4", + py: "0.5", + minH: "4", lineHeight: "[11px]", display: "inline-flex", alignItems: "center", + whiteSpace: "nowrap", + flexShrink: "0", }); const policyCard = css({ borderWidth: "1px", @@ -219,7 +225,7 @@ export const KeyMetricsRow = ({ // Secondary procurement lead-time figure. `complete_timing` carries the other // basis after load-time row derivation (full-receipt when the headline is // first, and vice-versa once the basis swap runs), so this cell shows the - // counterpart and its gap vs the headline. + // counterpart and its gap from the currently selected receipt basis. const secondaryStats = step.complete_timing?.stats; const secondaryValue = secondaryStats ? selectStat(secondaryStats, effectiveMeasure) @@ -231,6 +237,8 @@ export const KeyMetricsRow = ({ : null; const secondaryLabel = basis === "complete" ? "First receipt" : "Full receipt"; + const headlineBasisLabel = + basis === "complete" ? "full receipt" : "first receipt"; const showCost = isDwellType(step.type) && step.cost != null; const costComparison = useMemo(() => { @@ -263,6 +271,22 @@ export const KeyMetricsRow = ({ storageCost, ) : null; + const materialValue = unfilteredStep.material_value; + const periodMaterialValue = computePeriodMaterialValue( + materialValue, + timeRange, + ); + const materialValueTitle = materialValue + ? [ + `Unit cost: ${formatCost(materialValue.unit_cost, materialValue.currency)}`, + materialValue.uom ? `per ${materialValue.uom}` : null, + materialValue.unit_cost_source + ? `source: ${materialValue.unit_cost_source}` + : null, + ] + .filter(Boolean) + .join("; ") + : undefined; const planNote = step.plan_note; const planLabel = planNote ? planSourceLabel(planNote) : null; @@ -303,26 +327,6 @@ export const KeyMetricsRow = ({ -
    -
    Median vs previous period
    -
    - {comparison?.medianPctChange != null ? ( - - ) : ( - - )} -
    -
    - {secondaryValue != null && (
    @@ -334,7 +338,9 @@ export const KeyMetricsRow = ({ {secondaryGap != null && Math.abs(secondaryGap) >= 0.5 && ( - {`${secondaryGap > 0 ? "+" : "\u2212"}${formatNumber(Math.abs(secondaryGap), { maximumFractionDigits: 0 })}d vs headline`} + {`${formatNumber(Math.abs(secondaryGap), { maximumFractionDigits: 0 })}d ${ + secondaryGap > 0 ? "later" : "earlier" + } than ${headlineBasisLabel}`} )}
    @@ -374,6 +380,19 @@ export const KeyMetricsRow = ({
    )} + + {materialValue && ( +
    +
    Value
    +
    + + {formatCost(periodMaterialValue, materialValue.currency, { + compact: true, + })} + +
    +
    + )} ); diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/supplier-otif.test.ts b/apps/hash-frontend/src/pages/supply-chain/shared/supplier-otif.test.ts index bcf3db86b64..9a991301fbf 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/supplier-otif.test.ts +++ b/apps/hash-frontend/src/pages/supply-chain/shared/supplier-otif.test.ts @@ -162,7 +162,7 @@ describe("supplier line materialisation", () => { it("materialises site vendor monthly, worst events, and materials from raw lines", () => { const perf: SiteSupplierPerformance = { - schema_version: "1.1", + schema_version: "1.2", generated_at: "2026-06-01T00:00:00Z", overall: { n_lines: 3, diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/types.ts b/apps/hash-frontend/src/pages/supply-chain/shared/types.ts index ec40363e83f..6ee00ac12a5 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/types.ts +++ b/apps/hash-frontend/src/pages/supply-chain/shared/types.ts @@ -45,6 +45,17 @@ export interface CostData { unit_price_source?: string | null; } +export interface MaterialValueData { + unit_cost: number; + currency: string; + unit_cost_source: string | null; + uom: string | null; + monthly: Array<{ + month: string; + quantity: number; + }>; +} + /** * Source provenance for a step's durations. Attached by the corresponding * `extract_*` function on the backend so the UI can show users where the @@ -188,6 +199,7 @@ export interface GraphNode { /** Client-derived from the active timing series and `plan`; omitted on the wire. */ pct_exceeding_plan?: number | null; cost: CostData | null; + material_value?: MaterialValueData | null; observations?: Observation[]; /** Client-side cache of combined procurement node observations from the wire. */ procurement_observations?: ProcurementNodeObservation[]; @@ -397,7 +409,7 @@ export interface BindingScore { export interface GraphData { /** Procurement planning data contract version. */ - schema_version?: "1.1"; + schema_version?: "1.2"; analysis_settings?: AnalysisSettings | null; product_id: string; product_name: string; @@ -596,7 +608,7 @@ export interface SiteData { export interface StepDetail { /** Procurement planning data contract version. */ - schema_version?: "1.1"; + schema_version?: "1.2"; id: string; label: string; type: StepType; @@ -613,6 +625,7 @@ export interface StepDetail { /** Client-derived from the active timing series and `plan`; omitted on the wire. */ pct_exceeding_plan?: number | null; cost: CostData | null; + material_value?: MaterialValueData | null; detail_rows?: DetailRows | null; ref_date_col?: string | null; /** @@ -776,7 +789,7 @@ export interface ProcurementSupplierBlock { */ export interface SiteSupplierPerformance { /** Procurement planning data contract version. */ - schema_version?: "1.1"; + schema_version?: "1.2"; generated_at: string; overall: { n_lines: number; @@ -830,7 +843,7 @@ export interface SiteSummaryRollups { /** `site/{siteId}/summary.json` — the precomputed site overview artifact. */ export interface SiteSummary { - schema_version?: "1.1"; + schema_version?: "1.2"; analysis_settings?: AnalysisSettings | null; site_id: string; generated_at: string; diff --git a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/production-schedule.test.tsx b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/production-schedule.test.tsx index 5f60fa680af..28b3f899a04 100644 --- a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/production-schedule.test.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/production-schedule.test.tsx @@ -61,7 +61,7 @@ const makeBatch = ( }); const schedule: ProductionScheduleV12 = { - schema_version: "1.1", + schema_version: "1.2", artifact_type: "production_schedule", artifact_version: "1.2", product_id: "product", diff --git a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/production-schedule/model.test.ts b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/production-schedule/model.test.ts index caba66b0730..69b359db896 100644 --- a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/production-schedule/model.test.ts +++ b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/production-schedule/model.test.ts @@ -203,7 +203,7 @@ const v12Schedule = ({ links?: ProductionScheduleV12["batch_links"]; dispatchEvents?: ProductionScheduleV12["dispatch_events"]; }): ProductionScheduleV12 => ({ - schema_version: schedule.schema_version, + schema_version: "1.2", artifact_type: schedule.artifact_type, artifact_version: "1.2", product_id: schedule.product_id, diff --git a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/opportunities.test.ts b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/opportunities.test.ts index 5544533e0a7..64b326844eb 100644 --- a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/opportunities.test.ts +++ b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/opportunities.test.ts @@ -66,6 +66,7 @@ function planning(overrides: Partial): PlanningRow { pct_exceeding_plan: 30, cost: null, products: [{ id: "p1", name: "Product 1" }], + periodMaterialValue: null, deviationPct: 20, trendPct: null, previousValue: null, diff --git a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/planning-table.tsx b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/planning-table.tsx index e2e119d8fae..610582a58d8 100644 --- a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/planning-table.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/planning-table.tsx @@ -4,7 +4,7 @@ import { cx } from "@hashintel/ds-helpers/css"; import { StatusActionButton } from "../../shared/action-buttons"; import { getCategoryColor } from "../../shared/categories"; -import { formatNumber } from "../../shared/cost"; +import { formatCost, formatNumber } from "../../shared/cost"; import { MEASURE_LABELS, selectStat, @@ -19,6 +19,7 @@ import { type StatusActionLabel, type StatusStore, } from "../../shared/status"; +import { useTimeRange } from "../../shared/time-range-context"; import { TrendIndicator } from "../../shared/trend-indicator"; import { buildColumnFilter, countBy } from "./shared/column-filter"; import { ColumnHeader } from "./shared/column-header"; @@ -132,6 +133,7 @@ export const PlanningTable = ({ onStatusHiddenChange: (next: Set) => void; }) => { const { measure } = useBaseMeasure(); + const { timeRange } = useTimeRange(); const measureLabel = MEASURE_LABELS[measure]; const { @@ -239,6 +241,16 @@ export const PlanningTable = ({ + + toggleSort("materialValue"), + }} + /> + + + {formatCost( + row.periodMaterialValue, + row.material_value?.currency ?? null, + { compact: true }, + )} + {formatNumber(row.plan, { maximumFractionDigits: 0 })}d @@ -428,7 +447,7 @@ export const PlanningTable = ({ })} {displayedRows.length === 0 && ( - + {rows.length === 0 ? "No planning parameter data for this site." : "No planning parameter data matches the current filters."} diff --git a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/shared/helpers.test.ts b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/shared/helpers.test.ts new file mode 100644 index 00000000000..abe90005a43 --- /dev/null +++ b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/shared/helpers.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { sortPlanningRows } from "./helpers"; + +import type { PlanningRow } from "./row-types"; + +const planningRow = ( + label: string, + periodMaterialValue: number | null, +): PlanningRow => + ({ + label, + periodMaterialValue, + }) as unknown as PlanningRow; + +describe("sortPlanningRows material value", () => { + const rows = [ + planningRow("missing", null), + planningRow("high", 500), + planningRow("low", 100), + ]; + + it("sorts ascending with missing values last", () => { + expect( + sortPlanningRows(rows, { key: "materialValue", dir: "asc" }).map( + (row) => row.label, + ), + ).toEqual(["low", "high", "missing"]); + }); + + it("sorts descending with missing values last", () => { + expect( + sortPlanningRows(rows, { key: "materialValue", dir: "desc" }).map( + (row) => row.label, + ), + ).toEqual(["high", "low", "missing"]); + }); +}); diff --git a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/shared/helpers.ts b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/shared/helpers.ts index b1d95a3edbc..74337cab19e 100644 --- a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/shared/helpers.ts +++ b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/shared/helpers.ts @@ -165,6 +165,13 @@ export function sortPlanningRows( : Number.POSITIVE_INFINITY; va = left.deviationPct ?? nullRank; vb = right.deviationPct ?? nullRank; + } else if (sort.key === "materialValue") { + const nullRank = + sort.dir === "desc" + ? Number.NEGATIVE_INFINITY + : Number.POSITIVE_INFINITY; + va = left.periodMaterialValue ?? nullRank; + vb = right.periodMaterialValue ?? nullRank; } else if (sort.key === "exceeding") { va = left.pct_exceeding_plan ?? 0; vb = right.pct_exceeding_plan ?? 0; diff --git a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/shared/row-types.ts b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/shared/row-types.ts index f619c488984..3789d1b7f05 100644 --- a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/shared/row-types.ts +++ b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/shared/row-types.ts @@ -7,6 +7,7 @@ export type Tab = "dwell" | "planning" | "trends" | "suppliers"; export type SortKey = | "median" | "cost" + | "materialValue" | "moq" | "safetyStock" | "material" @@ -46,6 +47,7 @@ export type DwellRow = SiteNode & { }; export type PlanningRow = SiteNode & { + periodMaterialValue: number | null; /** Null when the applicable plan is zero: the row remains visible but no percentage is meaningful. */ deviationPct: number | null; trendPct: number | null; diff --git a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/use-site-overview-rows.ts b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/use-site-overview-rows.ts index 059d50b5b6c..2861a764a4d 100644 --- a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/use-site-overview-rows.ts +++ b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/use-site-overview-rows.ts @@ -1,7 +1,11 @@ import { useEffect, useMemo, useState } from "react"; import { DWELL_TYPES } from "../../shared/categories"; -import { useCostParams, useOutlierSetting } from "../../shared/cost"; +import { + computePeriodMaterialValue, + useCostParams, + useOutlierSetting, +} from "../../shared/cost"; import { fetchSupplierPerformance } from "../../shared/data"; import { selectStat, useBaseMeasure } from "../../shared/measure-context"; import { applyOutlierSelectionToNode } from "../../shared/outlier-selection"; @@ -130,6 +134,10 @@ export function useSiteOverviewRows({ return new Map(historicalNodes.map((count) => [siteNodeKey(count), count])); }, [historicalNodes]); + const sourceNodesByKey = useMemo(() => { + return new Map(dedupedNodes.map((count) => [siteNodeKey(count), count])); + }, [dedupedNodes]); + const planningVisibleNodes = useMemo(() => { if (!excludeLowSamples) { return filteredNodes; @@ -222,6 +230,7 @@ export function useSiteOverviewRows({ return []; } const historical = historicalNodesByKey.get(siteNodeKey(count)) ?? count; + const source = sourceNodesByKey.get(siteNodeKey(count)) ?? count; const trend = computeTimingTrend(historical, timeRange, measure); const deviationPct = plan <= 0 @@ -237,13 +246,23 @@ export function useSiteOverviewRows({ return { ...count, plan, + periodMaterialValue: computePeriodMaterialValue( + source.material_value, + timeRange, + ), deviationPct, trendPct: trend.pctChange, previousValue: trend.previousValue, previousTrendN: trend.previousN, }; }); - }, [planningVisibleNodes, historicalNodesByKey, timeRange, measure]); + }, [ + planningVisibleNodes, + historicalNodesByKey, + sourceNodesByKey, + timeRange, + measure, + ]); const windowedSupplier = useMemo(() => { if (!supplierData) { diff --git a/libs/@local/hash-isomorphic-utils/src/production-schedule/schema.ts b/libs/@local/hash-isomorphic-utils/src/production-schedule/schema.ts index c437159db17..091389d47af 100644 --- a/libs/@local/hash-isomorphic-utils/src/production-schedule/schema.ts +++ b/libs/@local/hash-isomorphic-utils/src/production-schedule/schema.ts @@ -254,7 +254,6 @@ export const productionScheduleDispatchEventSchema = z.strictObject({ }); const scheduleBaseShape = { - schema_version: z.literal("1.1"), artifact_type: z.literal("production_schedule"), product_id: nonEmptyString, product_name: nonEmptyString, @@ -273,6 +272,7 @@ const sourceBaseShape = { export const productionScheduleLegacySchema = z.strictObject({ ...scheduleBaseShape, + schema_version: z.literal("1.1"), artifact_version: z.union([z.literal("1.0"), z.literal("1.1")]), lanes: z.array(productionScheduleLegacyLaneSchema).min(1), consumption_evidence: z.array(productionScheduleConsumptionEvidenceSchema), @@ -285,6 +285,7 @@ export const productionScheduleLegacySchema = z.strictObject({ export const productionScheduleV12Schema = z.strictObject({ ...scheduleBaseShape, + schema_version: z.literal("1.2"), artifact_version: z.literal("1.2"), lanes: z.array(productionScheduleV12LaneSchema).min(1), consumption_events: z.array(productionScheduleConsumptionEventSchema), From f1d7652249ae72d84e6ea67c2ca8d570313bb8fb Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Mon, 27 Jul 2026 13:10:47 +0100 Subject: [PATCH 2/4] fix potential stylesheet cascade issues, add regression test for slideover visibility --- .../shared/action-button-styles.ts | 9 ++-- .../supply-chain/shared/action-buttons.tsx | 28 +++++++---- .../supply-chain/shared/docs/docs-modal.tsx | 11 +++-- .../supply-chain/shared/header-actions.tsx | 8 ++-- .../supply-chain/shared/segmented-control.tsx | 2 +- .../supply-chain/shared/slide-over.test.tsx | 48 +++++++++++++++++++ .../pages/supply-chain/shared/slide-over.tsx | 5 +- .../supply-chain/shared/step-detail-panel.tsx | 2 +- .../product/e2e-what-if/what-if-kpis.tsx | 4 +- .../production-schedule/schedule-summary.tsx | 16 +++---- .../supply-chain-data-shell/site.tsx | 8 ++-- .../site/shared/filter-menu.tsx | 7 ++- 12 files changed, 108 insertions(+), 40 deletions(-) create mode 100644 apps/hash-frontend/src/pages/supply-chain/shared/slide-over.test.tsx diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/action-button-styles.ts b/apps/hash-frontend/src/pages/supply-chain/shared/action-button-styles.ts index 21d88385cb3..e740947d73c 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/action-button-styles.ts +++ b/apps/hash-frontend/src/pages/supply-chain/shared/action-button-styles.ts @@ -28,15 +28,18 @@ export const neutralActionButtonStyle = css({ borderRadius: "sm", borderWidth: "1px", borderStyle: "solid", - borderColor: "bd.subtle", - bg: "bgSolid.min", px: "2.5", py: "1", textStyle: "xs", lineHeight: "none", fontWeight: "medium", - color: "fg.muted", cursor: "pointer", whiteSpace: "nowrap", +}); + +export const neutralActionButtonToneStyle = css({ + borderColor: "bd.subtle", + bg: "bgSolid.min", + color: "fg.muted", _hover: { borderColor: "bd.strong", color: "fg.heading" }, }); diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/action-buttons.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/action-buttons.tsx index b9f7c46fba4..ac12520d56b 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/action-buttons.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/shared/action-buttons.tsx @@ -5,6 +5,7 @@ import { Link } from "../../../shared/ui/link"; import { briefLinkStyle, neutralActionButtonStyle, + neutralActionButtonToneStyle, } from "./action-button-styles"; import { StatusIcon } from "./status-dialog"; @@ -22,13 +23,12 @@ const docsButton = css({ borderWidth: "1px", borderStyle: "solid", borderColor: "bd.subtle", - bg: "bgSolid.min", - color: "fg.subtle", cursor: "pointer", transition: "colors", _hover: { color: "fg.heading", bg: "bg.subtle" }, }); const docsButtonActive = css({ color: "fg.heading", bg: "bg.subtle" }); +const docsButtonInactive = css({ color: "fg.subtle", bg: "bgSolid.min" }); const statusSuccess = css({ borderColor: "status.success.bd.subtle", bg: "status.success.bg.subtle", @@ -62,7 +62,11 @@ export const DocsIconButton = ({ return ( From e9989a5d5df841b4f9429c148747e20c53daaeb4 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Mon, 27 Jul 2026 17:45:37 +0100 Subject: [PATCH 3/4] fixes to slideover open/close animation --- .../supply-chain/shared/slide-over.test.tsx | 55 +++++++++++++++++-- .../pages/supply-chain/shared/slide-over.tsx | 26 +++++---- 2 files changed, 65 insertions(+), 16 deletions(-) diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/slide-over.test.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/slide-over.test.tsx index 3acee97fce9..c775204676d 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/slide-over.test.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/shared/slide-over.test.tsx @@ -1,8 +1,15 @@ // @vitest-environment jsdom -import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { SlideOver } from "./slide-over"; +import { SlideOver, SlideOverClose } from "./slide-over"; afterEach(() => { cleanup(); @@ -30,7 +37,9 @@ describe("SlideOver", () => { ); expect(backdrop).not.toBeNull(); - expect(positioner.style.right).toBe("-960px"); + expect(positioner.style.inset).toBe("0"); + expect(positioner.style.right).toBe(""); + expect(dialog.style.right).toBe("-960px"); expect(backdrop?.style.opacity).toBe("0"); act(() => { @@ -41,8 +50,46 @@ describe("SlideOver", () => { }); await waitFor(() => { - expect(positioner.style.right).toBe("0px"); + expect(dialog.style.right).toBe("0px"); expect(backdrop?.style.opacity).toBe("1"); }); }); + + it("slides the panel right without moving its viewport positioner on close", () => { + const animationFrames: FrameRequestCallback[] = []; + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + animationFrames.push(callback); + return animationFrames.length; + }); + + render( + {}}> + + {(close) => ( + + )} + + , + ); + + const dialog = screen.getByRole("dialog", { name: "Step details" }); + const positioner = dialog.parentElement as HTMLElement; + + act(() => { + const queuedFrames = animationFrames.splice(0); + for (const animationFrame of queuedFrames) { + animationFrame(0); + } + }); + expect(dialog.style.right).toBe("0px"); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + + expect(positioner.style.inset).toBe("0"); + expect(positioner.style.right).toBe(""); + expect(dialog.style.right).toBe("-960px"); + expect(dialog.style.transition).toBe("right 200ms ease-out"); + }); }); diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/slide-over.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/slide-over.tsx index a4171a3a682..15ad6c10a55 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/slide-over.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/shared/slide-over.tsx @@ -18,12 +18,13 @@ import { css, cx } from "@hashintel/ds-helpers/css"; * click. Portals into the supply-chain layout scope so tokens resolve. Caller * owns header + body. * - * Enter/exit animation: the panel slides via the positioner's `right` offset and - * the backdrop fades, driven by a local `visible` flag. We deliberately animate - * `right` (not `transform`) so no containing block is created — the data-table - * modal renders inline inside the content with `position: fixed` and must stay - * relative to the viewport. On close we play the exit animation, then defer the - * caller's `onClose` (which unmounts us) until the transition finishes. + * Enter/exit animation: the panel slides via its `right` offset while the + * full-viewport positioner stays fixed and the backdrop fades, driven by a + * local `visible` flag. We deliberately animate `right` (not `transform`) so no + * containing block is created — the data-table modal renders inline inside the + * content with `position: fixed` and must stay relative to the viewport. On + * close we play the exit animation, then defer the caller's `onClose` (which + * unmounts us) until the transition finishes. */ const EXIT_MS = 200; @@ -70,14 +71,9 @@ const backdropBase = css({ const positionerBase = css({ position: "fixed", - top: "0", - bottom: "0", - right: "[-960px]", display: "flex", zIndex: "modal", - transition: "[right 200ms ease-out]", }); -const positionerVisible = css({ right: "0" }); const contentStyles = css({ height: "full", @@ -125,11 +121,17 @@ export const SlideOver = ({ style={{ opacity: visible ? 1 : 0 }} /> {children} From 3ca38f98588452c4b9b423afc114a9dde9ed07ab Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Mon, 27 Jul 2026 17:59:24 +0100 Subject: [PATCH 4/4] linting / PR feedback --- .../supply-chain-data-shell/site/planning-table.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/planning-table.tsx b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/planning-table.tsx index 610582a58d8..2a72eeed117 100644 --- a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/planning-table.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/planning-table.tsx @@ -19,7 +19,6 @@ import { type StatusActionLabel, type StatusStore, } from "../../shared/status"; -import { useTimeRange } from "../../shared/time-range-context"; import { TrendIndicator } from "../../shared/trend-indicator"; import { buildColumnFilter, countBy } from "./shared/column-filter"; import { ColumnHeader } from "./shared/column-header"; @@ -133,7 +132,6 @@ export const PlanningTable = ({ onStatusHiddenChange: (next: Set) => void; }) => { const { measure } = useBaseMeasure(); - const { timeRange } = useTimeRange(); const measureLabel = MEASURE_LABELS[measure]; const { @@ -243,7 +241,7 @@ export const PlanningTable = ({