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/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 (
@@ -104,16 +108,25 @@ export const NeutralActionButton = ({
icon,
children,
className,
+ tone = "neutral",
}: {
onClick: MouseEventHandler;
icon?: ReactNode;
children: ReactNode;
className?: string;
+ tone?: StatusActionState["tone"];
}) => {
+ const toneClassName =
+ tone === "success"
+ ? statusSuccess
+ : tone === "danger"
+ ? statusDanger
+ : neutralActionButtonToneStyle;
+
return (
{icon}
@@ -136,11 +149,8 @@ export const StatusActionButton = ({
}
- className={cx(
- state.tone === "success" && statusSuccess,
- state.tone === "danger" && statusDanger,
- className,
- )}
+ className={className}
+ tone={state.tone}
>
{state.label}
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.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal.tsx
index 9e959011950..f5b4a6ece77 100644
--- a/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal.tsx
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal.tsx
@@ -95,13 +95,11 @@ const navEntry = css({
pr: "4",
py: "1",
textStyle: "sm",
- color: "fg.subtle",
cursor: "pointer",
bg: "[transparent]",
border: "none",
borderLeftWidth: "2px",
borderLeftStyle: "solid",
- borderLeftColor: "[transparent]",
_hover: { color: "fg.muted" },
});
const navEntryActive = css({
@@ -109,6 +107,10 @@ const navEntryActive = css({
fontWeight: "medium",
borderLeftColor: "fg.heading",
});
+const navEntryInactive = css({
+ color: "fg.subtle",
+ borderLeftColor: "[transparent]",
+});
const content = css({
flex: "1",
minW: "0",
@@ -334,8 +336,9 @@ export const DocsModal = ({
className={cx(
navEntry,
section.id === activeSection &&
- entry.id === activeEntryId &&
- navEntryActive,
+ entry.id === activeEntryId
+ ? navEntryActive
+ : navEntryInactive,
)}
onClick={() =>
navigate({ section: section.id, sub: entry.id })
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/header-actions.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/header-actions.tsx
index dbae5c2044a..886ad821cf8 100644
--- a/apps/hash-frontend/src/pages/supply-chain/shared/header-actions.tsx
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/header-actions.tsx
@@ -39,13 +39,12 @@ const iconButton = 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 iconButtonActive = css({ color: "fg.heading", bg: "bg.subtle" });
+const iconButtonInactive = css({ color: "fg.subtle", bg: "bgSolid.min" });
const settingsWrap = css({
mt: "3",
borderTopWidth: "1px",
@@ -143,7 +142,10 @@ export const HeaderActionButtons = ({
{
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/slide-over.test.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/slide-over.test.tsx
new file mode 100644
index 00000000000..c775204676d
--- /dev/null
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/slide-over.test.tsx
@@ -0,0 +1,95 @@
+// @vitest-environment jsdom
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { SlideOver, SlideOverClose } from "./slide-over";
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+describe("SlideOver", () => {
+ it("moves the panel and backdrop into their visible state", async () => {
+ const animationFrames: FrameRequestCallback[] = [];
+ vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
+ animationFrames.push(callback);
+ return animationFrames.length;
+ });
+
+ render(
+ {}}>
+ Step content
+ ,
+ );
+
+ const dialog = screen.getByRole("dialog", { name: "Step details" });
+ const positioner = dialog.parentElement as HTMLElement;
+ const backdrop = document.querySelector(
+ '[data-scope="dialog"][data-part="backdrop"]',
+ );
+
+ expect(backdrop).not.toBeNull();
+ expect(positioner.style.inset).toBe("0");
+ expect(positioner.style.right).toBe("");
+ expect(dialog.style.right).toBe("-960px");
+ expect(backdrop?.style.opacity).toBe("0");
+
+ act(() => {
+ const queuedFrames = animationFrames.splice(0);
+ for (const animationFrame of queuedFrames) {
+ animationFrame(0);
+ }
+ });
+
+ await waitFor(() => {
+ 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) => (
+
+ 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 cdb3feff6d0..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;
@@ -65,21 +66,14 @@ const backdropBase = css({
inset: "0",
bg: "neutral.a80",
zIndex: "overlay",
- opacity: "[0]",
transition: "[opacity 200ms ease-out]",
});
-const backdropVisible = css({ opacity: "[1]" });
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",
@@ -123,14 +117,21 @@ export const SlideOver = ({
>
{children}
diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel.tsx
index fb20832729d..95c21efc208 100644
--- a/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel.tsx
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel.tsx
@@ -266,7 +266,6 @@ const segButton = css({
cursor: "pointer",
borderWidth: "1px",
borderStyle: "solid",
- borderColor: "[transparent]",
});
const segButtonActive = css({
bg: "bgSolid.min",
@@ -275,6 +274,7 @@ const segButtonActive = css({
boxShadow: "sm",
});
const segButtonInactive = css({
+ borderColor: "[transparent]",
color: "fg.subtle",
_hover: { color: "fg.muted" },
});
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/e2e-what-if/what-if-kpis.tsx b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/e2e-what-if/what-if-kpis.tsx
index dc2b6b5a1ec..1eb1254cdd5 100644
--- a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/e2e-what-if/what-if-kpis.tsx
+++ b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/e2e-what-if/what-if-kpis.tsx
@@ -5,13 +5,13 @@ import { formatNumber } from "../../../shared/cost";
import type { Caveat } from "./caveats";
const tile = css({
- bg: "bg.subtle",
borderRadius: "lg",
p: "2.5",
display: "flex",
flexDirection: "column",
gap: "1.5",
});
+const tileDefault = css({ bg: "bg.subtle" });
const tileSuccess = css({
bg: "status.success.bg.subtle",
borderWidth: "1px",
@@ -84,7 +84,7 @@ export const KpiTile = ({
: null;
const deltaClass = delta != null && delta < 0 ? deltaGood : deltaMuted;
return (
-
+
{label}
{value}
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/product/production-schedule/schedule-summary.tsx b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/production-schedule/schedule-summary.tsx
index 446150e1b58..a85c1daa114 100644
--- a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/production-schedule/schedule-summary.tsx
+++ b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/production-schedule/schedule-summary.tsx
@@ -1,4 +1,5 @@
import { css, cx } from "@hashintel/ds-helpers/css";
+import { token } from "@hashintel/ds-helpers/tokens";
const INVENTORY_DWELL_COLOR = "#f3f8ff";
const USED_ELSEWHERE_HATCH =
@@ -131,15 +132,12 @@ export const ProductionScheduleSummary = ({
{hasOverDepletedInventory && (
Over-depleted inventory
diff --git a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site.tsx b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site.tsx
index 658e94e1da7..40a13f20079 100644
--- a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site.tsx
+++ b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site.tsx
@@ -196,11 +196,10 @@ const SiteSearchInput = ({
const settingsCollapse = css({
display: "grid",
- gridTemplateRows: "0fr",
- opacity: "0",
transition: "[grid-template-rows 180ms ease, opacity 160ms ease]",
overflow: "hidden",
});
+const settingsCollapseClosed = css({ gridTemplateRows: "0fr", opacity: "0" });
const settingsCollapseOpen = css({ gridTemplateRows: "1fr", opacity: "1" });
const settingsCollapseInner = css({ minH: "0", overflow: "hidden" });
@@ -589,7 +588,10 @@ export const SiteOverview = ({
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..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
@@ -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,
@@ -239,6 +239,16 @@ export const PlanningTable = ({
+
+ toggleSort("materialValue"),
+ }}
+ />
+
+
+ {formatCost(
+ row.periodMaterialValue,
+ row.material_value?.currency ?? null,
+ { compact: true },
+ )}
+
{formatNumber(row.plan, { maximumFractionDigits: 0 })}d
@@ -428,7 +445,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/filter-menu.tsx b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/shared/filter-menu.tsx
index 4375c425fa2..1ce9e40563f 100644
--- a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/shared/filter-menu.tsx
+++ b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/site/shared/filter-menu.tsx
@@ -29,12 +29,12 @@ const triggerButton = css({
justifyContent: "center",
p: "0.5",
borderRadius: "sm",
- color: "fg.subtle",
cursor: "pointer",
transition: "colors",
_hover: { bg: "bg.subtle" },
});
const triggerActive = css({ color: "[#2563eb]" });
+const triggerInactive = css({ color: "fg.subtle" });
const content = css({
display: "flex",
@@ -206,7 +206,10 @@ export const FilterMenu = ({
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),