Merge pull request #36955 from BerriAI/litellm_shadcn_next_0814

refactor(ui): move the cost tracking components onto shadcn primitives
This commit is contained in:
yuneng-jiang 2026-08-14 09:46:30 -07:00 committed by GitHub
commit b77923fc87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 982 additions and 702 deletions

View file

@ -228,7 +228,7 @@
"count": 2
},
"no-restricted-imports": {
"count": 2
"count": 1
}
},
"src/app/(dashboard)/cost-tracking/_components/how_it_works.tsx": {
@ -239,9 +239,6 @@
"src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.test.tsx": {
@ -252,9 +249,6 @@
"src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 2
}
},
"src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_export_dropdown.tsx": {
@ -275,17 +269,11 @@
"src/app/(dashboard)/cost-tracking/_components/provider_discount_table.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/cost-tracking/_components/provider_margin_table.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/cost-tracking/_components/use_discount_config.ts": {

View file

@ -8,25 +8,29 @@ import CostTrackingSettings from "./cost_tracking_settings";
// Mock sub-hooks so we can control their state without network calls
const mockDiscountConfig = vi.fn(() => ({}));
const mockMarginConfig = vi.fn(() => ({}));
const mockRemoveDiscount = vi.fn();
const mockRemoveMargin = vi.fn();
const stableDiscountCallbacks = {
fetchDiscountConfig: vi.fn().mockResolvedValue(undefined),
handleAddProvider: vi.fn().mockResolvedValue(true),
handleRemoveProvider: mockRemoveDiscount,
handleDiscountChange: vi.fn().mockResolvedValue(undefined),
};
const stableMarginCallbacks = {
fetchMarginConfig: vi.fn().mockResolvedValue(undefined),
handleAddMargin: vi.fn().mockResolvedValue(true),
handleRemoveMargin: mockRemoveMargin,
handleMarginChange: vi.fn().mockResolvedValue(undefined),
};
vi.mock("./use_discount_config", () => ({
useDiscountConfig: () => ({
discountConfig: mockDiscountConfig(),
fetchDiscountConfig: vi.fn().mockResolvedValue(undefined),
handleAddProvider: vi.fn().mockResolvedValue(true),
handleRemoveProvider: vi.fn().mockResolvedValue(undefined),
handleDiscountChange: vi.fn().mockResolvedValue(undefined),
}),
useDiscountConfig: () => ({ discountConfig: mockDiscountConfig(), ...stableDiscountCallbacks }),
}));
vi.mock("./use_margin_config", () => ({
useMarginConfig: () => ({
marginConfig: mockMarginConfig(),
fetchMarginConfig: vi.fn().mockResolvedValue(undefined),
handleAddMargin: vi.fn().mockResolvedValue(true),
handleRemoveMargin: vi.fn().mockResolvedValue(undefined),
handleMarginChange: vi.fn().mockResolvedValue(undefined),
}),
useMarginConfig: () => ({ marginConfig: mockMarginConfig(), ...stableMarginCallbacks }),
}));
vi.mock("./pricing_calculator/index", () => ({
@ -153,6 +157,57 @@ describe("CostTrackingSettings", () => {
});
});
describe("removing a configured provider", () => {
const expandAndRemove = async (section: string, actionName: string) => {
const user = userEvent.setup();
renderWithProviders(<CostTrackingSettings {...ADMIN_PROPS} />);
await user.click(screen.getByText(section).closest("button")!);
await user.click(await screen.findByRole("button", { name: actionName }));
return user;
};
it("should ask to confirm before removing a discount", async () => {
mockDiscountConfig.mockReturnValue({ openai: 0.05 });
await expandAndRemove("Provider Discounts", "Remove discount for openai");
expect(await screen.findByRole("button", { name: "Remove" })).toBeInTheDocument();
expect(screen.getByText(/are you sure you want to remove the discount for openai\?/i)).toBeInTheDocument();
expect(mockRemoveDiscount).not.toHaveBeenCalled();
});
it("should remove the discount once removal is confirmed", async () => {
mockDiscountConfig.mockReturnValue({ openai: 0.05 });
const user = await expandAndRemove("Provider Discounts", "Remove discount for openai");
await user.click(await screen.findByRole("button", { name: "Remove" }));
expect(mockRemoveDiscount).toHaveBeenCalledWith("openai");
});
it("should leave the discount in place when the confirmation is cancelled", async () => {
mockDiscountConfig.mockReturnValue({ openai: 0.05 });
const user = await expandAndRemove("Provider Discounts", "Remove discount for openai");
await user.click(await screen.findByRole("button", { name: "Cancel" }));
expect(mockRemoveDiscount).not.toHaveBeenCalled();
expect(screen.queryByRole("button", { name: "Remove" })).not.toBeInTheDocument();
});
it("should remove the margin once removal is confirmed", async () => {
mockMarginConfig.mockReturnValue({ openai: 0.1 });
const user = await expandAndRemove("Fee/Price Margin", "Remove margin for openai");
expect(screen.getByText(/are you sure you want to remove the margin for openai\?/i)).toBeInTheDocument();
await user.click(await screen.findByRole("button", { name: "Remove" }));
expect(mockRemoveMargin).toHaveBeenCalledWith("openai");
});
});
describe("empty state messages", () => {
it("should show the empty state message when no discount config is loaded", async () => {
mockDiscountConfig.mockReturnValue({});

View file

@ -1,25 +1,25 @@
import React, { useState, useEffect } from "react";
import {
Title,
Text,
Button,
Accordion,
AccordionHeader,
AccordionBody,
TabGroup,
TabList,
Tab,
TabPanels,
TabPanel,
} from "@tremor/react";
import { ChevronDown } from "lucide-react";
import { Modal, Form } from "antd";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { CostTrackingSettingsProps } from "./types";
import ProviderDiscountTable from "./provider_discount_table";
import AddProviderForm from "./add_provider_form";
import ProviderMarginTable from "./provider_margin_table";
import AddMarginForm from "./add_margin_form";
import PricingCalculator from "./pricing_calculator/index";
import { ExclamationCircleOutlined } from "@ant-design/icons";
import { DocsMenu } from "@/components/HelpLink";
import HowItWorks from "./how_it_works";
import { useDiscountConfig } from "./use_discount_config";
@ -31,6 +31,29 @@ const DOCS_LINKS = [
{ label: "Spend tracking", href: "https://docs.litellm.ai/docs/proxy/cost_tracking" },
];
const REMOVAL_COPY = {
discount: { title: "Remove Provider Discount", noun: "discount" },
margin: { title: "Remove Provider Margin", noun: "margin" },
} as const;
interface PendingRemoval {
kind: keyof typeof REMOVAL_COPY;
provider: string;
displayName: string;
}
const SECTION_HEADER_CLASS = "group/section flex w-full items-center justify-between px-6 py-4 text-left";
const SectionHeader: React.FC<{ title: string; description: string }> = ({ title, description }) => (
<CollapsibleTrigger className={SECTION_HEADER_CLASS}>
<div className="flex flex-col items-start w-full">
<span className="block text-lg font-semibold text-gray-900">{title}</span>
<span className="block text-sm text-gray-500 mt-1">{description}</span>
</div>
<ChevronDown className="size-5 shrink-0 text-gray-500 transition-transform group-data-[panel-open]/section:rotate-180" />
</CollapsibleTrigger>
);
const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, userRole, accessToken }) => {
const [selectedProvider, setSelectedProvider] = useState<string | undefined>(undefined);
const [newDiscount, setNewDiscount] = useState<string>("");
@ -42,9 +65,9 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
const [percentageValue, setPercentageValue] = useState<string>("");
const [fixedAmountValue, setFixedAmountValue] = useState<string>("");
const [models, setModels] = useState<string[]>([]);
const [pendingRemoval, setPendingRemoval] = useState<PendingRemoval | null>(null);
const [form] = Form.useForm();
const [marginForm] = Form.useForm();
const [modal, contextHolder] = Modal.useModal();
const isProxyAdmin = userRole === "proxy_admin" || userRole === "Admin";
@ -104,16 +127,18 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
handleAddProvider();
};
const handleRemoveProvider = async (provider: string, providerDisplayName: string) => {
modal.confirm({
title: "Remove Provider Discount",
icon: <ExclamationCircleOutlined />,
content: `Are you sure you want to remove the discount for ${providerDisplayName}?`,
okText: "Remove",
okType: "danger",
cancelText: "Cancel",
onOk: () => removeProvider(provider),
});
const handleRemoveProvider = (provider: string, providerDisplayName: string) => {
setPendingRemoval({ kind: "discount", provider, displayName: providerDisplayName });
};
const handleConfirmRemoval = () => {
if (!pendingRemoval) return;
if (pendingRemoval.kind === "discount") {
removeProvider(pendingRemoval.provider);
} else {
removeMargin(pendingRemoval.provider);
}
setPendingRemoval(null);
};
const handleAddMargin = async () => {
@ -141,16 +166,8 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
setMarginType("percentage");
};
const handleRemoveMargin = async (provider: string, providerDisplayName: string) => {
modal.confirm({
title: "Remove Provider Margin",
icon: <ExclamationCircleOutlined />,
content: `Are you sure you want to remove the margin for ${providerDisplayName}?`,
okText: "Remove",
okType: "danger",
cancelText: "Cancel",
onOk: () => removeMargin(provider),
});
const handleRemoveMargin = (provider: string, providerDisplayName: string) => {
setPendingRemoval({ kind: "margin", provider, displayName: providerDisplayName });
};
if (!accessToken) {
@ -159,18 +176,16 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
return (
<div className="w-full p-8">
{contextHolder}
{/* Header Section - Outside the card */}
<div className="flex flex-col md:flex-row items-start md:items-center justify-between mb-6">
<div>
<div className="flex items-center gap-2">
<Title>Cost Tracking Settings</Title>
<p className="text-xl font-medium text-gray-900">Cost Tracking Settings</p>
<DocsMenu items={DOCS_LINKS} />
</div>
<Text className="text-gray-500 mt-1">
<p className="text-gray-500 mt-1">
Configure cost discounts and margins for different LLM providers. Changes are saved automatically.
</Text>
</p>
</div>
</div>
@ -178,90 +193,78 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
<div className="bg-white rounded-lg shadow-sm w-full max-w-full space-y-4">
{/* Accordion 1: Provider Discounts - Only for proxy admins */}
{isProxyAdmin && (
<Accordion>
<AccordionHeader className="px-6 py-4">
<div className="flex flex-col items-start w-full">
<Text className="text-lg font-semibold text-gray-900">Provider Discounts</Text>
<Text className="text-sm text-gray-500 mt-1">
Apply percentage-based discounts to reduce costs for specific providers
</Text>
</div>
</AccordionHeader>
<AccordionBody className="px-0">
<TabGroup>
<TabList className="px-6 pt-4">
<Tab>Discounts</Tab>
<Tab>Test It</Tab>
</TabList>
<TabPanels>
<TabPanel>
<div className="p-6">
<div className="flex justify-end mb-4">
<Button onClick={() => setIsModalVisible(true)}>+ Add Provider Discount</Button>
<Collapsible className="rounded-lg border">
<SectionHeader
title="Provider Discounts"
description="Apply percentage-based discounts to reduce costs for specific providers"
/>
<CollapsibleContent className="px-0">
<Tabs defaultValue="discounts">
<TabsList className="mx-6 mt-4">
<TabsTrigger value="discounts">Discounts</TabsTrigger>
<TabsTrigger value="test-it">Test It</TabsTrigger>
</TabsList>
<TabsContent value="discounts">
<div className="p-6">
<div className="flex justify-end mb-4">
<Button onClick={() => setIsModalVisible(true)}>+ Add Provider Discount</Button>
</div>
{isFetching ? (
<div className="py-12 text-center">
<p className="text-gray-500">Loading configuration...</p>
</div>
{isFetching ? (
<div className="py-12 text-center">
<Text className="text-gray-500">Loading configuration...</Text>
</div>
) : Object.keys(discountConfig).length > 0 ? (
<ProviderDiscountTable
discountConfig={discountConfig}
onDiscountChange={handleDiscountChange}
onRemoveProvider={handleRemoveProvider}
/>
) : (
<div className="py-16 px-6 text-center">
<svg
className="mx-auto h-12 w-12 text-gray-400 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<Text className="text-gray-700 font-medium mb-2">No provider discounts configured</Text>
<Text className="text-gray-500 text-sm">
Click &quot;Add Provider Discount&quot; to get started
</Text>
</div>
)}
</div>
</TabPanel>
<TabPanel>
<div className="px-6 pb-4">
<HowItWorks />
</div>
</TabPanel>
</TabPanels>
</TabGroup>
</AccordionBody>
</Accordion>
) : Object.keys(discountConfig).length > 0 ? (
<ProviderDiscountTable
discountConfig={discountConfig}
onDiscountChange={handleDiscountChange}
onRemoveProvider={handleRemoveProvider}
/>
) : (
<div className="py-16 px-6 text-center">
<svg
className="mx-auto h-12 w-12 text-gray-400 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<p className="text-gray-700 font-medium mb-2">No provider discounts configured</p>
<p className="text-gray-500 text-sm">Click &quot;Add Provider Discount&quot; to get started</p>
</div>
)}
</div>
</TabsContent>
<TabsContent value="test-it">
<div className="px-6 pb-4">
<HowItWorks />
</div>
</TabsContent>
</Tabs>
</CollapsibleContent>
</Collapsible>
)}
{/* Accordion 2: Fee/Price Margin - Only for proxy admins */}
{isProxyAdmin && (
<Accordion>
<AccordionHeader className="px-6 py-4">
<div className="flex flex-col items-start w-full">
<Text className="text-lg font-semibold text-gray-900">Fee/Price Margin</Text>
<Text className="text-sm text-gray-500 mt-1">
Add fees or margins to LLM costs for internal billing and cost recovery
</Text>
</div>
</AccordionHeader>
<AccordionBody className="px-0">
<Collapsible className="rounded-lg border">
<SectionHeader
title="Fee/Price Margin"
description="Add fees or margins to LLM costs for internal billing and cost recovery"
/>
<CollapsibleContent className="px-0">
<div className="p-6">
<div className="flex justify-end mb-4">
<Button onClick={() => setIsMarginModalVisible(true)}>+ Add Provider Margin</Button>
</div>
{isFetching ? (
<div className="py-12 text-center">
<Text className="text-gray-500">Loading configuration...</Text>
<p className="text-gray-500">Loading configuration...</p>
</div>
) : Object.keys(marginConfig).length > 0 ? (
<ProviderMarginTable
@ -284,33 +287,49 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<Text className="text-gray-700 font-medium mb-2">No provider margins configured</Text>
<Text className="text-gray-500 text-sm">Click &quot;Add Provider Margin&quot; to get started</Text>
<p className="text-gray-700 font-medium mb-2">No provider margins configured</p>
<p className="text-gray-500 text-sm">Click &quot;Add Provider Margin&quot; to get started</p>
</div>
)}
</div>
</AccordionBody>
</Accordion>
</CollapsibleContent>
</Collapsible>
)}
{/* Accordion 3: Pricing Calculator - Available to all roles */}
<Accordion defaultOpen={true}>
<AccordionHeader className="px-6 py-4">
<div className="flex flex-col items-start w-full">
<Text className="text-lg font-semibold text-gray-900">Pricing Calculator</Text>
<Text className="text-sm text-gray-500 mt-1">
Estimate LLM costs based on expected token usage and request volume
</Text>
</div>
</AccordionHeader>
<AccordionBody className="px-0">
<Collapsible defaultOpen={true} className="rounded-lg border">
<SectionHeader
title="Pricing Calculator"
description="Estimate LLM costs based on expected token usage and request volume"
/>
<CollapsibleContent className="px-0">
<div className="p-6">
<PricingCalculator accessToken={accessToken} models={models} />
</div>
</AccordionBody>
</Accordion>
</CollapsibleContent>
</Collapsible>
</div>
{pendingRemoval && (
<AlertDialog open onOpenChange={(open) => !open && setPendingRemoval(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{REMOVAL_COPY[pendingRemoval.kind].title}</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove the {REMOVAL_COPY[pendingRemoval.kind].noun} for{" "}
{pendingRemoval.displayName}?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handleConfirmRemoval}>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<Modal
title={
<div className="flex items-center space-x-3 pb-4 border-b border-gray-100">
@ -328,10 +347,10 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
}}
>
<div className="mt-6">
<Text className="text-sm text-gray-600 mb-6">
<p className="text-sm text-gray-600 mb-6">
Select a provider and set its discount percentage. Enter a value between 0% and 100% (e.g., 5 for a 5%
discount).
</Text>
</p>
<Form form={form} onFinish={handleFormSubmit} layout="vertical" className="space-y-6">
<AddProviderForm
discountConfig={discountConfig}
@ -362,10 +381,10 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
}}
>
<div className="mt-6">
<Text className="text-sm text-gray-600 mb-6">
<p className="text-sm text-gray-600 mb-6">
Select a provider (or &quot;Global&quot; for all providers) and configure the margin. You can use
percentage-based or fixed amount.
</Text>
</p>
<Form form={marginForm} layout="vertical" className="space-y-6">
<AddMarginForm
marginConfig={marginConfig}

View file

@ -41,6 +41,16 @@ const DEFAULT_PROPS = {
models: ["gpt-4", "gpt-3.5-turbo", "claude-3-sonnet"],
};
const dataRows = (): HTMLElement[] =>
within(screen.getByRole("table"))
.getAllByRole("row")
.filter((row) => within(row).queryAllByRole("combobox").length > 0);
const deleteButtonIn = (row: HTMLElement): HTMLElement => {
const cells = within(row).getAllByRole("cell");
return within(cells[cells.length - 1]).getByRole("button");
};
describe("PricingCalculator", () => {
beforeEach(() => {
vi.clearAllMocks();
@ -124,8 +134,31 @@ describe("PricingCalculator", () => {
it("should render column headers for Model, Input Tokens, and Output Tokens", () => {
renderWithProviders(<PricingCalculator {...DEFAULT_PROPS} />);
expect(screen.getByText("Model")).toBeInTheDocument();
expect(screen.getByText("Input Tokens")).toBeInTheDocument();
expect(screen.getByText("Output Tokens")).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Model" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Input Tokens" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Output Tokens" })).toBeInTheDocument();
});
it("should render a numeric field for input tokens, output tokens and requests", () => {
renderWithProviders(<PricingCalculator {...DEFAULT_PROPS} />);
expect(screen.getAllByRole("spinbutton")).toHaveLength(3);
});
it("should offer a model picker per row", () => {
renderWithProviders(<PricingCalculator {...DEFAULT_PROPS} />);
expect(screen.getAllByRole("combobox")).toHaveLength(1);
});
it("should remove a row when its delete button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<PricingCalculator {...DEFAULT_PROPS} />);
await user.click(screen.getByRole("button", { name: /add another model/i }));
const withTwoRows = dataRows();
expect(withTwoRows).toHaveLength(2);
await user.click(deleteButtonIn(withTwoRows[1]));
expect(dataRows()).toHaveLength(1);
});
});

View file

@ -1,6 +1,10 @@
import React, { useState, useCallback } from "react";
import { Table, Select, InputNumber, Button, Radio } from "antd";
import { DeleteOutlined, PlusOutlined } from "@ant-design/icons";
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { PricingCalculatorProps, ModelEntry } from "./types";
import MultiCostResults from "./multi_cost_results";
import { useMultiCostEstimate } from "./use_multi_cost_estimate";
@ -63,132 +67,115 @@ const PricingCalculator: React.FC<PricingCalculatorProps> = ({ accessToken, mode
const multiModelResult = getMultiModelResult(entries);
const columns = [
{
title: "Model",
dataIndex: "model",
key: "model",
width: "35%",
render: (_: string, record: ModelEntry) => (
<Select
showSearch
placeholder="Select a model"
value={record.model || undefined}
onChange={(value) => handleEntryChange(record.id, "model", value)}
optionFilterProp="label"
filterOption={(input, option) =>
String(option?.label ?? "")
.toLowerCase()
.includes(input.toLowerCase())
}
options={models.map((model) => ({
value: model,
label: model,
}))}
style={{ width: "100%" }}
size="small"
/>
),
},
{
title: "Input Tokens",
dataIndex: "input_tokens",
key: "input_tokens",
width: "18%",
render: (_: number, record: ModelEntry) => (
<InputNumber
min={0}
value={record.input_tokens}
onChange={(value) => handleEntryChange(record.id, "input_tokens", value ?? 0)}
style={{ width: "100%" }}
size="small"
formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}
/>
),
},
{
title: "Output Tokens",
dataIndex: "output_tokens",
key: "output_tokens",
width: "18%",
render: (_: number, record: ModelEntry) => (
<InputNumber
min={0}
value={record.output_tokens}
onChange={(value) => handleEntryChange(record.id, "output_tokens", value ?? 0)}
style={{ width: "100%" }}
size="small"
formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}
/>
),
},
{
title: `Requests/${timePeriod === "day" ? "Day" : "Month"}`,
dataIndex: timePeriod === "day" ? "num_requests_per_day" : "num_requests_per_month",
key: "num_requests",
width: "20%",
render: (_: number | undefined, record: ModelEntry) => (
<InputNumber
min={0}
value={timePeriod === "day" ? record.num_requests_per_day : record.num_requests_per_month}
onChange={(value) =>
handleEntryChange(
record.id,
timePeriod === "day" ? "num_requests_per_day" : "num_requests_per_month",
value ?? undefined,
)
}
style={{ width: "100%" }}
size="small"
placeholder="-"
formatter={(value) => (value ? `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : "")}
/>
),
},
{
title: "",
key: "actions",
width: 50,
render: (_: unknown, record: ModelEntry) => (
<Button
type="text"
icon={<DeleteOutlined />}
onClick={() => handleRemoveEntry(record.id)}
disabled={entries.length === 1}
danger
size="small"
/>
),
},
];
const modelOptions = models.map((model) => ({ label: model, value: model }));
const requestsField = timePeriod === "day" ? "num_requests_per_day" : "num_requests_per_month";
return (
<div className="space-y-4">
<div className="flex items-center justify-end mb-2">
<Radio.Group
<RadioGroup
value={timePeriod}
onChange={(e) => handleTimePeriodChange(e.target.value)}
size="small"
optionType="button"
buttonStyle="solid"
onValueChange={(value) => handleTimePeriodChange(value as TimePeriod)}
className="flex w-auto items-center gap-4"
>
<Radio.Button value="day">Per Day</Radio.Button>
<Radio.Button value="month">Per Month</Radio.Button>
</Radio.Group>
<label className="flex cursor-pointer items-center gap-2 text-sm">
<RadioGroupItem value="day" />
Per Day
</label>
<label className="flex cursor-pointer items-center gap-2 text-sm">
<RadioGroupItem value="month" />
Per Month
</label>
</RadioGroup>
</div>
<Table
columns={columns}
dataSource={entries}
rowKey="id"
pagination={false}
size="small"
footer={() => (
<Button type="dashed" onClick={handleAddEntry} icon={<PlusOutlined />} className="w-full">
Add Another Model
</Button>
)}
/>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[35%]">Model</TableHead>
<TableHead className="w-[18%]">Input Tokens</TableHead>
<TableHead className="w-[18%]">Output Tokens</TableHead>
<TableHead className="w-[20%]">Requests/{timePeriod === "day" ? "Day" : "Month"}</TableHead>
<TableHead className="w-[50px]">
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map((record, index) => (
<TableRow key={record.id}>
<TableCell className="whitespace-normal">
<SearchSelect
options={modelOptions}
value={record.model || undefined}
onValueChange={(value) => handleEntryChange(record.id, "model", value)}
placeholder="Select a model"
/>
</TableCell>
<TableCell>
<Input
type="number"
min={0}
className="h-8"
value={record.input_tokens}
onChange={(e) =>
handleEntryChange(record.id, "input_tokens", e.target.value === "" ? 0 : Number(e.target.value))
}
/>
</TableCell>
<TableCell>
<Input
type="number"
min={0}
className="h-8"
value={record.output_tokens}
onChange={(e) =>
handleEntryChange(record.id, "output_tokens", e.target.value === "" ? 0 : Number(e.target.value))
}
/>
</TableCell>
<TableCell>
<Input
type="number"
min={0}
className="h-8"
placeholder="-"
value={record[requestsField] ?? ""}
onChange={(e) =>
handleEntryChange(
record.id,
requestsField,
e.target.value === "" ? undefined : Number(e.target.value),
)
}
/>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Remove model row ${index + 1}`}
onClick={() => handleRemoveEntry(record.id)}
disabled={entries.length === 1}
className="text-destructive"
>
<Trash2 className="size-3.5" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={5}>
<Button variant="outline" onClick={handleAddEntry} className="w-full border-dashed">
<Plus className="size-3.5" />
Add Another Model
</Button>
</TableCell>
</TableRow>
</TableFooter>
</Table>
<MultiCostResults multiResult={multiModelResult} timePeriod={timePeriod} />
</div>

View file

@ -85,6 +85,14 @@ function emptyMultiResult(): MultiModelResult {
};
}
const expandToggle = (): HTMLElement => screen.getByRole("button", { name: /cost breakdown for / });
const shownBreakdown = (): HTMLElement | null => {
const label = screen.queryByText("Total/Request");
if (label === null) return null;
return label.closest("[style*='display: none']") === null ? label : null;
};
describe("MultiCostResults", () => {
beforeEach(() => {
vi.clearAllMocks();
@ -200,40 +208,78 @@ describe("MultiCostResults", () => {
expect(screen.getByRole("button", { name: /export/i })).toBeInTheDocument();
});
it("should render a column header for each summary column", () => {
renderWithProviders(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
expect(screen.getByRole("columnheader", { name: "Model" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Per Request" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Margin Fee" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Daily" })).toBeInTheDocument();
});
it("should not show the model breakdown before the row is expanded", () => {
renderWithProviders(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
expect(shownBreakdown()).toBeNull();
});
it("should expand the model breakdown row when the expand button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
// The expand column renders a button (RightOutlined icon) for rows without errors
const expandButtons = screen.getAllByRole("button");
// Find the small expand button (not the Export button)
const expandButton = expandButtons.find((btn) => !btn.textContent?.toLowerCase().includes("export"));
expect(expandButton).toBeDefined();
await user.click(expandToggle());
await user.click(expandButton!);
// After expanding, the SingleModelBreakdown should be visible
expect(screen.getByText("Total/Request")).toBeInTheDocument();
expect(shownBreakdown()).toBeVisible();
expect(screen.getByText("Daily Total (100 req)")).toBeInTheDocument();
});
it("should show the collapse icon after expanding a row", async () => {
it("should collapse the model breakdown again on a second click", async () => {
const user = userEvent.setup();
renderWithProviders(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
const getExpandButton = () => {
const allButtons = screen.getAllByRole("button");
return allButtons.find((btn) => !btn.textContent?.toLowerCase().includes("export"));
};
await user.click(expandToggle());
expect(shownBreakdown()).toBeVisible();
// Before expand: button has the "down" aria-label (RightOutlined renders as down in ant icons)
// Just verify clicking works and the breakdown content appears
await user.click(getExpandButton()!);
expect(screen.getByText("Total/Request")).toBeInTheDocument();
await user.click(expandToggle());
expect(shownBreakdown()).toBeNull();
});
// After a second click, the row collapses — content may be hidden or removed
await user.click(getExpandButton()!);
// The expanded content should no longer be visible
expect(screen.queryByText("Total/Request")).not.toBeVisible();
it("should name the breakdown toggle and report its expanded state", async () => {
const user = userEvent.setup();
renderWithProviders(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
const toggle = screen.getByRole("button", { name: "Show cost breakdown for gpt-4" });
expect(toggle).toHaveAttribute("aria-expanded", "false");
await user.click(toggle);
const collapseToggle = screen.getByRole("button", { name: "Hide cost breakdown for gpt-4" });
expect(collapseToggle).toHaveAttribute("aria-expanded", "true");
});
it("should not offer an expand toggle for a row that failed", () => {
renderWithProviders(
<MultiCostResults
multiResult={makeMultiResult({
entries: [
{
entry: { id: "e1", model: "gpt-4", input_tokens: 1000, output_tokens: 500 },
result: makeCostResponse(),
loading: false,
error: null,
},
{
entry: { id: "e2", model: "bad-model", input_tokens: 0, output_tokens: 0 },
result: null,
loading: false,
error: "Pricing not found",
},
],
})}
timePeriod="day"
/>,
);
expect(screen.getAllByRole("button", { name: /cost breakdown for / })).toHaveLength(1);
});
});

View file

@ -1,7 +1,11 @@
import React, { useState } from "react";
import { Text, Button } from "@tremor/react";
import { Card, Statistic, Row, Col, Divider, Spin, Table, Tag } from "antd";
import { LoadingOutlined, DownOutlined, RightOutlined } from "@ant-design/icons";
import { ChevronDown, ChevronRight } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { CostEstimateResponse } from "../types";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { MultiModelResult } from "./types";
@ -41,55 +45,57 @@ const SingleModelBreakdown: React.FC<{
<div className="space-y-3 bg-gray-50 p-4 rounded-lg">
{loading && (
<div className="flex items-center gap-2 text-gray-500 text-sm">
<Spin indicator={<LoadingOutlined spin />} size="small" />
<UiLoadingSpinner className="size-3.5" />
<span>Updating...</span>
</div>
)}
<div className="grid grid-cols-4 gap-4">
<div>
<Text className="text-xs text-gray-500 block">Total/Request</Text>
<Text className="text-base font-semibold text-blue-600">{formatCost(result.cost_per_request)}</Text>
<div className="min-w-0">
<p className="text-xs text-gray-500 block">Total/Request</p>
<p className="text-base font-semibold text-blue-600 break-words">{formatCost(result.cost_per_request)}</p>
</div>
<div>
<Text className="text-xs text-gray-500 block">Input Cost</Text>
<Text className="text-sm">{formatCost(result.input_cost_per_request)}</Text>
<div className="min-w-0">
<p className="text-xs text-gray-500 block">Input Cost</p>
<p className="text-sm break-words">{formatCost(result.input_cost_per_request)}</p>
</div>
<div>
<Text className="text-xs text-gray-500 block">Output Cost</Text>
<Text className="text-sm">{formatCost(result.output_cost_per_request)}</Text>
<div className="min-w-0">
<p className="text-xs text-gray-500 block">Output Cost</p>
<p className="text-sm break-words">{formatCost(result.output_cost_per_request)}</p>
</div>
<div>
<Text className="text-xs text-gray-500 block">Margin Fee</Text>
<Text className={`text-sm ${result.margin_cost_per_request > 0 ? "text-amber-600" : ""}`}>
<div className="min-w-0">
<p className="text-xs text-gray-500 block">Margin Fee</p>
<p className={`text-sm break-words ${result.margin_cost_per_request > 0 ? "text-amber-600" : ""}`}>
{formatCost(result.margin_cost_per_request)}
</Text>
</p>
</div>
</div>
{periodCost !== null && (
<div className="grid grid-cols-4 gap-4 pt-2 border-t border-gray-200">
<div>
<Text className="text-xs text-gray-500 block">
<div className="min-w-0">
<p className="text-xs text-gray-500 block">
{periodLabel} Total ({formatRequests(periodRequests)} req)
</Text>
<Text className={`text-base font-semibold ${timePeriod === "day" ? "text-green-600" : "text-purple-600"}`}>
</p>
<p
className={`text-base font-semibold break-words ${timePeriod === "day" ? "text-green-600" : "text-purple-600"}`}
>
{formatCost(periodCost)}
</Text>
</p>
</div>
<div>
<Text className="text-xs text-gray-500 block">{periodLabel} Input</Text>
<Text className="text-sm">{formatCost(periodInputCost)}</Text>
<div className="min-w-0">
<p className="text-xs text-gray-500 block">{periodLabel} Input</p>
<p className="text-sm break-words">{formatCost(periodInputCost)}</p>
</div>
<div>
<Text className="text-xs text-gray-500 block">{periodLabel} Output</Text>
<Text className="text-sm">{formatCost(periodOutputCost)}</Text>
<div className="min-w-0">
<p className="text-xs text-gray-500 block">{periodLabel} Output</p>
<p className="text-sm break-words">{formatCost(periodOutputCost)}</p>
</div>
<div>
<Text className="text-xs text-gray-500 block">{periodLabel} Margin Fee</Text>
<Text className={`text-sm ${(periodMarginCost ?? 0) > 0 ? "text-amber-600" : ""}`}>
<div className="min-w-0">
<p className="text-xs text-gray-500 block">{periodLabel} Margin Fee</p>
<p className={`text-sm break-words ${(periodMarginCost ?? 0) > 0 ? "text-amber-600" : ""}`}>
{formatCost(periodMarginCost)}
</Text>
</p>
</div>
</div>
)}
@ -124,7 +130,7 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
if (!hasAnyResult && !isAnyLoading && !hasAnyError) {
return (
<div className="py-6 text-center border border-dashed border-gray-300 rounded-lg bg-gray-50">
<Text className="text-gray-500">Select models above to see cost estimates</Text>
<p className="text-gray-500">Select models above to see cost estimates</p>
</div>
);
}
@ -133,8 +139,8 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
if (!hasAnyResult && isAnyLoading && !hasAnyError) {
return (
<div className="py-6 text-center">
<Spin indicator={<LoadingOutlined spin />} />
<Text className="text-gray-500 block mt-2">Calculating costs...</Text>
<UiLoadingSpinner className="inline-block size-5" />
<p className="text-gray-500 block mt-2">Calculating costs...</p>
</div>
);
}
@ -143,10 +149,10 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
if (!hasAnyResult && hasAnyError) {
return (
<div className="space-y-4">
<Divider className="my-4" />
<Separator className="my-4" />
<div className="flex items-center justify-between">
<Text className="text-base font-semibold text-gray-900">Cost Estimates</Text>
{isAnyLoading && <Spin indicator={<LoadingOutlined spin />} size="small" />}
<p className="text-base font-semibold text-gray-900">Cost Estimates</p>
{isAnyLoading && <UiLoadingSpinner className="size-3.5" />}
</div>
{/* Error Messages */}
{errorEntries.map((e) => (
@ -174,102 +180,10 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
const hasMargin = multiResult.totals.margin_per_request > 0;
const periodLabel = timePeriod === "day" ? "Daily" : "Monthly";
const periodCostKey = timePeriod === "day" ? "daily_cost" : "monthly_cost";
const summaryColumns = [
{
title: "Model",
dataIndex: "model",
key: "model",
render: (
text: string,
record: {
id: string;
provider?: string | null;
error?: string | null;
loading?: boolean;
hasZeroCost?: boolean | null;
},
) => (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{text}</span>
{record.provider && (
<Tag color="blue" className="text-xs">
{record.provider}
</Tag>
)}
{record.loading && <Spin indicator={<LoadingOutlined spin />} size="small" />}
</div>
{record.error && <div className="text-xs text-red-600 bg-red-50 px-2 py-1 rounded-sm"> {record.error}</div>}
{record.hasZeroCost && !record.error && (
<div className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded-sm">
No pricing data found for this model. Set base_model in config.
</div>
)}
</div>
),
},
{
title: "Per Request",
dataIndex: "cost_per_request",
key: "cost_per_request",
align: "right" as const,
render: (value: number | null, record: { error?: string | null }) =>
record.error ? (
<span className="text-gray-400">-</span>
) : (
<span className="font-mono text-sm">{formatCost(value)}</span>
),
},
{
title: "Margin Fee",
dataIndex: "margin_cost_per_request",
key: "margin_cost_per_request",
align: "right" as const,
render: (value: number | null, record: { error?: string | null }) =>
record.error ? (
<span className="text-gray-400">-</span>
) : (
<span className={`font-mono text-sm ${(value ?? 0) > 0 ? "text-amber-600" : "text-gray-400"}`}>
{formatCost(value)}
</span>
),
},
{
title: periodLabel,
dataIndex: periodCostKey,
key: "period_cost",
align: "right" as const,
render: (value: number | null, record: { error?: string | null }) =>
record.error ? (
<span className="text-gray-400">-</span>
) : (
<span className="font-mono text-sm">{formatCost(value)}</span>
),
},
{
title: "",
key: "expand",
width: 40,
render: (_: unknown, record: { id: string; error?: string | null }) =>
record.error ? null : (
<Button
size="xs"
variant="light"
onClick={() => toggleExpanded(record.id)}
className="text-gray-400 hover:text-gray-600"
>
{expandedModels.has(record.id) ? <DownOutlined /> : <RightOutlined />}
</Button>
),
},
];
// Include both valid results and errors in the table data
const allEntriesWithModels = multiResult.entries.filter((e) => e.entry.model);
const summaryData = allEntriesWithModels.map((e) => ({
key: e.entry.id,
id: e.entry.id,
model: e.result?.model || e.entry.model,
provider: e.result?.provider,
@ -284,78 +198,153 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
return (
<div className="space-y-4">
<Divider className="my-4" />
<Separator className="my-4" />
<div className="flex items-center justify-between">
<Text className="text-base font-semibold text-gray-900">Cost Estimates</Text>
<p className="text-base font-semibold text-gray-900">Cost Estimates</p>
<div className="flex items-center gap-2">
{isAnyLoading && <Spin indicator={<LoadingOutlined spin />} size="small" />}
{isAnyLoading && <UiLoadingSpinner className="size-3.5" />}
<MultiExportDropdown multiResult={multiResult} />
</div>
</div>
{/* Combined Totals - Always show when there are results */}
<Card size="small" className="bg-linear-to-r from-slate-50 to-blue-50 border-slate-200">
<Row gutter={[16, 8]}>
<Col xs={24} sm={12}>
<Statistic
title={<span className="text-xs">Total Per Request</span>}
value={formatCost(multiResult.totals.cost_per_request)}
valueStyle={{ color: "#1890ff", fontSize: "18px", fontFamily: "monospace" }}
/>
</Col>
<Col xs={24} sm={12}>
<Statistic
title={<span className="text-xs">Total {periodLabel}</span>}
value={formatCost(timePeriod === "day" ? multiResult.totals.daily_cost : multiResult.totals.monthly_cost)}
valueStyle={{
color: timePeriod === "day" ? "#52c41a" : "#722ed1",
fontSize: "18px",
fontFamily: "monospace",
}}
/>
</Col>
</Row>
<Card size="sm" className="px-4 bg-linear-to-r from-slate-50 to-blue-50">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2">
<div className="min-w-0">
<span className="text-xs text-gray-500">Total Per Request</span>
<div className="text-lg font-mono text-blue-600 break-words">
{formatCost(multiResult.totals.cost_per_request)}
</div>
</div>
<div className="min-w-0">
<span className="text-xs text-gray-500">Total {periodLabel}</span>
<div
className={`text-lg font-mono break-words ${timePeriod === "day" ? "text-green-600" : "text-purple-600"}`}
>
{formatCost(timePeriod === "day" ? multiResult.totals.daily_cost : multiResult.totals.monthly_cost)}
</div>
</div>
</div>
{hasMargin && (
<Row gutter={[16, 8]} className="mt-3 pt-3 border-t border-slate-200">
<Col xs={24} sm={12}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2 mt-3 pt-3 border-t border-slate-200">
<div className="min-w-0">
<div className="text-xs text-gray-500">Margin Fee/Request</div>
<div className="text-sm font-mono text-amber-600">
<div className="text-sm font-mono text-amber-600 break-words">
{formatCost(multiResult.totals.margin_per_request)}
</div>
</Col>
<Col xs={24} sm={12}>
</div>
<div className="min-w-0">
<div className="text-xs text-gray-500">{periodLabel} Margin Fee</div>
<div className="text-sm font-mono text-amber-600">
<div className="text-sm font-mono text-amber-600 break-words">
{formatCost(timePeriod === "day" ? multiResult.totals.daily_margin : multiResult.totals.monthly_margin)}
</div>
</Col>
</Row>
</div>
</div>
)}
</Card>
{/* Per-Model Table */}
{summaryData.length > 0 && (
<Table
columns={summaryColumns}
dataSource={summaryData}
pagination={false}
size="small"
className="border border-gray-200 rounded-lg"
expandable={{
expandedRowKeys: Array.from(expandedModels),
expandedRowRender: (record) => {
const entry = validEntries.find((e) => e.entry.id === record.id);
if (!entry?.result) return null;
<Table className="border border-gray-200 rounded-lg">
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead className="text-right">Per Request</TableHead>
<TableHead className="text-right">Margin Fee</TableHead>
<TableHead className="text-right">{periodLabel}</TableHead>
<TableHead className="w-10">
<span className="sr-only">Cost breakdown</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{summaryData.map((record) => {
const isExpanded = expandedModels.has(record.id);
const periodCost = timePeriod === "day" ? record.daily_cost : record.monthly_cost;
const breakdownEntry = validEntries.find((e) => e.entry.id === record.id);
return (
<div className="py-2">
<SingleModelBreakdown result={entry.result} loading={entry.loading} timePeriod={timePeriod} />
</div>
<React.Fragment key={record.id}>
<TableRow>
<TableCell className="whitespace-normal">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm break-words">{record.model}</span>
{record.provider && (
<Badge variant="secondary" className="text-xs">
{record.provider}
</Badge>
)}
{record.loading && <UiLoadingSpinner className="size-3.5" />}
</div>
{record.error && (
<div className="text-xs text-red-600 bg-red-50 px-2 py-1 rounded-sm"> {record.error}</div>
)}
{record.hasZeroCost && !record.error && (
<div className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded-sm">
No pricing data found for this model. Set base_model in config.
</div>
)}
</div>
</TableCell>
<TableCell className="text-right">
{record.error ? (
<span className="text-gray-400">-</span>
) : (
<span className="font-mono text-sm">{formatCost(record.cost_per_request)}</span>
)}
</TableCell>
<TableCell className="text-right">
{record.error ? (
<span className="text-gray-400">-</span>
) : (
<span
className={`font-mono text-sm ${(record.margin_cost_per_request ?? 0) > 0 ? "text-amber-600" : "text-gray-400"}`}
>
{formatCost(record.margin_cost_per_request)}
</span>
)}
</TableCell>
<TableCell className="text-right">
{record.error ? (
<span className="text-gray-400">-</span>
) : (
<span className="font-mono text-sm">{formatCost(periodCost)}</span>
)}
</TableCell>
<TableCell className="text-right">
{!record.error && (
<Button
variant="ghost"
size="icon-xs"
aria-expanded={isExpanded}
aria-label={`${isExpanded ? "Hide" : "Show"} cost breakdown for ${record.model}`}
onClick={() => toggleExpanded(record.id)}
className="text-gray-400 hover:text-gray-600"
>
{isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
</Button>
)}
</TableCell>
</TableRow>
{isExpanded && breakdownEntry?.result && (
<TableRow>
<TableCell colSpan={5} className="whitespace-normal">
<div className="py-2">
<SingleModelBreakdown
result={breakdownEntry.result}
loading={breakdownEntry.loading}
timePeriod={timePeriod}
/>
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
},
showExpandColumn: false,
}}
/>
})}
</TableBody>
</Table>
)}
</div>
);

View file

@ -5,49 +5,21 @@ import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "../../../../../tests/test-utils";
import ProviderDiscountTable from "./provider_discount_table";
vi.mock("@heroicons/react/outline", () => ({
TrashIcon: function TrashIcon() {
return null;
},
PencilAltIcon: function PencilAltIcon() {
return null;
},
CheckIcon: function CheckIcon() {
return null;
},
XIcon: function XIcon() {
return null;
},
}));
vi.mock("@tremor/react", () => ({
Table: ({ children }: any) => <table>{children}</table>,
TableHead: ({ children }: any) => <thead>{children}</thead>,
TableRow: ({ children }: any) => <tr>{children}</tr>,
TableHeaderCell: ({ children }: any) => <th>{children}</th>,
TableBody: ({ children }: any) => <tbody>{children}</tbody>,
TableCell: ({ children }: any) => <td>{children}</td>,
Text: ({ children }: any) => <span>{children}</span>,
TextInput: ({ value, onValueChange, onKeyDown, placeholder, ...rest }: any) => (
<input
value={value}
onChange={(e) => onValueChange?.(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
{...rest}
/>
),
Icon: ({ icon: IconComponent, onClick }: any) => {
const name = IconComponent?.displayName ?? IconComponent?.name ?? "icon";
return <button onClick={onClick} aria-label={name} />;
},
}));
const DEFAULT_DISCOUNT_CONFIG = {
openai: 0.05,
anthropic: 0.1,
};
const ROW_ACTION_NAME = {
edit: /^Edit discount for /,
save: /^Save discount for /,
cancel: /^Cancel editing discount for /,
remove: /^Remove discount for /,
} as const;
const rowAction = (action: keyof typeof ROW_ACTION_NAME): HTMLElement =>
screen.getByRole("button", { name: ROW_ACTION_NAME[action] });
describe("ProviderDiscountTable", () => {
const onDiscountChange = vi.fn();
const onRemoveProvider = vi.fn();
@ -75,9 +47,9 @@ describe("ProviderDiscountTable", () => {
onRemoveProvider={onRemoveProvider}
/>,
);
expect(screen.getByText("Provider")).toBeInTheDocument();
expect(screen.getByText("Discount Percentage")).toBeInTheDocument();
expect(screen.getByText("Actions")).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Provider" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Discount Percentage" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Actions" })).toBeInTheDocument();
});
it("should display provider display names in the table", () => {
@ -91,6 +63,21 @@ describe("ProviderDiscountTable", () => {
expect(screen.getByText("OpenAI")).toBeInTheDocument();
});
it("should sort rows by provider display name", () => {
renderWithProviders(
<ProviderDiscountTable
discountConfig={DEFAULT_DISCOUNT_CONFIG}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>,
);
const rows = screen.getAllByRole("row").slice(1);
expect(rows.map((row) => row.textContent)).toEqual([
expect.stringContaining("Anthropic"),
expect.stringContaining("OpenAI"),
]);
});
it("should display the formatted discount percentage", () => {
renderWithProviders(
<ProviderDiscountTable
@ -102,6 +89,17 @@ describe("ProviderDiscountTable", () => {
expect(screen.getByText("5.0%")).toBeInTheDocument();
});
it("should render the provider logo alongside the display name", () => {
renderWithProviders(
<ProviderDiscountTable
discountConfig={{ openai: 0.05 }}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>,
);
expect(screen.getByRole("img", { name: "OpenAI logo" })).toBeInTheDocument();
});
it("should show a text input when the edit icon is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
@ -112,8 +110,7 @@ describe("ProviderDiscountTable", () => {
/>,
);
const pencilButton = screen.getByRole("button", { name: /PencilAltIcon/i });
await user.click(pencilButton);
await user.click(rowAction("edit"));
expect(screen.getByPlaceholderText("5")).toBeInTheDocument();
});
@ -128,11 +125,26 @@ describe("ProviderDiscountTable", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
await user.click(rowAction("edit"));
expect(screen.queryByText("5.0%")).not.toBeInTheDocument();
});
it("should seed the edit input with the current discount as a percentage", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderDiscountTable
discountConfig={{ openai: 0.05 }}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>,
);
await user.click(rowAction("edit"));
expect(screen.getByPlaceholderText("5")).toHaveValue("5");
});
it("should call onDiscountChange with the new value when the save icon is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
@ -143,17 +155,57 @@ describe("ProviderDiscountTable", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
await user.click(rowAction("edit"));
const input = screen.getByPlaceholderText("5");
await user.clear(input);
await user.type(input, "10");
await user.click(screen.getByRole("button", { name: /CheckIcon/i }));
await user.click(rowAction("save"));
expect(onDiscountChange).toHaveBeenCalledWith("openai", "0.1");
});
it("should save the edited discount when Enter is pressed", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderDiscountTable
discountConfig={{ openai: 0.05 }}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>,
);
await user.click(rowAction("edit"));
const input = screen.getByPlaceholderText("5");
await user.clear(input);
await user.type(input, "10{Enter}");
expect(onDiscountChange).toHaveBeenCalledWith("openai", "0.1");
expect(screen.queryByPlaceholderText("5")).not.toBeInTheDocument();
});
it("should abandon the edit when Escape is pressed", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderDiscountTable
discountConfig={{ openai: 0.05 }}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>,
);
await user.click(rowAction("edit"));
const input = screen.getByPlaceholderText("5");
await user.clear(input);
await user.type(input, "10{Escape}");
expect(onDiscountChange).not.toHaveBeenCalled();
expect(screen.getByText("5.0%")).toBeInTheDocument();
});
it("should restore the display view after saving", async () => {
const user = userEvent.setup();
renderWithProviders(
@ -164,8 +216,8 @@ describe("ProviderDiscountTable", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
await user.click(screen.getByRole("button", { name: /CheckIcon/i }));
await user.click(rowAction("edit"));
await user.click(rowAction("save"));
expect(screen.queryByPlaceholderText("5")).not.toBeInTheDocument();
});
@ -180,30 +232,14 @@ describe("ProviderDiscountTable", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
await user.click(screen.getByRole("button", { name: /XIcon/i }));
await user.click(rowAction("edit"));
await user.click(rowAction("cancel"));
expect(screen.queryByPlaceholderText("5")).not.toBeInTheDocument();
expect(onDiscountChange).not.toHaveBeenCalled();
expect(screen.getByText("5.0%")).toBeInTheDocument();
});
it("should not call onDiscountChange when canceling edit", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderDiscountTable
discountConfig={{ openai: 0.05 }}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>,
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
await user.click(screen.getByRole("button", { name: /XIcon/i }));
expect(onDiscountChange).not.toHaveBeenCalled();
});
it("should call onRemoveProvider with the provider key and display name when the trash icon is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
@ -214,7 +250,7 @@ describe("ProviderDiscountTable", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /TrashIcon/i }));
await user.click(rowAction("remove"));
expect(onRemoveProvider).toHaveBeenCalledWith("openai", "OpenAI");
});
@ -229,12 +265,42 @@ describe("ProviderDiscountTable", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
await user.click(rowAction("edit"));
const input = screen.getByPlaceholderText("5");
await user.clear(input);
await user.type(input, "150");
await user.click(screen.getByRole("button", { name: /CheckIcon/i }));
await user.click(rowAction("save"));
expect(onDiscountChange).not.toHaveBeenCalled();
});
it("should expose each row action as a button named for its provider", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderDiscountTable
discountConfig={{ openai: 0.05 }}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>,
);
expect(screen.getByRole("button", { name: "Edit discount for OpenAI" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Remove discount for OpenAI" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Edit discount for OpenAI" }));
expect(screen.getByRole("button", { name: "Save discount for OpenAI" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Cancel editing discount for OpenAI" })).toBeInTheDocument();
});
it("should render the empty message when no discounts are configured", () => {
renderWithProviders(
<ProviderDiscountTable
discountConfig={{}}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>,
);
expect(screen.getByText("No provider discounts configured")).toBeInTheDocument();
});
});

View file

@ -1,6 +1,7 @@
import React, { useState } from "react";
import { TextInput, Icon, Text } from "@tremor/react";
import { TrashIcon, PencilAltIcon, CheckIcon, XIcon } from "@heroicons/react/outline";
import { Check, SquarePen, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SimpleTable } from "@/components/common_components/simple_table";
import { DiscountConfig } from "./types";
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
@ -79,45 +80,57 @@ const ProviderDiscountTable: React.FC<ProviderDiscountTableProps> = ({
},
{
header: "Discount Percentage",
cell: (row) => (
<div className="flex items-center gap-2">
{editingProvider === row.provider ? (
<>
<TextInput
value={editValue}
onValueChange={setEditValue}
onKeyDown={(e) => handleKeyDown(e, row.provider)}
placeholder="5"
className="w-20"
autoFocus
/>
<span className="text-gray-600">%</span>
<Icon
icon={CheckIcon}
size="sm"
onClick={() => handleSaveEdit(row.provider)}
className="cursor-pointer text-green-600 hover:text-green-700"
/>
<Icon
icon={XIcon}
size="sm"
onClick={handleCancelEdit}
className="cursor-pointer text-gray-600 hover:text-gray-700"
/>
</>
) : (
<>
<Text className="font-medium">{(row.discount * 100).toFixed(1)}%</Text>
<Icon
icon={PencilAltIcon}
size="sm"
onClick={() => handleStartEdit(row.provider, row.discount)}
className="cursor-pointer text-blue-600 hover:text-blue-700"
/>
</>
)}
</div>
),
cell: (row) => {
const { displayName } = getProviderLogoAndName(row.provider);
return (
<div className="flex items-center gap-2">
{editingProvider === row.provider ? (
<>
<Input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => handleKeyDown(e, row.provider)}
placeholder="5"
className="w-20"
autoFocus
/>
<span className="text-gray-600">%</span>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Save discount for ${displayName}`}
onClick={() => handleSaveEdit(row.provider)}
className="cursor-pointer text-green-600 hover:text-green-700"
>
<Check className="size-5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Cancel editing discount for ${displayName}`}
onClick={handleCancelEdit}
className="cursor-pointer text-gray-600 hover:text-gray-700"
>
<X className="size-5" />
</Button>
</>
) : (
<>
<p className="font-medium">{(row.discount * 100).toFixed(1)}%</p>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit discount for ${displayName}`}
onClick={() => handleStartEdit(row.provider, row.discount)}
className="cursor-pointer text-blue-600 hover:text-blue-700"
>
<SquarePen className="size-5" />
</Button>
</>
)}
</div>
);
},
width: "250px",
},
{
@ -125,12 +138,15 @@ const ProviderDiscountTable: React.FC<ProviderDiscountTableProps> = ({
cell: (row) => {
const { displayName } = getProviderLogoAndName(row.provider);
return (
<Icon
icon={TrashIcon}
size="sm"
<Button
variant="ghost"
size="icon-sm"
aria-label={`Remove discount for ${displayName}`}
onClick={() => onRemoveProvider(row.provider, displayName)}
className="cursor-pointer hover:text-red-600"
/>
>
<Trash2 className="size-5" />
</Button>
);
},
width: "80px",

View file

@ -6,43 +6,15 @@ import { renderWithProviders } from "../../../../../tests/test-utils";
import ProviderMarginTable from "./provider_margin_table";
import { Providers, providerLogoMap } from "@/components/provider_info_helpers";
vi.mock("@heroicons/react/outline", () => ({
TrashIcon: function TrashIcon() {
return null;
},
PencilAltIcon: function PencilAltIcon() {
return null;
},
CheckIcon: function CheckIcon() {
return null;
},
XIcon: function XIcon() {
return null;
},
}));
const ROW_ACTION_NAME = {
edit: /^Edit margin for /,
save: /^Save margin for /,
cancel: /^Cancel editing margin for /,
remove: /^Remove margin for /,
} as const;
vi.mock("@tremor/react", () => ({
Table: ({ children }: any) => <table>{children}</table>,
TableHead: ({ children }: any) => <thead>{children}</thead>,
TableRow: ({ children }: any) => <tr>{children}</tr>,
TableHeaderCell: ({ children }: any) => <th>{children}</th>,
TableBody: ({ children }: any) => <tbody>{children}</tbody>,
TableCell: ({ children }: any) => <td>{children}</td>,
Text: ({ children }: any) => <span>{children}</span>,
TextInput: ({ value, onValueChange, placeholder, autoFocus, className }: any) => (
<input
value={value}
onChange={(e) => onValueChange?.(e.target.value)}
placeholder={placeholder}
autoFocus={autoFocus}
className={className}
/>
),
Icon: ({ icon: IconComponent, onClick }: any) => {
const name = IconComponent?.displayName ?? IconComponent?.name ?? "icon";
return <button onClick={onClick} aria-label={name} />;
},
}));
const rowAction = (action: keyof typeof ROW_ACTION_NAME): HTMLElement =>
screen.getByRole("button", { name: ROW_ACTION_NAME[action] });
describe("ProviderMarginTable", () => {
const onMarginChange = vi.fn();
@ -71,9 +43,9 @@ describe("ProviderMarginTable", () => {
onRemoveProvider={onRemoveProvider}
/>,
);
expect(screen.getByText("Provider")).toBeInTheDocument();
expect(screen.getByText("Margin")).toBeInTheDocument();
expect(screen.getByText("Actions")).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Provider" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Margin" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Actions" })).toBeInTheDocument();
});
it("should display the provider display name", () => {
@ -122,6 +94,21 @@ describe("ProviderMarginTable", () => {
expect(screen.getByText("Global (All Providers)")).toBeInTheDocument();
});
it("should sort the global row above provider rows", () => {
renderWithProviders(
<ProviderMarginTable
marginConfig={{ openai: 0.1, global: 0.05 }}
onMarginChange={onMarginChange}
onRemoveProvider={onRemoveProvider}
/>,
);
const rows = screen.getAllByRole("row").slice(1);
expect(rows.map((row) => row.textContent)).toEqual([
expect.stringContaining("Global (All Providers)"),
expect.stringContaining("OpenAI"),
]);
});
it("should display a numeric margin as a percentage", () => {
renderWithProviders(
<ProviderMarginTable
@ -165,12 +152,28 @@ describe("ProviderMarginTable", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
await user.click(rowAction("edit"));
expect(screen.getByPlaceholderText("10")).toBeInTheDocument();
expect(screen.getByPlaceholderText("0.001")).toBeInTheDocument();
});
it("should seed the percentage input from a numeric margin and leave the fixed amount blank", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderMarginTable
marginConfig={{ openai: 0.1 }}
onMarginChange={onMarginChange}
onRemoveProvider={onRemoveProvider}
/>,
);
await user.click(rowAction("edit"));
expect(screen.getByPlaceholderText("10")).toHaveValue("10");
expect(screen.getByPlaceholderText("0.001")).toHaveValue("");
});
it("should call onMarginChange with a percentage value when save is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
@ -181,17 +184,37 @@ describe("ProviderMarginTable", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
await user.click(rowAction("edit"));
const percentInput = screen.getByPlaceholderText("10");
await user.clear(percentInput);
await user.type(percentInput, "20");
await user.click(screen.getByRole("button", { name: /CheckIcon/i }));
await user.click(rowAction("save"));
expect(onMarginChange).toHaveBeenCalledWith("openai", 0.2);
});
it("should call onMarginChange with a fixed-amount-only object when the percentage is cleared", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderMarginTable
marginConfig={{ openai: 0.1 }}
onMarginChange={onMarginChange}
onRemoveProvider={onRemoveProvider}
/>,
);
await user.click(rowAction("edit"));
await user.clear(screen.getByPlaceholderText("10"));
await user.type(screen.getByPlaceholderText("0.001"), "0.002");
await user.click(rowAction("save"));
expect(onMarginChange).toHaveBeenCalledWith("openai", { fixed_amount: 0.002 });
});
it("should cancel edit mode without calling onMarginChange when X is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
@ -202,8 +225,8 @@ describe("ProviderMarginTable", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
await user.click(screen.getByRole("button", { name: /XIcon/i }));
await user.click(rowAction("edit"));
await user.click(rowAction("cancel"));
expect(onMarginChange).not.toHaveBeenCalled();
expect(screen.queryByPlaceholderText("10")).not.toBeInTheDocument();
@ -219,7 +242,7 @@ describe("ProviderMarginTable", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /TrashIcon/i }));
await user.click(rowAction("remove"));
expect(onRemoveProvider).toHaveBeenCalledWith("openai", "OpenAI");
});
@ -234,11 +257,50 @@ describe("ProviderMarginTable", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /TrashIcon/i }));
await user.click(rowAction("remove"));
expect(onRemoveProvider).toHaveBeenCalledWith("global", "Global");
});
it("should expose each row action as a button named for its provider", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderMarginTable
marginConfig={{ openai: 0.1 }}
onMarginChange={onMarginChange}
onRemoveProvider={onRemoveProvider}
/>,
);
expect(screen.getByRole("button", { name: "Edit margin for OpenAI" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Remove margin for OpenAI" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Edit margin for OpenAI" }));
expect(screen.getByRole("button", { name: "Save margin for OpenAI" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Cancel editing margin for OpenAI" })).toBeInTheDocument();
});
it("should name the global row's actions after the global provider", () => {
renderWithProviders(
<ProviderMarginTable
marginConfig={{ global: 0.05 }}
onMarginChange={onMarginChange}
onRemoveProvider={onRemoveProvider}
/>,
);
expect(screen.getByRole("button", { name: "Edit margin for Global" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Remove margin for Global" })).toBeInTheDocument();
});
it("should render the empty message when no margins are configured", () => {
renderWithProviders(
<ProviderMarginTable marginConfig={{}} onMarginChange={onMarginChange} onRemoveProvider={onRemoveProvider} />,
);
expect(screen.getByText("No provider margins configured")).toBeInTheDocument();
});
describe("when both percentage and fixed amount are entered", () => {
it("should call onMarginChange with an object containing both values", async () => {
const user = userEvent.setup();
@ -250,7 +312,7 @@ describe("ProviderMarginTable", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
await user.click(rowAction("edit"));
const percentInput = screen.getByPlaceholderText("10");
await user.clear(percentInput);
@ -259,7 +321,7 @@ describe("ProviderMarginTable", () => {
const fixedInput = screen.getByPlaceholderText("0.001");
await user.type(fixedInput, "0.002");
await user.click(screen.getByRole("button", { name: /CheckIcon/i }));
await user.click(rowAction("save"));
expect(onMarginChange).toHaveBeenCalledWith("openai", {
percentage: 0.05,

View file

@ -1,6 +1,7 @@
import React, { useState } from "react";
import { TextInput, Icon, Text } from "@tremor/react";
import { TrashIcon, PencilAltIcon, CheckIcon, XIcon } from "@heroicons/react/outline";
import { Check, SquarePen, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SimpleTable } from "@/components/common_components/simple_table";
import { MarginConfig } from "./types";
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
@ -17,6 +18,9 @@ interface ProviderMarginRow {
margin: number | { percentage?: number; fixed_amount?: number };
}
const marginRowDisplayName = (provider: string): string =>
provider === "global" ? "Global" : getProviderLogoAndName(provider).displayName;
const ProviderMarginTable: React.FC<ProviderMarginTableProps> = ({
marginConfig,
onMarginChange,
@ -119,67 +123,82 @@ const ProviderMarginTable: React.FC<ProviderMarginTableProps> = ({
},
{
header: "Margin",
cell: (row) => (
<div className="flex items-center gap-2">
{editingProvider === row.provider ? (
<>
<div className="flex items-center gap-2">
<TextInput
value={editPercentage}
onValueChange={setEditPercentage}
placeholder="10"
className="w-20"
autoFocus
/>
<span className="text-gray-600">%</span>
<span className="text-gray-400">+</span>
<span className="text-gray-600">$</span>
<TextInput
value={editFixedAmount}
onValueChange={setEditFixedAmount}
placeholder="0.001"
className="w-24"
/>
</div>
<Icon
icon={CheckIcon}
size="sm"
onClick={() => handleSaveEdit(row.provider)}
className="cursor-pointer text-green-600 hover:text-green-700"
/>
<Icon
icon={XIcon}
size="sm"
onClick={handleCancelEdit}
className="cursor-pointer text-gray-600 hover:text-gray-700"
/>
</>
) : (
<>
<Text className="font-medium">{formatMargin(row.margin)}</Text>
<Icon
icon={PencilAltIcon}
size="sm"
onClick={() => handleStartEdit(row.provider, row.margin)}
className="cursor-pointer text-blue-600 hover:text-blue-700"
/>
</>
)}
</div>
),
cell: (row) => {
const displayName = marginRowDisplayName(row.provider);
return (
<div className="flex items-center gap-2">
{editingProvider === row.provider ? (
<>
<div className="flex items-center gap-2">
<Input
value={editPercentage}
onChange={(e) => setEditPercentage(e.target.value)}
placeholder="10"
className="w-20"
autoFocus
/>
<span className="text-gray-600">%</span>
<span className="text-gray-400">+</span>
<span className="text-gray-600">$</span>
<Input
value={editFixedAmount}
onChange={(e) => setEditFixedAmount(e.target.value)}
placeholder="0.001"
className="w-24"
/>
</div>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Save margin for ${displayName}`}
onClick={() => handleSaveEdit(row.provider)}
className="cursor-pointer text-green-600 hover:text-green-700"
>
<Check className="size-5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Cancel editing margin for ${displayName}`}
onClick={handleCancelEdit}
className="cursor-pointer text-gray-600 hover:text-gray-700"
>
<X className="size-5" />
</Button>
</>
) : (
<>
<p className="font-medium">{formatMargin(row.margin)}</p>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit margin for ${displayName}`}
onClick={() => handleStartEdit(row.provider, row.margin)}
className="cursor-pointer text-blue-600 hover:text-blue-700"
>
<SquarePen className="size-5" />
</Button>
</>
)}
</div>
);
},
width: "350px",
},
{
header: "Actions",
cell: (row) => {
const displayName = row.provider === "global" ? "Global" : getProviderLogoAndName(row.provider).displayName;
const displayName = marginRowDisplayName(row.provider);
return (
<Icon
icon={TrashIcon}
size="sm"
<Button
variant="ghost"
size="icon-sm"
aria-label={`Remove margin for ${displayName}`}
onClick={() => onRemoveProvider(row.provider, displayName)}
className="cursor-pointer hover:text-red-600"
/>
>
<Trash2 className="size-5" />
</Button>
);
},
width: "80px",