feat(ui): reuse usage spend design for customer teams and members (#51724)

This commit is contained in:
tin-berri 2026-11-14 19:45:29 -08:00 • committed by GitHub
parent bc78cbe9a2
commit c3b929d203
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 584 additions and 155 deletions

View file

@ -4,7 +4,6 @@ import { useState } from "react";
import { ChevronRight, Pencil, Plus } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import { formatUsd } from "@/app/(dashboard)/usage/_components/components/overview/overviewData";
import { ErrorState } from "@/components/shared/ErrorState";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
@ -13,7 +12,8 @@ import CustomerHierarchyNavigation, { useCustomerHierarchyLinks } from "./Custom
import CustomerHierarchySettings from "./CustomerHierarchySettings";
import CustomerBudgetGroups from "./CustomerBudgetGroups";
import CustomerBudgetSummary from "./CustomerBudgetSummary";
import type { CustomerUsageEntity } from "./customerUsageApi";
import { customerUsageEntity, isCustomerUsageIdentifier, type CustomerUsageEntity } from "./customerUsageApi";
import { CustomerSpendCells, CustomerSpendHeaders, CustomerSpendIdentity } from "./CustomerSpendCells";
import CreateCustomerHierarchyDialog from "./CreateCustomerHierarchyDialog";
import { customerEntityId, customerEntityLabel, type CreatedCustomerHierarchyEntity } from "./customerHierarchyForms";
import useCustomerHierarchy, { type CustomerHierarchyContext } from "./useCustomerHierarchy";
@ -123,8 +123,7 @@ function hierarchyRows({ hierarchy, selected, scope }: ScopeProps) {
return list.data.map((team) => ({
id: team.customer_team_id,
label: team.alias || team.customer_team_id,
spend: team.spend,
budget: team.budget?.max_budget,
entity: customerUsageEntity(team.customer_team_id, team),
open: () => hierarchy.openTeam(team.customer_team_id),
edit: () => hierarchy.openTeam(team.customer_team_id, true),
parentTeamId: null,
@ -135,8 +134,7 @@ function hierarchyRows({ hierarchy, selected, scope }: ScopeProps) {
return list.data.map((member) => ({
id: member.customer_member_id,
label: member.alias || member.customer_member_id,
spend: member.spend,
budget: member.effective_budget.max_budget,
entity: customerUsageEntity(member.customer_member_id, member),
open: () => hierarchy.openMember(member.customer_member_id, member.parent_team_ids[0] ?? ""),
edit: () => hierarchy.openMember(member.customer_member_id, member.parent_team_ids[0] ?? ""),
parentTeamId: member.parent_team_ids[0] ?? "",
@ -182,7 +180,7 @@ function HierarchyChildTable(props: ScopeProps) {
const { customerHierarchyHref } = useCustomerHierarchyLinks();
const { t } = useTranslation();
const { hierarchy } = props;
const { children, childTab, page } = hierarchy;
const { children, childUsage, childTab, page } = hierarchy;
const showTeam = childTab === "members" && props.selected.team === null;
const hint = showTeam ? "customerPortal.hierarchy.organizationMembersHint" : CHILD_HINTS[childTab];
const childLabel = childTab === "teams" ? t("customerPortal.hierarchy.teams") : t("customerPortal.hierarchy.members");
@ -206,6 +204,8 @@ function HierarchyChildTable(props: ScopeProps) {
);
const rows = hierarchyRows(props);
const pages = Math.max(1, children.data.meta.total_pages);
const monthly = new Map(childUsage.data?.monthly.map((row) => [row.id, row]));
const yearly = new Map(childUsage.data?.yearly.map((row) => [row.id, row.spend]));
return (
<>
<div className="mb-5 flex flex-wrap items-start justify-between gap-3">
@ -215,13 +215,16 @@ function HierarchyChildTable(props: ScopeProps) {
</div>
<HierarchyCreateAction hierarchy={hierarchy} selected={props.selected} scope={props.scope} />
</div>
{childUsage.isError && (
<ErrorState message={extractProxyErrorMessage(childUsage.error)} onRetry={() => void childUsage.refetch()} />
)}
<p className="mb-4 text-xs text-muted-foreground">{t("customerPortal.analytics.memberSpendHint")}</p>
<Table aria-label={childLabel}>
<TableHeader>
<TableRow>
<TableHead className="pl-0">{t("customerPortal.hierarchy.name")}</TableHead>
{showTeam && <TableHead>{t("customerPortal.hierarchy.team")}</TableHead>}
<TableHead className="text-right">{t("customerPortal.hierarchy.spend")}</TableHead>
<TableHead className="text-right">{t("customerPortal.hierarchy.budget")}</TableHead>
<CustomerSpendHeaders />
<TableHead className="pr-0">
<span className="sr-only">{t("customerPortal.hierarchy.settings")}</span>
</TableHead>
@ -231,10 +234,7 @@ function HierarchyChildTable(props: ScopeProps) {
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell className="py-4 pl-0">
<button type="button" onClick={row.open} className="block text-left font-medium hover:text-primary">
{row.label}
</button>
<span className="mt-1 block font-mono text-[11px] text-muted-foreground">{row.id}</span>
<CustomerSpendIdentity entity={row.entity} onClick={row.open} />
</TableCell>
{showTeam && (
<TableCell>
@ -247,10 +247,23 @@ function HierarchyChildTable(props: ScopeProps) {
</button>
</TableCell>
)}
<TableCell className="text-right tabular-nums">{formatUsd(row.spend)}</TableCell>
<TableCell className="text-right tabular-nums">
{row.budget == null ? t("customerPortal.hierarchy.noBudget") : formatUsd(row.budget)}
</TableCell>
{childUsage.data && isCustomerUsageIdentifier(row.id) ? (
<CustomerSpendCells
entity={row.entity}
monthlySpend={monthly.get(row.id)?.spend ?? 0}
yearlySpend={yearly.get(row.id) ?? 0}
tokens={monthly.get(row.id)?.tokens ?? 0}
organizationSpend={childUsage.data.organizationSpend}
/>
) : (
<TableCell colSpan={5} className="text-center text-xs text-muted-foreground" role="status">
{t(
childUsage.isError || !isCustomerUsageIdentifier(row.id)
? "customerPortal.analytics.unknownSpend"
: "customerPortal.hierarchy.loading",
)}
</TableCell>
)}
<TableCell className="pr-0 text-right">
<div className="flex justify-end gap-2">
<a
@ -275,7 +288,7 @@ function HierarchyChildTable(props: ScopeProps) {
))}
{rows.length === 0 && (
<TableRow>
<TableCell colSpan={showTeam ? 5 : 4} className="py-12 text-center text-sm text-muted-foreground">
<TableCell colSpan={showTeam ? 8 : 7} className="py-12 text-center text-sm text-muted-foreground">
{t(
childTab === "teams"
? "customerPortal.hierarchy.emptyTeams"

View file

@ -5,13 +5,10 @@ import { parseAsString, parseAsInteger, useQueryStates } from "nuqs";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
import { Search } from "lucide-react";
import { formatCompact, formatUsd } from "@/app/(dashboard)/usage/_components/components/overview/overviewData";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/cva.config";
import type { CustomerHierarchyUsage } from "./customerUsageApi";
import CustomerBudgetSpend, { CustomerBudgetWindow } from "./CustomerBudgetSpend";
import { customerBudgetPeriod, customerBudgetStatus, customerBudgetUsage } from "./customerBudgetUsage";
import { CustomerSpendCells, CustomerSpendHeaders, CustomerSpendIdentity } from "./CustomerSpendCells";
const MEMBER_TABLE_PARSERS = { memberSearch: parseAsString.withDefault(""), memberPage: parseAsInteger.withDefault(1) };
const PAGE_SIZE = 20;
@ -64,126 +61,24 @@ export default function CustomerMemberSpend({
<TableHeader>
<TableRow>
<TableHead className="pl-0">{t("customerPortal.analytics.member")}</TableHead>
<TableHead className="min-w-72">{t("customerPortal.analytics.monthlySpend")}</TableHead>
<TableHead className="text-right">{t("customerPortal.analytics.yearlySpend")}</TableHead>
<TableHead className="text-right">{t("customerPortal.analytics.customerSpendShare")}</TableHead>
<TableHead className="text-right">{t("customerPortal.analytics.tokens")}</TableHead>
<TableHead className="pr-0 text-right">{t("customerPortal.analytics.status")}</TableHead>
<CustomerSpendHeaders />
</TableRow>
</TableHeader>
<TableBody>
{members.slice(start, start + PAGE_SIZE).map((member) => {
const monthlySpend = member.monthlyUsage?.spend ?? 0;
const budgets = customerBudgetUsage(member);
const monthlyBudgets = budgets.filter(
(budget) => customerBudgetPeriod(budget.budget_duration) === "monthly",
);
const yearlyBudgets = budgets.filter((budget) => customerBudgetPeriod(budget.budget_duration) === "yearly");
const otherBudgets = budgets.filter((budget) => customerBudgetPeriod(budget.budget_duration) === "other");
const yearlySpend = yearlyUsage.get(member.id) ?? 0;
const spendShare =
data.monthlyOrganizationSpend > 0 ? (monthlySpend / data.monthlyOrganizationSpend) * 100 : 0;
const sharePercentage = Math.max(0, Math.min(100, spendShare));
const shareLabel = t("format.number", {
val: spendShare / 100,
style: "percent",
maximumFractionDigits: 1,
});
const status = customerBudgetStatus(budgets);
const atLimit = status === "customerPortal.analytics.reachedLimit";
return (
<TableRow key={member.id}>
<TableCell className="py-4 pl-0">
<div className="flex items-center gap-3">
<span
aria-hidden="true"
className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium"
>
{member.label
.split(/\s+/)
.slice(0, 2)
.map((part) => part.charAt(0))
.join("")
.toLocaleUpperCase()}
</span>
<div className="min-w-0">
<button
type="button"
className="block text-left font-medium hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => onMemberClick(member.id)}
>
{member.label}
</button>
<span className="block font-mono text-[11px] text-muted-foreground">{member.id}</span>
</div>
</div>
</TableCell>
<TableCell className="pr-6">
<div className="space-y-3">
{monthlyBudgets.map((budget) => (
<CustomerBudgetWindow key={budget.source} budget={budget} showReset={monthlyBudgets.length > 1} />
))}
{monthlyBudgets.length === 0 && (
<CustomerBudgetSpend
spend={monthlySpend}
limit={null}
label={t("customerPortal.analytics.monthlySpend")}
noLimitLabel={t("customerPortal.analytics.noMonthlyLimit")}
/>
)}
{otherBudgets.map((budget) => (
<div key={budget.source} className="space-y-1 text-xs text-muted-foreground">
<span>
{t("customerPortal.analytics.budgetPeriod")}
{budget.budget_duration && ` (${budget.budget_duration})`}
</span>
<CustomerBudgetWindow budget={budget} />
</div>
))}
</div>
</TableCell>
<TableCell className="text-right tabular-nums">
<div className="space-y-2">
{yearlyBudgets.map((budget) => (
<CustomerBudgetWindow key={budget.source} budget={budget} showReset={yearlyBudgets.length > 1} />
))}
{yearlyBudgets.length === 0 &&
`${formatUsd(yearlySpend)} / ${t("customerPortal.analytics.noYearlyLimit")}`}
</div>
</TableCell>
<TableCell>
<div
role="meter"
aria-label={t("customerPortal.analytics.customerSpendShare")}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={sharePercentage}
aria-valuetext={shareLabel}
title={shareLabel}
className="ml-auto h-2.5 w-24 overflow-hidden rounded-full bg-muted"
>
<div
className="h-full rounded-full bg-primary"
style={{ width: `${sharePercentage}%`, opacity: 0.3 + sharePercentage * 0.007 }}
/>
</div>
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCompact(member.monthlyUsage?.tokens ?? 0)}
</TableCell>
<TableCell className="pr-0 text-right">
<span
className={cn(
"inline-flex rounded-full bg-muted px-2 py-0.5 text-xs",
atLimit && "bg-destructive/10 text-destructive",
)}
>
{t(status)}
</span>
</TableCell>
</TableRow>
);
})}
{members.slice(start, start + PAGE_SIZE).map((member) => (
<TableRow key={member.id}>
<TableCell className="py-4 pl-0">
<CustomerSpendIdentity entity={member} onClick={() => onMemberClick(member.id)} />
</TableCell>
<CustomerSpendCells
entity={member}
monthlySpend={member.monthlyUsage?.spend ?? 0}
yearlySpend={yearlyUsage.get(member.id) ?? 0}
tokens={member.monthlyUsage?.tokens ?? 0}
organizationSpend={data.monthlyOrganizationSpend}
/>
</TableRow>
))}
{members.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-10 text-center text-muted-foreground">

View file

@ -0,0 +1,149 @@
import { useTranslation } from "react-i18next";
import { formatCompact, formatUsd } from "@/app/(dashboard)/usage/_components/components/overview/overviewData";
import { TableCell, TableHead } from "@/components/ui/table";
import { cn } from "@/lib/cva.config";
import type { CustomerUsageEntity } from "./customerUsageApi";
import CustomerBudgetSpend, { CustomerBudgetWindow } from "./CustomerBudgetSpend";
import { customerBudgetPeriod, customerBudgetStatus, customerBudgetUsage } from "./customerBudgetUsage";
export function CustomerSpendIdentity({
entity: member,
onClick,
}: {
entity: CustomerUsageEntity;
onClick: () => void;
}) {
return (
<div className="flex items-center gap-3">
<span
aria-hidden="true"
className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium"
>
{member.label
.split(/\s+/)
.slice(0, 2)
.map((part) => part.charAt(0))
.join("")
.toLocaleUpperCase()}
</span>
<div className="min-w-0">
<button
type="button"
className="block text-left font-medium hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
onClick={onClick}
>
{member.label}
</button>
<span className="block font-mono text-[11px] text-muted-foreground">{member.id}</span>
</div>
</div>
);
}
export function CustomerSpendHeaders() {
const { t } = useTranslation();
return (
<>
<TableHead className="min-w-72">{t("customerPortal.analytics.monthlySpend")}</TableHead>
<TableHead className="text-right">{t("customerPortal.analytics.yearlySpend")}</TableHead>
<TableHead className="text-right">{t("customerPortal.analytics.customerSpendShare")}</TableHead>
<TableHead className="text-right">{t("customerPortal.analytics.tokens")}</TableHead>
<TableHead className="pr-0 text-right">{t("customerPortal.analytics.status")}</TableHead>
</>
);
}
export function CustomerSpendCells({
entity: member,
monthlySpend,
yearlySpend,
tokens,
organizationSpend,
}: {
entity: CustomerUsageEntity;
monthlySpend: number;
yearlySpend: number;
tokens: number;
organizationSpend: number;
}) {
const { t } = useTranslation();
const budgets = customerBudgetUsage(member);
const monthlyBudgets = budgets.filter((budget) => customerBudgetPeriod(budget.budget_duration) === "monthly");
const yearlyBudgets = budgets.filter((budget) => customerBudgetPeriod(budget.budget_duration) === "yearly");
const otherBudgets = budgets.filter((budget) => customerBudgetPeriod(budget.budget_duration) === "other");
const spendShare = organizationSpend > 0 ? (monthlySpend / organizationSpend) * 100 : 0;
const sharePercentage = Math.max(0, Math.min(100, spendShare));
const shareLabel = t("format.number", {
val: spendShare / 100,
style: "percent",
maximumFractionDigits: 1,
});
const status = customerBudgetStatus(budgets);
const atLimit = status === "customerPortal.analytics.reachedLimit";
return (
<>
<TableCell className="pr-6">
<div className="space-y-3">
{monthlyBudgets.map((budget) => (
<CustomerBudgetWindow key={budget.source} budget={budget} showReset={monthlyBudgets.length > 1} />
))}
{monthlyBudgets.length === 0 && (
<CustomerBudgetSpend
spend={monthlySpend}
limit={null}
label={t("customerPortal.analytics.monthlySpend")}
noLimitLabel={t("customerPortal.analytics.noMonthlyLimit")}
/>
)}
{otherBudgets.map((budget) => (
<div key={budget.source} className="space-y-1 text-xs text-muted-foreground">
<span>
{t("customerPortal.analytics.budgetPeriod")}
{budget.budget_duration && ` (${budget.budget_duration})`}
</span>
<CustomerBudgetWindow budget={budget} />
</div>
))}
</div>
</TableCell>
<TableCell className="text-right tabular-nums">
<div className="space-y-2">
{yearlyBudgets.map((budget) => (
<CustomerBudgetWindow key={budget.source} budget={budget} showReset={yearlyBudgets.length > 1} />
))}
{yearlyBudgets.length === 0 && `${formatUsd(yearlySpend)} / ${t("customerPortal.analytics.noYearlyLimit")}`}
</div>
</TableCell>
<TableCell>
<div
role="meter"
aria-label={t("customerPortal.analytics.customerSpendShare")}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={sharePercentage}
aria-valuetext={shareLabel}
title={shareLabel}
className="ml-auto h-2.5 w-24 overflow-hidden rounded-full bg-muted"
>
<div
className="h-full rounded-full bg-primary"
style={{ width: `${sharePercentage}%`, opacity: 0.3 + sharePercentage * 0.007 }}
/>
</div>
</TableCell>
<TableCell className="text-right tabular-nums">{formatCompact(tokens)}</TableCell>
<TableCell className="pr-0 text-right">
<span
className={cn(
"inline-flex rounded-full bg-muted px-2 py-0.5 text-xs",
atLimit && "bg-destructive/10 text-destructive",
)}
>
{t(status)}
</span>
</TableCell>
</>
);
}

View file

@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerBaseUrlGetter } from "@/lib/http/runtime";
import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi";
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
import { customerHierarchyUsageCall } from "./customerUsageApi";
import { customerChildSpendCall, customerHierarchyUsageCall, customerUsageEntity } from "./customerUsageApi";
const audit = {
created_at: "2026-11-01T00:00:00Z",
@ -94,6 +94,7 @@ const requestUrl = (input: Parameters<typeof fetch>[0]) =>
const fetchMock = vi.fn<typeof fetch>();
const urls = () => fetchMock.mock.calls.map(([input]) => requestUrl(input));
const dailyUrls = () => urls().filter((url) => url.pathname.endsWith("/daily/activity"));
const organizationTotalUrls = () => urls().filter((url) => url.pathname.endsWith("/daily/activity/aggregated"));
const request = {
scope: { org: "org-1", team: null, member: null },
accessToken: "test-session",
@ -145,6 +146,17 @@ const activityResponse = (url: URL) => {
const serve = (input: Parameters<typeof fetch>[0]): Response => {
const url = requestUrl(input);
if (url.pathname === "/management/v1/customer_organizations/daily/activity/aggregated") {
const organizationId = url.searchParams.get("customer_org_id");
const selected = records.filter((row) => row.organization === organizationId);
return json({
results: selected.map((row) => daily(row, row.organization)),
metadata: {
...EMPTY_DAILY_ACTIVITY_METADATA,
total_spend: selected.reduce((sum, row) => sum + row.metrics.spend, 0),
},
});
}
if (url.pathname.endsWith("/daily/activity")) return activityResponse(url);
if (url.pathname === "/management/v1/customer_organizations/org-1") return json(organization);
if (url.pathname === "/management/v1/customer_teams") {
@ -353,9 +365,10 @@ describe("customerHierarchyUsageCall", () => {
expect(result.monthlyOrganizationSpend).toBe(4);
expect(result.monthlyMembers.find((member) => member.id === "member-a")?.spend).toBe(3);
expect(result.yearlyMembers.find((member) => member.id === "member-a")?.spend).toBe(3);
const organizationReads = dailyUrls().filter((url) => url.searchParams.has("customer_org_ids"));
expect(organizationReads.map((url) => url.searchParams.get("page"))).toEqual(["1", "2"]);
expect(organizationReads.every((url) => url.searchParams.get("start_date") === request.monthStartDate)).toBe(true);
expect(organizationTotalUrls()).toHaveLength(1);
expect(organizationTotalUrls()[0].searchParams.get("start_date")).toBe(request.monthStartDate);
expect(organizationTotalUrls()[0].searchParams.get("end_date")).toBe(request.monthEndDate);
expect(organizationTotalUrls()[0].searchParams.get("customer_org_id")).toBe(request.scope.org);
});
it("should never send an empty descendant filter and should retain unassigned usage", async () => {
@ -423,3 +436,120 @@ describe("customerHierarchyUsageCall", () => {
expect(dailyUrls().some((url) => url.searchParams.has("customer_member_ids"))).toBe(false);
});
});
describe("customerChildSpendCall", () => {
it.each([" org-1 ", "org-1,org-2"])(
"should retain the exact organization ID %s for its denominator",
async (organizationId) => {
fetchMock.mockImplementation(async (input) => {
const url = requestUrl(input);
if (
url.pathname.endsWith("/daily/activity/aggregated") &&
url.searchParams.get("customer_org_id") === organizationId
)
return json({ results: [], metadata: { ...EMPTY_DAILY_ACTIVITY_METADATA, total_spend: 29 } });
return serve(input);
});
const childRequest = {
...request,
organizationId,
kind: "teams" as const,
entities: [customerUsageEntity("team-a", teams[0])],
};
const result = await customerChildSpendCall(childRequest);
expect(result.organizationSpend).toBe(29);
expect(result.monthly[0].spend).toBe(3);
},
);
it.each(["teams", "members"] as const)(
"should load complete totals for a busy organization without downloading its raw history for %s",
async (kind) => {
const aggregateSpend = 100_001;
fetchMock.mockImplementation(async (input) => {
const url = requestUrl(input);
if (url.pathname === "/management/v1/customer_organizations/daily/activity")
return json({ detail: "Raw activity history exceeds the page limit" }, 503);
if (url.pathname.endsWith("/daily/activity/aggregated"))
return json({
results: [],
metadata: { ...EMPTY_DAILY_ACTIVITY_METADATA, total_spend: aggregateSpend },
});
return serve(input);
});
const entities =
kind === "teams"
? teams.map((team) => customerUsageEntity(team.customer_team_id, team))
: members.map((member) => customerUsageEntity(member.customer_member_id, member));
const childRequest = { ...request, organizationId: "org-1", kind, entities };
const result = await customerChildSpendCall(childRequest);
expect(result.organizationSpend).toBe(aggregateSpend);
expect(result.monthly.map((row) => [row.spend, row.tokens])).toEqual([
[3, 100],
[1, 500],
]);
expect(result.yearly).toEqual(result.monthly);
expect(organizationTotalUrls()).toHaveLength(1);
expect(Object.fromEntries(organizationTotalUrls()[0].searchParams)).toEqual({
customer_org_id: "org-1",
start_date: request.monthStartDate,
end_date: request.monthEndDate,
});
},
);
it.each([
{ body: { detail: "Organization totals unavailable" }, status: 503 },
{ body: { results: [] }, status: 200 },
])("should reject unavailable organization totals instead of presenting zero spend", async ({ body, status }) => {
fetchMock.mockImplementation(async (input) =>
requestUrl(input).pathname.endsWith("/daily/activity/aggregated") ? json(body, status) : serve(input),
);
const childRequest = {
...request,
organizationId: "org-1",
kind: "teams" as const,
entities: [customerUsageEntity("team-a", teams[0])],
};
await expect(customerChildSpendCall(childRequest)).rejects.toThrow();
});
it("should avoid unfiltered reads for empty management pages", async () => {
const childRequest = { ...request, organizationId: "org-1", kind: "teams" as const, entities: [] };
await expect(customerChildSpendCall(childRequest)).resolves.toEqual({
monthly: [],
yearly: [],
organizationSpend: 0,
});
expect(fetchMock).not.toHaveBeenCalled();
});
it("should reject empty organization filters before requesting any management usage", async () => {
const childRequest = {
...request,
organizationId: "",
kind: "teams" as const,
entities: [customerUsageEntity("team-a", teams[0])],
};
await expect(customerChildSpendCall(childRequest)).rejects.toThrow();
expect(fetchMock).not.toHaveBeenCalled();
});
it.each(["team-a,team-b", ""])(
"should avoid unfiltered reads when only an unrepresentable ID %s exists",
async (id) => {
const childRequest = {
...request,
organizationId: "org-1",
kind: "teams" as const,
entities: [customerUsageEntity(id, teams[0])],
};
await expect(customerChildSpendCall(childRequest)).resolves.toEqual({
monthly: [],
yearly: [],
organizationSpend: 0,
});
expect(fetchMock).not.toHaveBeenCalled();
},
);
});

View file

@ -70,8 +70,11 @@ const ID_FILTER: Record<Collection, string> = {
customer_members: "customer_member_ids",
};
export const isCustomerUsageIdentifier = (id: string): boolean => id.length > 0 && !id.includes(",");
const assertIdentifiers = (ids: readonly string[]) => {
if (ids.some((id) => !id || id.includes(","))) throw new Error(i18n.t("customerPortal.analytics.invalidIdentifier"));
if (ids.some((id) => !isCustomerUsageIdentifier(id)))
throw new Error(i18n.t("customerPortal.analytics.invalidIdentifier"));
};
const batches = async <T, R>(items: readonly T[], task: (item: T) => Promise<R>): Promise<R[]> => {
@ -123,7 +126,11 @@ const descendants = async (scope: ResolvedScope, request: CustomerUsageRequest)
};
};
const activityPages = async (resource: Collection, ids: readonly string[], request: CustomerUsageRequest) => {
const activityPages = async (
resource: Collection,
ids: readonly string[],
request: Pick<CustomerUsageRequest, "accessToken" | "startDate" | "endDate" | "signal">,
) => {
assertIdentifiers(ids);
const pages: DailyActivityAggregatedResponse[] = [];
for (let page = 1; page <= MAX_PAGES; page++) {
@ -145,6 +152,61 @@ const activityPages = async (resource: Collection, ids: readonly string[], reque
throw new Error(i18n.t("customerPortal.analytics.incompletePages"));
};
const organizationActivityTotals = async (
organizationId: string,
request: Pick<CustomerUsageRequest, "accessToken" | "startDate" | "endDate" | "signal">,
) => {
const result = await apiClient.get<DailyActivityAggregatedResponse>(
`${PREFIX}/customer_organizations/daily/activity/aggregated`,
{
accessToken: request.accessToken,
signal: request.signal,
query: {
start_date: request.startDate,
end_date: request.endDate,
customer_org_id: organizationId,
},
},
);
if (!result.metadata) throw new Error(i18n.t("customerPortal.analytics.incompletePages"));
return { ...result, metadata: result.metadata };
};
export const customerChildSpendCall = async ({
organizationId,
kind,
entities,
...request
}: Pick<
CustomerUsageRequest,
"accessToken" | "monthStartDate" | "monthEndDate" | "yearStartDate" | "yearEndDate" | "signal"
> & {
organizationId: string;
kind: "teams" | "members";
entities: readonly CustomerUsageEntity[];
}) => {
const ids = entities.map((item) => item.id).filter(isCustomerUsageIdentifier);
if (ids.length === 0) return { monthly: [], yearly: [], organizationSpend: 0 };
if (!organizationId) throw new Error(i18n.t("customerPortal.analytics.invalidIdentifier"));
const resource = kind === "teams" ? "customer_teams" : "customer_members";
const monthRequest = { ...request, startDate: request.monthStartDate, endDate: request.monthEndDate };
const yearRequest = { ...request, startDate: request.yearStartDate, endDate: request.yearEndDate };
const [monthlyPages, yearlyPages, organizationActivity] = await Promise.all([
activityPages(resource, ids, monthRequest),
activityPages(resource, ids, yearRequest),
organizationActivityTotals(organizationId, monthRequest),
]);
const monthly = mergeCustomerActivityPages(monthlyPages);
const yearly = mergeCustomerActivityPages(yearlyPages);
const labels = new Map(entities.map((item) => [item.id, item.label]));
const options = { unassignedLabel: "" };
return {
monthly: customerEntityRankings(monthly.results, labels, monthly.metadata, options),
yearly: customerEntityRankings(yearly.results, labels, yearly.metadata, options),
organizationSpend: organizationActivity.metadata.total_spend,
};
};
interface ActivityJob {
dimension: "scope" | "teams" | "members" | "monthlyOrganization" | "monthlyMembers" | "yearlyMembers";
resource: Collection;
@ -158,7 +220,7 @@ const chunkJobs = (resource: Collection, dimension: ActivityJob["dimension"], id
ids: ids.slice(index * PAGE_SIZE, (index + 1) * PAGE_SIZE),
}));
const entity = (id: string, record: Organization | Team | Member): CustomerUsageEntity => {
export const customerUsageEntity = (id: string, record: Organization | Team | Member): CustomerUsageEntity => {
const budget = "effective_budget" in record ? record.effective_budget : record.budget;
return {
id,
@ -246,7 +308,10 @@ export const customerHierarchyUsageCall = async (request: CustomerUsageRequest):
};
const activity = await batches(jobs, async (job) => ({
dimension: job.dimension,
pages: await activityPages(job.resource, job.ids, requests[job.dimension]),
pages:
job.dimension === "monthlyOrganization"
? [await organizationActivityTotals(request.scope.org, monthRequest)]
: await activityPages(job.resource, job.ids, requests[job.dimension]),
}));
const primary = mergeCustomerActivityPages(
activity.filter((result) => result.dimension === "scope").flatMap((result) => result.pages),
@ -285,9 +350,9 @@ export const customerHierarchyUsageCall = async (request: CustomerUsageRequest):
);
return {
...primary,
organization: entity(selected.organization.customer_org_id, selected.organization),
team: selected.team ? entity(selected.team.customer_team_id, selected.team) : null,
member: selected.member ? entity(selected.member.customer_member_id, selected.member) : null,
organization: customerUsageEntity(selected.organization.customer_org_id, selected.organization),
team: selected.team ? customerUsageEntity(selected.team.customer_team_id, selected.team) : null,
member: selected.member ? customerUsageEntity(selected.member.customer_member_id, selected.member) : null,
teams: customerEntityRankings(teamDays, teamLabels, primary.metadata, {
unassignedLabel: i18n.t("customerPortal.analytics.unassignedTeam"),
onlyEntityId: selected.member ? selected.team?.customer_team_id : undefined,
@ -303,7 +368,7 @@ export const customerHierarchyUsageCall = async (request: CustomerUsageRequest):
selected.member ? selected.team?.customer_team_id : undefined,
),
memberResults: customerEntityDailyData(primary.results, memberDays, memberLabels),
memberBudgets: children.members.map((member) => entity(member.customer_member_id, member)),
memberBudgets: children.members.map((member) => customerUsageEntity(member.customer_member_id, member)),
monthlyOrganizationSpend: monthlyOrganization.metadata.total_spend,
monthlyMembers: customerEntityRankings(monthlyMemberDays, memberLabels, monthlyOrganization.metadata, {
unassignedLabel: i18n.t("customerPortal.analytics.unassignedMember"),

View file

@ -7,6 +7,8 @@ import i18n from "@/i18n/i18n";
import { isProxyAdminRole } from "@/utils/roles";
import type { components } from "@/lib/http/schema";
import { resolveCustomerHierarchy, type CustomerHierarchyScope } from "./customerHierarchyApi";
import { customerChildSpendCall, customerUsageEntity } from "./customerUsageApi";
import { calendarDate, customerPresetRange } from "./customerUsageRange";
import type {
CreatedCustomerHierarchyEntity,
CustomerHierarchyEntity,
@ -103,6 +105,56 @@ function organizationView(
return requested === "budget_groups" && !team ? "budget_groups" : children;
}
function useChildSpend(
list: ChildList | undefined,
scope: CustomerHierarchyScope | null,
accessToken: string | null,
enabled: boolean,
) {
const childEntities =
list?.kind === "teams"
? list.data.map((team) => customerUsageEntity(team.customer_team_id, team))
: list?.data.map((member) => customerUsageEntity(member.customer_member_id, member)) ?? [];
const month = customerPresetRange("month");
const year = customerPresetRange("YTD");
const monthStartDate = calendarDate(month.from);
const monthEndDate = calendarDate(month.to);
const yearStartDate = calendarDate(year.from);
const yearEndDate = calendarDate(year.to);
const activeList = enabled ? list : undefined;
return useQuery({
queryKey: [
"customer-hierarchy",
"child-spend",
scope?.org,
scope?.team,
list?.kind,
childEntities.map((entity) => entity.id),
monthStartDate,
monthEndDate,
yearStartDate,
yearEndDate,
],
queryFn:
activeList && accessToken && scope
? ({ signal }) => {
const request = {
organizationId: scope.org,
kind: activeList.kind,
entities: childEntities,
accessToken,
monthStartDate,
monthEndDate,
yearStartDate,
yearEndDate,
signal,
};
return customerChildSpendCall(request);
}
: skipToken,
});
}
export default function useCustomerHierarchy() {
const { accessToken, userRole, isLoading } = useAuthorized();
const canManage = isProxyAdminRole(userRole);
@ -140,6 +192,7 @@ export default function useCustomerHierarchy() {
? ({ signal }) => customerHierarchyChildren(childContext, childTab, page, signal)
: skipToken,
});
const childUsage = useChildSpend(children.data, scope, accessToken, enabled && showChildren);
useEffect(() => {
if (!children.isSuccess) return;
if (page <= Math.max(1, children.data.meta.total_pages)) return;
@ -190,6 +243,7 @@ export default function useCustomerHierarchy() {
scope,
context,
children,
childUsage,
selected,
tab,
childTab,

View file

@ -4,6 +4,7 @@ import { toast } from "sonner";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerBaseUrlGetter } from "@/lib/http/runtime";
import { uiHref } from "@/utils/uiHref";
import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi";
import {
act,
fireEvent,
@ -66,6 +67,9 @@ const requestedUrls = () => fetchMock.mock.calls.map(([input]) => requestUrl(inp
function serve(input: Parameters<typeof fetch>[0]): Response {
const url = requestUrl(input);
if (url.pathname.endsWith("/daily/activity/aggregated"))
return json({ results: [], metadata: EMPTY_DAILY_ACTIVITY_METADATA });
if (url.pathname.endsWith("/daily/activity")) return json({ results: [], metadata: EMPTY_DAILY_ACTIVITY_METADATA });
if (url.pathname === "/models")
return json({ data: [{ id: "configured-model", object: "model", created: 0, owned_by: "openai" }] });
if (url.pathname === "/management/v1/customer_organizations/org-1") return json(organization);
@ -113,6 +117,118 @@ function BrowserNavigationProbe() {
}
describe("customer hierarchy management", () => {
it.each(["teams", "members"] as const)(
"should show usage-style budgets, spend share, tokens and actions for customer %s",
async (kind) => {
const id = kind === "teams" ? "team-a" : "member-a";
const label = kind === "teams" ? "Product" : "Member One";
const collection = kind === "teams" ? "customer_teams" : "customer_members";
const budgetUsage = [
{ source: "scalar", budget_duration: "1mo", max_budget: 10, spend: 3, reset_at: null },
{ source: "window", budget_duration: "12mo", max_budget: 100, spend: 7, reset_at: null },
];
fetchMock.mockImplementation(async (input) => {
const url = requestUrl(input);
if (url.pathname === `/management/v1/${collection}`) {
return json({
data: [
{ ...(kind === "teams" ? product : member), budget_usage: budgetUsage },
{
...(kind === "teams" ? product : member),
[kind === "teams" ? "customer_team_id" : "customer_member_id"]: `${id},other`,
alias: "Comma ID",
},
],
meta: { total_count: 2, page: 1, page_size: 20, total_pages: 1 },
});
}
if (url.pathname.endsWith("/daily/activity/aggregated"))
return json({ results: [], metadata: { ...EMPTY_DAILY_ACTIVITY_METADATA, total_spend: 12 } });
if (!url.pathname.endsWith("/daily/activity")) return serve(input);
const spend = url.pathname.includes("customer_organizations") ? 12 : 3;
const metrics = {
spend,
total_tokens: 250,
prompt_tokens: 250,
completion_tokens: 0,
api_requests: 1,
successful_requests: 1,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
};
return json({
results: [
{
date: url.searchParams.get("start_date"),
metrics,
breakdown: {
models: {},
model_groups: {},
providers: {},
mcp_servers: {},
api_keys: {},
entities: { [id]: { metrics, metadata: {}, api_key_breakdown: {} } },
},
},
],
metadata: {
...EMPTY_DAILY_ACTIVITY_METADATA,
total_spend: spend,
total_tokens: 250,
total_api_requests: 1,
},
});
});
renderWithProviders(<CustomerHierarchyManager />, { searchParams: `?org=org-1&tab=${kind}` });
const table = await screen.findByRole("table", {
name: kind === "teams" ? "Customer teams" : "Customer members",
});
const progress = await within(table).findByRole("progressbar", { name: "Spend this month / monthly limit" });
expect(progress).toHaveAttribute("aria-valuenow", "30");
expect(within(table).getByText("$3.00 / $10.00")).toBeInTheDocument();
expect(within(table).getByText("$7.00 remains")).toBeInTheDocument();
expect(within(table).getByText("$7.00 / $100.00")).toBeInTheDocument();
expect(within(table).getByRole("meter", { name: "Share of customer spend" })).toHaveAttribute(
"aria-valuenow",
"25",
);
expect(within(table).getByRole("cell", { name: "250" })).toBeInTheDocument();
expect(within(table).getByText("Within budget")).toBeInTheDocument();
expect(within(table).getByRole("button", { name: `Edit ${label}` })).toBeEnabled();
const validRow = within(table).getByRole("row", { name: new RegExp(label) });
expect(within(validRow).getByRole("link", { name: "View usage" })).toHaveAttribute(
"href",
`${uiHref("customers/usage")}?org=org-1&${kind === "teams" ? "team" : "member"}=${id}`,
);
const unavailableRow = within(table).getByRole("row", { name: /Comma ID/ });
expect(within(unavailableRow).getByRole("status")).toHaveTextContent("Unavailable");
expect(within(unavailableRow).queryByRole("meter")).not.toBeInTheDocument();
const activity = requestedUrls().filter((url) => url.pathname.endsWith("/daily/activity"));
expect(activity).toHaveLength(2);
expect(
activity
.filter((url) => url.pathname.includes(collection))
.every((url) => url.searchParams.get(kind === "teams" ? "customer_team_ids" : "customer_member_ids") === id),
).toBe(true);
},
);
it.each(["/daily/activity", "/daily/activity/aggregated"])(
"should keep row actions without showing zero usage when %s fails",
async (route) => {
fetchMock.mockImplementation(async (input) =>
requestUrl(input).pathname.endsWith(route) ? json({ detail: "Usage unavailable" }, 503) : serve(input),
);
renderWithProviders(<CustomerHierarchyManager />, { searchParams: "?org=org-1" });
const table = await screen.findByRole("table", { name: "Customer teams" });
expect(await screen.findByRole("alert")).toHaveTextContent("Usage unavailable");
expect(within(table).getByRole("status")).toHaveTextContent("Unavailable");
expect(within(table).queryByRole("meter")).not.toBeInTheDocument();
expect(within(table).getByRole("button", { name: "Edit Product" })).toBeEnabled();
},
);
it("should preserve a draft on refresh and conflict, then reload and save the current configuration", async () => {
const user = userEvent.setup();
const standard = {

View file

@ -148,11 +148,12 @@ const activityUrl = (resource: string, id: string, endDate = "2026-11-03") => {
};
const serve = (input: Parameters<typeof fetch>[0]): Response => {
const url = requestUrl(input);
if (url.pathname.endsWith("/daily/activity")) {
const resource = url.pathname.split("/").at(-3);
const aggregated = url.pathname.endsWith("/daily/activity/aggregated");
if (url.pathname.endsWith("/daily/activity") || aggregated) {
const resource = url.pathname.split("/").at(aggregated ? -4 : -3);
const dimension = activityDimension(resource);
const filter = filterFor(resource);
const ids = url.searchParams.get(filter)?.split(",") ?? [];
const ids = aggregated ? [url.searchParams.get("customer_org_id")] : url.searchParams.get(filter)?.split(",") ?? [];
const selected = records
.filter((record) => ids.includes(record[dimension]))
.filter(
@ -173,7 +174,13 @@ const serve = (input: Parameters<typeof fetch>[0]): Response => {
},
};
});
return json({ results, metadata: EMPTY_DAILY_ACTIVITY_METADATA });
return json({
results,
metadata: {
...EMPTY_DAILY_ACTIVITY_METADATA,
total_spend: selected.reduce((total, record) => total + record.metrics.spend, 0),
},
});
}
if (url.pathname === "/management/v1/customer_organizations/org-1") return json(organization);
if (url.pathname === "/management/v1/customer_teams") return json(list(teams));