mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-15 09:12:18 +00:00
feat(ui): reuse usage spend design for customer teams and members (#51724)
This commit is contained in:
parent
bc78cbe9a2
commit
c3b929d203
8 changed files with 584 additions and 155 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue