feat(ui): add Teams list CSV export with budgets, model grants, and rate limits (#38436)

* feat(ui): add Teams list CSV export with budgets, model grants, and rate limits

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): neutralize formula-leading values in teams CSV export

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-08-26 20:44:33 -07:00 committed by GitHub
parent 02035120e4
commit 1fcdb3d92a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 302 additions and 11 deletions

View file

@ -2,6 +2,7 @@
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { useTeamsTable } from "@/app/(dashboard)/hooks/teams/useTeams";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import {
DataTable,
DataTableFilterDrawer,
@ -9,14 +10,17 @@ import {
DataTableToolbar,
} from "@/components/shared/DataTable";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
import { Download } from "lucide-react";
import React, { useCallback, useMemo, useState } from "react";
import { Team } from "../key_team_helpers/key_list";
import { getTeamTableColumns, TEAM_TABLE_HIDDEN_COLUMNS } from "./teamTableColumns";
import { exportTeamsToCsv } from "./teamsCsvExport";
interface TeamsTableProps {
userRole: string | null;
@ -49,7 +53,9 @@ export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDelet
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [filtersOpen, setFiltersOpen] = useState(false);
const [searchInput, setSearchInput] = useState("");
const [isExporting, setIsExporting] = useState(false);
const [searchQuery] = useDebouncedValue(searchInput, { wait: DEBOUNCE_WAIT_MS });
const { accessToken } = useAuthorized();
const getFilterValue = useCallback(
(columnId: string): string | undefined => {
@ -61,16 +67,19 @@ export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDelet
const isAdminView = userRole === "Admin" || userRole === "Admin Viewer";
const teamListOptions = {
organizationID: getFilterValue("org_id"),
team_alias: getFilterValue("alias"),
teamID: getFilterValue("team_id"),
search: searchQuery.trim() || undefined,
searchTeamIdMatch: "prefix" as const,
userID: isAdminView ? undefined : userID ?? undefined,
sortBy: sorting[0]?.id,
sortOrder: toSortOrder(sorting),
};
const teamListOptions = useMemo(
() => ({
organizationID: getFilterValue("org_id"),
team_alias: getFilterValue("alias"),
teamID: getFilterValue("team_id"),
search: searchQuery.trim() || undefined,
searchTeamIdMatch: "prefix" as const,
userID: isAdminView ? undefined : userID ?? undefined,
sortBy: sorting[0]?.id,
sortOrder: toSortOrder(sorting),
}),
[getFilterValue, searchQuery, isAdminView, userID, sorting],
);
const {
data: teamsResponse,
@ -97,6 +106,16 @@ export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDelet
setTablePagination((prev) => ({ ...prev, pageIndex: 0 }));
}, []);
const handleExportCsv = useCallback(async () => {
if (!accessToken || isExporting) return;
setIsExporting(true);
try {
await exportTeamsToCsv(accessToken, teamListOptions);
} finally {
setIsExporting(false);
}
}, [accessToken, isExporting, teamListOptions]);
const columns = useMemo(() => {
const columnDeps = { organizations, userRole, onSelectTeam, onEditTeam, onDeleteTeam };
return getTeamTableColumns(columnDeps);
@ -159,7 +178,18 @@ export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDelet
onOpenFilters={() => setFiltersOpen(true)}
filterLabels={FILTER_LABELS}
formatFilterValue={formatFilterValue}
/>
>
<Button
variant="outline"
size="sm"
onClick={handleExportCsv}
disabled={isExporting}
data-testid="teams-export-csv"
>
<Download />
{isExporting ? "Exporting..." : "Export CSV"}
</Button>
</DataTableToolbar>
<DataTableFilterDrawer
table={table}
open={filtersOpen}

View file

@ -0,0 +1,163 @@
import { describe, expect, it, vi } from "vitest";
import type { TeamsResponse } from "@/app/(dashboard)/hooks/teams/useTeams";
import type { Team } from "../key_team_helpers/key_list";
import {
buildTeamsCsv,
buildTeamsCsvRows,
collectTeamMemberBudgetIds,
fetchAllTeams,
TEAMS_EXPORT_PAGE_SIZE,
} from "./teamsCsvExport";
const makeTeam = (overrides: Partial<Team>): Team =>
({
team_id: "team-1",
team_alias: "alias-1",
models: [],
max_budget: null,
budget_duration: null,
tpm_limit: null,
rpm_limit: null,
organization_id: "org-1",
created_at: "2026-01-01T00:00:00Z",
keys: [],
members_with_roles: [],
spend: 0,
...overrides,
}) as Team;
const makePage = (teams: Team[], page: number, totalPages: number): TeamsResponse => ({
teams,
total: teams.length,
page,
page_size: TEAMS_EXPORT_PAGE_SIZE,
total_pages: totalPages,
});
describe("fetchAllTeams", () => {
it("returns the single page without extra requests", async () => {
const fetchPage = vi.fn().mockResolvedValue(makePage([makeTeam({ team_id: "a" })], 1, 1));
const teams = await fetchAllTeams(fetchPage);
expect(teams.map((t) => t.team_id)).toEqual(["a"]);
expect(fetchPage).toHaveBeenCalledTimes(1);
expect(fetchPage).toHaveBeenCalledWith(1, TEAMS_EXPORT_PAGE_SIZE);
});
it("fetches and concatenates every page in order", async () => {
const fetchPage = vi
.fn()
.mockImplementation(async (page: number) => makePage([makeTeam({ team_id: `team-${page}` })], page, 3));
const teams = await fetchAllTeams(fetchPage);
expect(teams.map((t) => t.team_id)).toEqual(["team-1", "team-2", "team-3"]);
expect(fetchPage).toHaveBeenCalledTimes(3);
expect(fetchPage).toHaveBeenCalledWith(2, TEAMS_EXPORT_PAGE_SIZE);
expect(fetchPage).toHaveBeenCalledWith(3, TEAMS_EXPORT_PAGE_SIZE);
});
});
describe("collectTeamMemberBudgetIds", () => {
it("dedupes ids and skips teams without a member budget", () => {
const teams = [
makeTeam({ team_id: "a", metadata: { team_member_budget_id: "bud-1" } }),
makeTeam({ team_id: "b", metadata: { team_member_budget_id: "bud-1" } }),
makeTeam({ team_id: "c", metadata: {} }),
makeTeam({ team_id: "d", metadata: { team_member_budget_id: "" } }),
makeTeam({ team_id: "e" }),
makeTeam({ team_id: "f", metadata: { team_member_budget_id: "bud-2" } }),
];
expect(collectTeamMemberBudgetIds(teams)).toEqual(["bud-1", "bud-2"]);
});
});
describe("buildTeamsCsvRows", () => {
it("maps configured limits, spend, models, and rate limits", () => {
const teamFields: Partial<Team> = {
team_id: "team-42",
team_alias: "finance",
organization_id: "org-9",
models: ["gpt-4o", "claude-sonnet-4-5"],
max_budget: 250,
budget_duration: "30d",
budget_reset_at: "2026-02-01T00:00:00Z",
spend: 12.5,
tpm_limit: 1000,
rpm_limit: 50,
members_count: 7,
keys_count: 3,
blocked: false,
};
const [row] = buildTeamsCsvRows([makeTeam(teamFields)], []);
const expectedRow = {
"Team Alias": "finance",
"Team ID": "team-42",
"Organization ID": "org-9",
Models: "gpt-4o, claude-sonnet-4-5",
"Max Budget (USD)": 250,
"Budget Duration": "30d",
"Budget Reset At": "2026-02-01T00:00:00Z",
"Spend (USD)": 12.5,
"TPM Limit": 1000,
"RPM Limit": 50,
"Team Member Budget (USD)": "",
"Team Member Budget Duration": "",
"Team Member TPM Limit": "",
"Team Member RPM Limit": "",
Members: 7,
Keys: 3,
Blocked: false,
"Created At": "2026-01-01T00:00:00Z",
};
expect(row).toEqual(expectedRow);
});
it("joins team member budget rows by budget id from metadata", () => {
const teams = [
makeTeam({ team_id: "a", metadata: { team_member_budget_id: "bud-1" } }),
makeTeam({ team_id: "b" }),
];
const rows = buildTeamsCsvRows(teams, [
{ budget_id: "bud-1", max_budget: 25, budget_duration: "7d", tpm_limit: 200, rpm_limit: 10 },
]);
expect(rows[0]["Team Member Budget (USD)"]).toBe(25);
expect(rows[0]["Team Member Budget Duration"]).toBe("7d");
expect(rows[0]["Team Member TPM Limit"]).toBe(200);
expect(rows[0]["Team Member RPM Limit"]).toBe(10);
expect(rows[1]["Team Member Budget (USD)"]).toBe("");
});
it("falls back to members_with_roles and keys lengths when counts are absent", () => {
const team = makeTeam({
members_with_roles: [
{ user_id: "u1", role: "admin" },
{ user_id: "u2", role: "user" },
],
keys: [{ token: "t" } as Team["keys"][number]],
});
const [row] = buildTeamsCsvRows([team], []);
expect(row.Members).toBe(2);
expect(row.Keys).toBe(1);
});
});
describe("buildTeamsCsv", () => {
it("produces a header row and quotes values containing commas", () => {
const csv = buildTeamsCsv([makeTeam({ team_alias: "sales, emea", models: ["m1", "m2"] })], []);
const [header, row] = csv.split("\r\n");
expect(header).toBe(
"Team Alias,Team ID,Organization ID,Models,Max Budget (USD),Budget Duration,Budget Reset At,Spend (USD)," +
"TPM Limit,RPM Limit,Team Member Budget (USD),Team Member Budget Duration,Team Member TPM Limit," +
"Team Member RPM Limit,Members,Keys,Blocked,Created At",
);
expect(row).toContain('"sales, emea"');
expect(row).toContain('"m1, m2"');
});
it("neutralizes formula-leading values so spreadsheets render them as text", () => {
const csv = buildTeamsCsv([makeTeam({ team_alias: "=SUM(A1:A9)" })], []);
const [, row] = csv.split("\r\n");
expect(row).toContain('"\'=SUM(A1:A9)"');
expect(row).not.toContain("=SUM(A1:A9),");
});
});

View file

@ -0,0 +1,95 @@
import Papa from "papaparse";
import { TeamListCallOptions, TeamsResponse, teamListCall } from "@/app/(dashboard)/hooks/teams/useTeams";
import { Team } from "../key_team_helpers/key_list";
import { apiClient } from "../networking";
export interface TeamMemberBudget {
budget_id: string;
max_budget?: number | null;
budget_duration?: string | null;
tpm_limit?: number | null;
rpm_limit?: number | null;
}
export const TEAMS_EXPORT_PAGE_SIZE = 100;
type FetchTeamsPage = (page: number, pageSize: number) => Promise<TeamsResponse>;
export const fetchAllTeams = async (fetchPage: FetchTeamsPage): Promise<Team[]> => {
const firstPage = await fetchPage(1, TEAMS_EXPORT_PAGE_SIZE);
const totalPages = firstPage.total_pages ?? 1;
if (totalPages <= 1) return firstPage.teams;
const remainingPages = await Promise.all(
Array.from({ length: totalPages - 1 }, (_, i) => fetchPage(i + 2, TEAMS_EXPORT_PAGE_SIZE)),
);
return [firstPage, ...remainingPages].flatMap((page) => page.teams);
};
const teamMemberBudgetId = (team: Team): string | null => {
const id = team.metadata?.team_member_budget_id;
return typeof id === "string" && id.length > 0 ? id : null;
};
export const collectTeamMemberBudgetIds = (teams: Team[]): string[] =>
Array.from(new Set(teams.map(teamMemberBudgetId).filter((id): id is string => id !== null)));
const cell = (value: string | number | boolean | null | undefined): string | number | boolean => value ?? "";
export const buildTeamsCsvRows = (
teams: Team[],
budgets: TeamMemberBudget[],
): Record<string, string | number | boolean>[] => {
const budgetsById = new Map(budgets.map((budget) => [budget.budget_id, budget]));
return teams.map((team) => {
const budgetId = teamMemberBudgetId(team);
const memberBudget = budgetId ? budgetsById.get(budgetId) : undefined;
return {
"Team Alias": cell(team.team_alias),
"Team ID": cell(team.team_id),
"Organization ID": cell(team.organization_id),
Models: (team.models ?? []).join(", "),
"Max Budget (USD)": cell(team.max_budget),
"Budget Duration": cell(team.budget_duration),
"Budget Reset At": cell(team.budget_reset_at),
"Spend (USD)": cell(team.spend),
"TPM Limit": cell(team.tpm_limit),
"RPM Limit": cell(team.rpm_limit),
"Team Member Budget (USD)": cell(memberBudget?.max_budget),
"Team Member Budget Duration": cell(memberBudget?.budget_duration),
"Team Member TPM Limit": cell(memberBudget?.tpm_limit),
"Team Member RPM Limit": cell(memberBudget?.rpm_limit),
Members: cell(team.members_count ?? team.members_with_roles?.length),
Keys: cell(team.keys_count ?? team.keys?.length),
Blocked: cell(team.blocked),
"Created At": cell(team.created_at),
};
});
};
export const buildTeamsCsv = (teams: Team[], budgets: TeamMemberBudget[]): string =>
Papa.unparse(buildTeamsCsvRows(teams, budgets), { escapeFormulae: true });
const downloadCsv = (csv: string, fileName: string): void => {
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
};
export const exportTeamsToCsv = async (accessToken: string, options: TeamListCallOptions): Promise<number> => {
const teams = await fetchAllTeams((page, pageSize) => teamListCall(accessToken, page, pageSize, options));
const budgetIds = collectTeamMemberBudgetIds(teams);
const budgets = budgetIds.length
? await apiClient.post<TeamMemberBudget[]>("/budget/info", { accessToken, body: { budgets: budgetIds } })
: [];
downloadCsv(buildTeamsCsv(teams, budgets), `teams_export_${new Date().toISOString().split("T")[0]}.csv`);
return teams.length;
};

View file

@ -13,6 +13,9 @@ export interface Team {
tpm_limit: number | null;
rpm_limit: number | null;
organization_id: string;
metadata?: Record<string, unknown> | null;
budget_reset_at?: string | null;
blocked?: boolean;
created_at: string;
updated_at?: string | null;
keys: KeyResponse[];