diff --git a/ui/app/_fallbacks/enterprise/components/user-groups/sheets/customerDetailSheet.tsx b/ui/app/_fallbacks/enterprise/components/user-groups/sheets/customerDetailSheet.tsx index 2ee0c195637..858e4d32ef8 100644 --- a/ui/app/_fallbacks/enterprise/components/user-groups/sheets/customerDetailSheet.tsx +++ b/ui/app/_fallbacks/enterprise/components/user-groups/sheets/customerDetailSheet.tsx @@ -1,7 +1,7 @@ import { CopyableId } from "@/components/copyableId"; import { Label } from "@/components/ui/label"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; -import { resetDurationLabels } from "@/lib/constants/governance"; +import { fiscalQuarterNote, resetDurationLabels } from "@/lib/constants/governance"; import { Customer } from "@/lib/types/governance"; import { cn } from "@/lib/utils"; import { formatCompactNumber } from "@/lib/utils/numbers"; @@ -32,7 +32,17 @@ function DetailCard({ title, children, contentClassName }: { title: string; chil ); } -function BudgetLineBar({ current, max, resetDuration }: { current: number; max: number; resetDuration?: string }) { +function BudgetLineBar({ + current, + max, + resetDuration, + resetConfig, +}: { + current: number; + max: number; + resetDuration?: string; + resetConfig?: { quarter_start_month?: number }; +}) { const pct = max > 0 ? Math.min((current / max) * 100, 100) : 0; const isOver80 = pct >= 80; const isOver100 = pct >= 100; @@ -40,7 +50,10 @@ function BudgetLineBar({ current, max, resetDuration }: { current: number; max:
Resets {formatResetDuration(b.reset_duration, calendarAligned)}
++ Resets {formatResetDuration(b.reset_duration, calendarAligned)} + {fiscalQuarterNote(b.reset_duration, b.reset_config)} +
) : null} diff --git a/ui/lib/budgetOutline.ts b/ui/lib/budgetOutline.ts index 7f1d4da3501..448c3e59e10 100644 --- a/ui/lib/budgetOutline.ts +++ b/ui/lib/budgetOutline.ts @@ -3,9 +3,13 @@ // compact mono budget labels, allocation-bar math, and the neutral swatch ramp // consistent across every screen that renders a provider config. +import { fiscalQuarterNote } from "./constants/governance"; + export interface BudgetLineLike { max_limit?: number | null; reset_duration?: string; + // Fiscal quarter definition; only meaningful when reset_duration is "1Q". + reset_config?: { quarter_start_month?: number }; } // Short, mono-friendly period suffixes (e.g. "$20/wk"). Falls back to the raw @@ -49,10 +53,14 @@ export function money(value: number | null | undefined): string { // Returns `fallback` when empty. export function budgetLinesLabel(budgets: BudgetLineLike[] | undefined, fallback = "No budget"): string { if (!budgets || budgets.length === 0) return fallback; - return [...budgets] + const label = [...budgets] .sort((a, b) => periodRank(a.reset_duration) - periodRank(b.reset_duration)) .map((b) => `${money(b.max_limit)}/${shortPeriod(b.reset_duration)}`) .join(" · "); + // A group has at most one quarterly line (duplicate periods are blocked), so a + // single trailing fiscal-start note is unambiguous. Empty for a January start. + const quarterly = budgets.find((b) => b.reset_duration?.endsWith("Q")); + return label + fiscalQuarterNote(quarterly?.reset_duration, quarterly?.reset_config); } // True when two or more budget lines share the same reset period. A budget diff --git a/ui/lib/constants/governance.ts b/ui/lib/constants/governance.ts index e4aeccc9234..a42a8a7f744 100644 --- a/ui/lib/constants/governance.ts +++ b/ui/lib/constants/governance.ts @@ -66,6 +66,21 @@ export function formatQuarterPreview(startMonth?: number): string { .join(" · "); } +/** + * Compact read-only note naming a quarterly budget's fiscal-year start, e.g. + * " · FY starts Apr". Returns "" for non-quarterly budgets and for a January / + * unset start (the default), so it only ever appears when it changes behaviour. + * Callers append it after the reset-period label (which already reads "Quarterly"). + */ +export function fiscalQuarterNote(resetDuration?: string, resetConfig?: { quarter_start_month?: number } | null): string { + if (!resetDuration || !resetDuration.endsWith("Q")) return ""; + const start = resetConfig?.quarter_start_month; + // Number.isInteger also rejects undefined/NaN; a fractional month like 2.5 would + // otherwise pass the range check and index MONTH_ABBREVIATIONS between slots. + if (start === undefined || !Number.isInteger(start) || start === 1 || start < 1 || start > 12) return ""; + return ` · FY starts ${MONTH_ABBREVIATIONS[start - 1]}`; +} + // Month choices for the fiscal quarter start select. export const quarterStartMonthOptions = MONTH_ABBREVIATIONS.map((_, index) => ({ label: new Date(Date.UTC(2026, index, 1)).toLocaleString("en-US", { month: "long", timeZone: "UTC" }), diff --git a/ui/lib/utils/governance.test.ts b/ui/lib/utils/governance.test.ts index 1bc7dc32bda..d800d65bb5e 100644 --- a/ui/lib/utils/governance.test.ts +++ b/ui/lib/utils/governance.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { periodRank, shortPeriod } from "@/lib/budgetOutline"; import { budgetResetDurationOptions, + fiscalQuarterNote, formatQuarterPreview, resetDurationLabels, resetDurationOptions, @@ -117,6 +118,31 @@ describe("fiscal quarter preview", () => { expect(formatQuarterPreview(1.5)).toBe(formatQuarterPreview(1)); }); }); +describe("fiscalQuarterNote", () => { + it("names the fiscal start only for a non-January quarterly budget", () => { + expect(fiscalQuarterNote("1Q", { quarter_start_month: 4 })).toBe(" · FY starts Apr"); + expect(fiscalQuarterNote("1Q", { quarter_start_month: 10 })).toBe(" · FY starts Oct"); + }); + + it("is empty for a January or unset start (the default)", () => { + expect(fiscalQuarterNote("1Q", { quarter_start_month: 1 })).toBe(""); + expect(fiscalQuarterNote("1Q", {})).toBe(""); + expect(fiscalQuarterNote("1Q", undefined)).toBe(""); + }); + + it("is empty for a non-quarterly duration regardless of config", () => { + expect(fiscalQuarterNote("1M", { quarter_start_month: 4 })).toBe(""); + expect(fiscalQuarterNote(undefined, { quarter_start_month: 4 })).toBe(""); + }); + + it("is empty for an out-of-range or non-integer month", () => { + expect(fiscalQuarterNote("1Q", { quarter_start_month: 0 })).toBe(""); + expect(fiscalQuarterNote("1Q", { quarter_start_month: 13 })).toBe(""); + // A fractional month sits in range but indexes MONTH_ABBREVIATIONS between + // slots, which would render "FY starts undefined" without the integer guard. + expect(fiscalQuarterNote("1Q", { quarter_start_month: 2.5 })).toBe(""); + }); +}); describe("budgetSignature", () => { const quarterly = (quarterStartMonth?: number) => [ {