mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
fix(ui): stop pairing key spend with the team budget when a key has no budget (#37196)
The key overview card and the Virtual Keys spend meter fell back to the parent team's max_budget as the denominator while the numerator stayed the key's own spend, so a $0.50 key on a $1,200 team read as "$0.50 of $1,200 (Team)" and drew a meter against a limit that governs the whole team's aggregate spend, not this key. Both surfaces now show Unlimited for a budgetless key and, when the parent team or organization does carry a budget, a hover hint listing those inherited caps so the reader knows what still gates the key
This commit is contained in:
parent
f04b1dadfb
commit
d0a815d2e1
8 changed files with 229 additions and 35 deletions
|
|
@ -2947,9 +2947,6 @@
|
|||
"max-lines": {
|
||||
"count": 1
|
||||
},
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { ColumnDef } from "@tanstack/react-table";
|
|||
import { Popover, Typography } from "antd";
|
||||
|
||||
import { DataTableMultiSortHeader, DataTableSortHeader, type DataTableSortField } from "@/components/shared/DataTable";
|
||||
import { inheritedBudgetGates } from "@/components/shared/InheritedBudgetHint";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
DateCell,
|
||||
|
|
@ -304,13 +305,14 @@ export const getKeyTableColumns = ({
|
|||
size: 180,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const teamId = row.original.team_id;
|
||||
const team = allTeams.find((t) => t.team_id === teamId);
|
||||
const team = allTeams.find((t) => t.team_id === row.original.team_id);
|
||||
const orgId = row.original.organization_id || row.original.org_id || team?.organization_id;
|
||||
const organization = organizations.find((o) => o.organization_id === orgId);
|
||||
return (
|
||||
<SpendBudgetCell
|
||||
spend={row.original.spend}
|
||||
maxBudget={row.original.max_budget}
|
||||
teamMaxBudget={team?.max_budget ?? null}
|
||||
inheritedGates={row.original.max_budget == null ? inheritedBudgetGates(team, organization) : []}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { InheritedBudgetHint, inheritedBudgetGates } from "./InheritedBudgetHint";
|
||||
|
||||
const team = { team_id: "team-1", team_alias: "Platform", max_budget: 1200, budget_duration: "30d" };
|
||||
const organization = {
|
||||
organization_id: "org-1",
|
||||
organization_alias: "Acme",
|
||||
litellm_budget_table: { max_budget: 5000, budget_duration: null },
|
||||
};
|
||||
|
||||
describe("inheritedBudgetGates", () => {
|
||||
it("returns team then org gates when both have budgets", () => {
|
||||
expect(inheritedBudgetGates(team, organization)).toEqual([
|
||||
{ scope: "Team", alias: "Platform", maxBudget: 1200, budgetDuration: "30d" },
|
||||
{ scope: "Organization", alias: "Acme", maxBudget: 5000, budgetDuration: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips a team or org whose max_budget is null", () => {
|
||||
expect(inheritedBudgetGates({ ...team, max_budget: null }, organization)).toEqual([
|
||||
{ scope: "Organization", alias: "Acme", maxBudget: 5000, budgetDuration: null },
|
||||
]);
|
||||
expect(inheritedBudgetGates(team, { ...organization, litellm_budget_table: { max_budget: null } })).toEqual([
|
||||
{ scope: "Team", alias: "Platform", maxBudget: 1200, budgetDuration: "30d" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns nothing when team and org are missing or budgetless", () => {
|
||||
expect(inheritedBudgetGates(null, undefined)).toEqual([]);
|
||||
expect(
|
||||
inheritedBudgetGates({ ...team, max_budget: null }, { ...organization, litellm_budget_table: null }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to ids when aliases are empty", () => {
|
||||
expect(
|
||||
inheritedBudgetGates({ ...team, team_alias: "" }, { ...organization, organization_alias: "" }).map(
|
||||
(g) => g.alias,
|
||||
),
|
||||
).toEqual(["team-1", "org-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InheritedBudgetHint", () => {
|
||||
it("renders nothing without gates", () => {
|
||||
const { container } = render(<InheritedBudgetHint gates={[]} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("shows each gate with its budget and duration on hover", async () => {
|
||||
render(<InheritedBudgetHint gates={inheritedBudgetGates(team, organization)} />);
|
||||
await userEvent.setup().hover(screen.getByLabelText("question-circle"));
|
||||
expect(screen.getByTestId("inherited-budget-hint")).toHaveTextContent("Team Platform: $1,200.00 / 30d");
|
||||
expect(screen.getByTestId("inherited-budget-hint")).toHaveTextContent("Organization Acme: $5,000.00");
|
||||
expect(screen.getByTestId("inherited-budget-hint")).not.toHaveTextContent("Organization Acme: $5,000.00 /");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
"use client";
|
||||
|
||||
import { Tooltip } from "@/components/atoms/Tooltip";
|
||||
import type { Team } from "@/components/key_team_helpers/key_list";
|
||||
import type { Organization } from "@/components/networking";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
|
||||
export interface InheritedBudgetGate {
|
||||
scope: "Team" | "Organization";
|
||||
alias: string;
|
||||
maxBudget: number;
|
||||
budgetDuration: string | null;
|
||||
}
|
||||
|
||||
type TeamBudgetSource = Pick<Team, "team_id" | "team_alias" | "max_budget" | "budget_duration">;
|
||||
type OrganizationBudgetSource = Pick<Organization, "organization_id" | "organization_alias" | "litellm_budget_table">;
|
||||
|
||||
const teamGate = (team: TeamBudgetSource | null | undefined): InheritedBudgetGate | null =>
|
||||
team && team.max_budget != null
|
||||
? {
|
||||
scope: "Team",
|
||||
alias: team.team_alias || team.team_id,
|
||||
maxBudget: team.max_budget,
|
||||
budgetDuration: team.budget_duration ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
const organizationGate = (organization: OrganizationBudgetSource | null | undefined): InheritedBudgetGate | null => {
|
||||
const budgetTable: { max_budget?: number | null; budget_duration?: string | null } | null | undefined =
|
||||
organization?.litellm_budget_table;
|
||||
return organization && budgetTable?.max_budget != null
|
||||
? {
|
||||
scope: "Organization",
|
||||
alias: organization.organization_alias || organization.organization_id,
|
||||
maxBudget: budgetTable.max_budget,
|
||||
budgetDuration: budgetTable.budget_duration ?? null,
|
||||
}
|
||||
: null;
|
||||
};
|
||||
|
||||
export const inheritedBudgetGates = (
|
||||
team: TeamBudgetSource | null | undefined,
|
||||
organization: OrganizationBudgetSource | null | undefined,
|
||||
): readonly InheritedBudgetGate[] => [teamGate(team), organizationGate(organization)].filter((gate) => gate !== null);
|
||||
|
||||
const formatGate = (gate: InheritedBudgetGate): string =>
|
||||
`${gate.scope} ${gate.alias}: $${formatNumberWithCommas(gate.maxBudget, 2)}${gate.budgetDuration ? ` / ${gate.budgetDuration}` : ""}`;
|
||||
|
||||
interface InheritedBudgetHintProps {
|
||||
gates: readonly InheritedBudgetGate[];
|
||||
}
|
||||
|
||||
export function InheritedBudgetHint({ gates }: InheritedBudgetHintProps) {
|
||||
if (gates.length === 0) return null;
|
||||
return (
|
||||
<Tooltip
|
||||
content={
|
||||
<div data-testid="inherited-budget-hint" className="flex flex-col gap-1">
|
||||
<span>This key has no budget of its own, but its spend still counts toward:</span>
|
||||
{gates.map((gate) => (
|
||||
<span key={gate.scope}>{formatGate(gate)}</span>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -53,9 +53,24 @@ describe("SpendBudgetCell", () => {
|
|||
expect(indicator(container)?.className).toContain("bg-destructive");
|
||||
});
|
||||
|
||||
it("falls back to the team budget and labels it", () => {
|
||||
render(<SpendBudgetCell spend={10} maxBudget={null} teamMaxBudget={200} />);
|
||||
expect(screen.getByText("of $200 (Team)")).toBeInTheDocument();
|
||||
expect(screen.getByRole("meter")).toHaveAttribute("aria-valuemax", "200");
|
||||
it("never meters key spend against an inherited team/org budget", () => {
|
||||
const gates = [{ scope: "Team" as const, alias: "Team A", maxBudget: 200, budgetDuration: "30d" }];
|
||||
render(<SpendBudgetCell spend={10} maxBudget={null} inheritedGates={gates} />);
|
||||
expect(screen.getByText("· Unlimited")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/\(Team\)/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("meter")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("question-circle")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no inherited-budget hint when there is nothing to inherit", () => {
|
||||
render(<SpendBudgetCell spend={10} maxBudget={null} inheritedGates={[]} />);
|
||||
expect(screen.queryByLabelText("question-circle")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no inherited-budget hint when the key has its own budget", () => {
|
||||
const gates = [{ scope: "Team" as const, alias: "Team A", maxBudget: 200, budgetDuration: null }];
|
||||
render(<SpendBudgetCell spend={10} maxBudget={50} inheritedGates={gates} />);
|
||||
expect(screen.getByText("of $50")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("question-circle")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { InheritedBudgetHint, type InheritedBudgetGate } from "@/components/shared/InheritedBudgetHint";
|
||||
import { Meter, MeterIndicator, MeterTrack } from "@/components/ui/meter";
|
||||
import { formatNumberWithCommas, getSpendString } from "@/utils/dataUtils";
|
||||
|
||||
interface SpendBudgetCellProps {
|
||||
spend: number | null | undefined;
|
||||
maxBudget: number | null | undefined;
|
||||
teamMaxBudget?: number | null;
|
||||
inheritedGates?: readonly InheritedBudgetGate[];
|
||||
spendDecimals?: number;
|
||||
budgetDecimals?: number;
|
||||
}
|
||||
|
|
@ -20,27 +21,24 @@ const meterTone = (pct: number): "default" | "warning" | "over" => {
|
|||
export function SpendBudgetCell({
|
||||
spend,
|
||||
maxBudget,
|
||||
teamMaxBudget,
|
||||
inheritedGates = [],
|
||||
spendDecimals = 4,
|
||||
budgetDecimals = 0,
|
||||
}: SpendBudgetCellProps) {
|
||||
const spendValue = typeof spend === "number" && !Number.isNaN(spend) ? spend : 0;
|
||||
const budget = maxBudget ?? teamMaxBudget ?? null;
|
||||
const isTeamBudget = maxBudget == null && teamMaxBudget != null;
|
||||
const budget = maxBudget ?? null;
|
||||
const hasBudget = typeof budget === "number" && budget > 0;
|
||||
const pct = hasBudget ? (spendValue / budget) * 100 : 0;
|
||||
|
||||
const spendText = spendValue > 0 ? getSpendString(spendValue, spendDecimals) : "$0.00";
|
||||
const budgetLabel =
|
||||
budget === null
|
||||
? "· Unlimited"
|
||||
: `of $${formatNumberWithCommas(budget, budgetDecimals)}${isTeamBudget ? " (Team)" : ""}`;
|
||||
const budgetLabel = budget === null ? "· Unlimited" : `of $${formatNumberWithCommas(budget, budgetDecimals)}`;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[130px] flex-col gap-1">
|
||||
<div className="whitespace-nowrap text-xs">
|
||||
<span className="font-medium tabular-nums text-foreground">{spendText}</span>{" "}
|
||||
<span className="text-muted-foreground">{budgetLabel}</span>
|
||||
{budget === null && <InheritedBudgetHint gates={inheritedGates} />}
|
||||
</div>
|
||||
{hasBudget && (
|
||||
<Meter
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { renderWithProviders } from "../../../tests/test-utils";
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { KeyResponse, Team } from "../key_team_helpers/key_list";
|
||||
import KeyInfoView from "./key_info_view";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import useTeams from "@/app/(dashboard)/hooks/useTeams";
|
||||
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
import type { Organization } from "../networking";
|
||||
|
||||
// IMPORTANT: do not mock `@/utils/dataUtils` here. We want to exercise the
|
||||
// real `formatNumberWithCommas` so this test catches the LIT-2845 regression
|
||||
|
|
@ -13,15 +16,12 @@ import useTeams from "@/app/(dashboard)/hooks/useTeams";
|
|||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
|
||||
useOrganizations: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("./key_edit_view", () => ({
|
||||
KeyEditView: () => <div data-testid="key-edit-view-stub" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({ default: vi.fn() }));
|
||||
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ useOrganizations: vi.fn() }));
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: vi.fn() }));
|
||||
vi.mock("@/app/(dashboard)/hooks/projects/useProjects", () => ({
|
||||
useProjects: vi.fn().mockReturnValue({ data: [], isLoading: false }),
|
||||
|
|
@ -130,10 +130,34 @@ const makeTeam = (overrides: Partial<Team>): Team => ({
|
|||
...overrides,
|
||||
});
|
||||
|
||||
const makeOrganization = (overrides: Partial<Organization>): Organization =>
|
||||
({
|
||||
organization_id: "org-1",
|
||||
organization_alias: "Acme Org",
|
||||
budget_id: "budget-1",
|
||||
metadata: {},
|
||||
models: [],
|
||||
spend: 0,
|
||||
model_spend: {},
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
created_by: "admin",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
updated_by: "admin",
|
||||
litellm_budget_table: { max_budget: null, budget_duration: null },
|
||||
teams: null,
|
||||
users: null,
|
||||
members: null,
|
||||
...overrides,
|
||||
}) as Organization;
|
||||
|
||||
const mockOrganizations = (organizations: Organization[]) =>
|
||||
vi.mocked(useOrganizations).mockReturnValue({ data: organizations } as ReturnType<typeof useOrganizations>);
|
||||
|
||||
describe("KeyInfoView overview budget display (LIT-2845)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useTeams).mockReturnValue({ teams: [], setTeams: vi.fn() });
|
||||
vi.mocked(useAuthorized).mockReturnValue(baseAuthorized);
|
||||
mockOrganizations([]);
|
||||
});
|
||||
|
||||
it("renders a sub-dollar max_budget ($0.10) with 2-decimal precision in the overview Spend card", async () => {
|
||||
|
|
@ -188,7 +212,7 @@ describe("KeyInfoView overview budget display (LIT-2845)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("renders team budget with alias and duration when key has no own budget but team has one", async () => {
|
||||
it("never pairs key spend with the team budget: shows Unlimited plus an inherited-budget hint", async () => {
|
||||
vi.mocked(useTeams).mockReturnValue({
|
||||
teams: [makeTeam({ team_id: "team-123", team_alias: "Test Budget", max_budget: 1200, budget_duration: "30d" })],
|
||||
setTeams: vi.fn(),
|
||||
|
|
@ -203,15 +227,20 @@ describe("KeyInfoView overview budget display (LIT-2845)", () => {
|
|||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/of \$1,200\.00 \(Team: Test Budget \/ 30d\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/of Unlimited/)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText(/of \$1,200\.00/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/\(Team: Test Budget/)).not.toBeInTheDocument();
|
||||
await userEvent.setup().hover(screen.getByLabelText("question-circle"));
|
||||
expect(screen.getByTestId("inherited-budget-hint")).toHaveTextContent("Team Test Budget: $1,200.00 / 30d");
|
||||
});
|
||||
|
||||
it("renders team budget without duration when team has no budget_duration", async () => {
|
||||
it("lists the organization budget in the hint when the team's org has one", async () => {
|
||||
vi.mocked(useTeams).mockReturnValue({
|
||||
teams: [makeTeam({ team_id: "team-456", team_alias: "No Duration Team", max_budget: 500 })],
|
||||
teams: [makeTeam({ team_id: "team-456", team_alias: "Org Team", organization_id: "org-1" })],
|
||||
setTeams: vi.fn(),
|
||||
});
|
||||
mockOrganizations([makeOrganization({ litellm_budget_table: { max_budget: 5000, budget_duration: null } })]);
|
||||
renderWithProviders(
|
||||
<KeyInfoView
|
||||
keyData={{ ...MOCK_KEY_DATA, max_budget: null, team_id: "team-456" } as unknown as KeyResponse}
|
||||
|
|
@ -222,11 +251,14 @@ describe("KeyInfoView overview budget display (LIT-2845)", () => {
|
|||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/of \$500\.00 \(Team: No Duration Team\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/of Unlimited/)).toBeInTheDocument();
|
||||
});
|
||||
await userEvent.setup().hover(screen.getByLabelText("question-circle"));
|
||||
expect(screen.getByTestId("inherited-budget-hint")).toHaveTextContent("Organization Acme Org: $5,000.00");
|
||||
expect(screen.getByTestId("inherited-budget-hint")).not.toHaveTextContent("Team Org Team");
|
||||
});
|
||||
|
||||
it("renders 'Unlimited' when key has no budget and team also has no budget", async () => {
|
||||
it("renders 'Unlimited' with no hint when neither key, team, nor org has a budget", async () => {
|
||||
vi.mocked(useTeams).mockReturnValue({
|
||||
teams: [makeTeam({ team_id: "team-789", team_alias: "Free Team" })],
|
||||
setTeams: vi.fn(),
|
||||
|
|
@ -243,6 +275,27 @@ describe("KeyInfoView overview budget display (LIT-2845)", () => {
|
|||
await waitFor(() => {
|
||||
expect(screen.getByText(/of Unlimited/)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByLabelText("question-circle")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no hint when the key has its own budget even if the team has one", async () => {
|
||||
vi.mocked(useTeams).mockReturnValue({
|
||||
teams: [makeTeam({ team_id: "team-123", team_alias: "Test Budget", max_budget: 1200 })],
|
||||
setTeams: vi.fn(),
|
||||
});
|
||||
renderWithProviders(
|
||||
<KeyInfoView
|
||||
keyData={{ ...MOCK_KEY_DATA, max_budget: 25, team_id: "team-123" } as unknown as KeyResponse}
|
||||
onClose={() => {}}
|
||||
keyId={"test-key-id"}
|
||||
onKeyDataUpdate={() => {}}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/of \$25\.00/)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByLabelText("question-circle")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -250,6 +303,7 @@ describe("KeyInfoView budget reset visibility", () => {
|
|||
beforeEach(() => {
|
||||
vi.mocked(useTeams).mockReturnValue({ teams: [], setTeams: vi.fn() });
|
||||
vi.mocked(useAuthorized).mockReturnValue(baseAuthorized);
|
||||
mockOrganizations([]);
|
||||
});
|
||||
|
||||
const KEY_WITH_RESET = {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import { extractMcpEntitlement } from "../mcp_server_management/mcpEntitlement";
|
|||
import ObjectPermissionsView from "../object_permissions_view";
|
||||
import { RegenerateKeyModal } from "../organisms/RegenerateKeyModal";
|
||||
import { parseErrorMessage } from "../shared/errorUtils";
|
||||
import { InheritedBudgetHint, inheritedBudgetGates } from "../shared/InheritedBudgetHint";
|
||||
import { KeyEditView } from "./key_edit_view";
|
||||
|
||||
interface KeyInfoViewProps {
|
||||
|
|
@ -460,12 +461,9 @@ export default function KeyInfoView({
|
|||
const orgId = currentKeyData.organization_id || currentKeyData.org_id || parentTeam?.organization_id || "";
|
||||
const parentOrg = orgId ? organizations?.find((org) => org.organization_id === orgId) : null;
|
||||
|
||||
const budgetDisplay =
|
||||
currentKeyData.max_budget !== null
|
||||
? `$${formatNumberWithCommas(currentKeyData.max_budget, 2)}`
|
||||
: parentTeam?.max_budget != null
|
||||
? `$${formatNumberWithCommas(parentTeam.max_budget, 2)} (Team: ${parentTeam.team_alias || parentTeam.team_id}${parentTeam.budget_duration ? ` / ${parentTeam.budget_duration}` : ""})`
|
||||
: "Unlimited";
|
||||
const hasOwnBudget = currentKeyData.max_budget !== null;
|
||||
const budgetDisplay = hasOwnBudget ? `$${formatNumberWithCommas(currentKeyData.max_budget, 2)}` : "Unlimited";
|
||||
const inheritedGates = hasOwnBudget ? [] : inheritedBudgetGates(parentTeam, parentOrg);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full overflow-y-auto p-4">
|
||||
|
|
@ -616,7 +614,10 @@ export default function KeyInfoView({
|
|||
<p className="text-sm">Spend</p>
|
||||
<div className="mt-2">
|
||||
<h3 className="text-lg font-medium">${formatNumberWithCommas(currentKeyData.spend, 4)}</h3>
|
||||
<p className="text-sm">of {budgetDisplay}</p>
|
||||
<p className="text-sm">
|
||||
of {budgetDisplay}
|
||||
<InheritedBudgetHint gates={inheritedGates} />
|
||||
</p>
|
||||
{currentKeyData.budget_reset_at && (
|
||||
<p className="text-sm">Resets {formatTimestamp(currentKeyData.budget_reset_at)}</p>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue