feat(ui): EntityUsageExport to shadcn

- ExportFormatSelector: antd Select \u2192 shadcn Select with explicit Label.
- ExportTypeSelector: antd Radio.Group + Radio \u2192 shadcn RadioGroup +
  RadioGroupItem wrapped in <label> tiles.
- ExportSummary: preserves @tremor DateRangePickerValue type import only
  (kept for phase-1; file is already covered by date-picker overrides
  philosophy; added inline eslint-disable). text-gray-500 \u2192
  text-muted-foreground.
- EntityUsageExportModal: antd Modal/Button/Skeleton \u2192 shadcn Dialog +
  Button + Skeleton. 'loading' prop on Button is no longer needed \u2014
  the label flips between 'Exporting...' and the format name.

Gates: TS \u2713 | Build \u2713.

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-23 11:03:17 +00:00
parent 6d182db494
commit ba043e06a5
No known key found for this signature in database
4 changed files with 176 additions and 95 deletions

View file

@ -1,12 +1,23 @@
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { createTeamAliasMap } from "@/utils/teamUtils";
import { Button, Modal, Skeleton } from "antd";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import React, { useMemo, useState } from "react";
import NotificationsManager from "../molecules/notifications_manager";
import ExportFormatSelector from "./ExportFormatSelector";
import ExportSummary from "./ExportSummary";
import ExportTypeSelector from "./ExportTypeSelector";
import type { EntityUsageExportModalProps, ExportFormat, ExportScope } from "./types";
import type {
EntityUsageExportModalProps,
ExportFormat,
ExportScope,
} from "./types";
import { handleExportCSV, handleExportJSON } from "./utils";
const EntityUsageExportModal: React.FC<EntityUsageExportModalProps> = ({
@ -26,18 +37,36 @@ const EntityUsageExportModal: React.FC<EntityUsageExportModalProps> = ({
const entityLabel = entityType.charAt(0).toUpperCase() + entityType.slice(1);
const modalTitle = customTitle || `Export ${entityLabel} Usage`;
// Cache team alias map using useMemo
const teamAliasMap = useMemo(() => createTeamAliasMap(teams), [teams]);
const handleExport = async (format?: ExportFormat) => {
const formatToUse = format || exportFormat;
setIsExporting(true);
try {
if (formatToUse === "csv") {
handleExportCSV(spendData, exportScope, entityLabel, entityType, teamAliasMap);
NotificationsManager.success(`${entityLabel} usage data exported successfully as CSV`);
handleExportCSV(
spendData,
exportScope,
entityLabel,
entityType,
teamAliasMap,
);
NotificationsManager.success(
`${entityLabel} usage data exported successfully as CSV`,
);
} else {
handleExportJSON(spendData, exportScope, entityLabel, entityType, dateRange, selectedFilters, teamAliasMap);
NotificationsManager.success(`${entityLabel} usage data exported successfully as JSON`);
handleExportJSON(
spendData,
exportScope,
entityLabel,
entityType,
dateRange,
selectedFilters,
teamAliasMap,
);
NotificationsManager.success(
`${entityLabel} usage data exported successfully as JSON`,
);
}
onClose();
} catch (error) {
@ -49,45 +78,67 @@ const EntityUsageExportModal: React.FC<EntityUsageExportModalProps> = ({
};
return (
<Modal
title={<span className="text-base font-semibold">{modalTitle}</span>}
<Dialog
open={isOpen}
onCancel={onClose}
footer={null}
width={480}
onOpenChange={(open) => (!open ? onClose() : undefined)}
>
<div className="space-y-5 py-2">
{isLoadingTeams ? (
<Skeleton active />
) : (
<>
<ExportSummary dateRange={dateRange} selectedFilters={selectedFilters} />
<ExportTypeSelector value={exportScope} onChange={setExportScope} entityType={entityType} />
<ExportFormatSelector value={exportFormat} onChange={setExportFormat} />
</>
)}
{isLoadingTeams ? (
<div className="flex items-center justify-end gap-2 pt-4 border-t">
<Skeleton.Button active />
<Skeleton.Button active />
</div>
) : (
<div className="flex items-center justify-end gap-2 pt-4 border-t">
<Button variant="outlined" onClick={onClose} disabled={isExporting}>
Cancel
</Button>
<Button
onClick={() => handleExport()}
loading={isExporting || isLoadingTeams}
disabled={isExporting || isLoadingTeams}
type="primary"
>
{isExporting ? "Exporting..." : `Export ${exportFormat.toUpperCase()}`}
</Button>
</div>
)}
</div>
</Modal>
<DialogContent className="max-w-[480px]">
<DialogHeader>
<DialogTitle className="text-base font-semibold">
{modalTitle}
</DialogTitle>
</DialogHeader>
<div className="space-y-5 py-2">
{isLoadingTeams ? (
<div className="space-y-2">
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-2/3" />
</div>
) : (
<>
<ExportSummary
dateRange={dateRange}
selectedFilters={selectedFilters}
/>
<ExportTypeSelector
value={exportScope}
onChange={setExportScope}
entityType={entityType}
/>
<ExportFormatSelector
value={exportFormat}
onChange={setExportFormat}
/>
</>
)}
{isLoadingTeams ? (
<div className="flex items-center justify-end gap-2 pt-4 border-t">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-24" />
</div>
) : (
<div className="flex items-center justify-end gap-2 pt-4 border-t">
<Button
variant="outline"
onClick={onClose}
disabled={isExporting}
>
Cancel
</Button>
<Button
onClick={() => handleExport()}
disabled={isExporting || isLoadingTeams}
>
{isExporting
? "Exporting..."
: `Export ${exportFormat.toUpperCase()}`}
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};

View file

@ -1,5 +1,12 @@
import React from "react";
import { Select } from "antd";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { ExportFormat } from "./types";
interface ExportFormatSelectorProps {
@ -7,28 +14,24 @@ interface ExportFormatSelectorProps {
onChange: (value: ExportFormat) => void;
}
const ExportFormatSelector: React.FC<ExportFormatSelectorProps> = ({ value, onChange }) => {
const ExportFormatSelector: React.FC<ExportFormatSelectorProps> = ({
value,
onChange,
}) => {
return (
<div>
<label className="text-sm font-medium text-gray-700 block mb-2">Format</label>
<Select
value={value}
onChange={onChange}
className="w-full"
options={[
{
value: "csv",
label: "CSV (Excel, Google Sheets)",
},
{
value: "json",
label: "JSON (includes metadata)",
},
]}
/>
<Label className="text-sm font-medium block mb-2">Format</Label>
<Select value={value} onValueChange={(v) => onChange(v as ExportFormat)}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="csv">CSV (Excel, Google Sheets)</SelectItem>
<SelectItem value="json">JSON (includes metadata)</SelectItem>
</SelectContent>
</Select>
</div>
);
};
export default ExportFormatSelector;

View file

@ -1,4 +1,5 @@
import React from "react";
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
import type { DateRangePickerValue } from "@tremor/react";
interface ExportSummaryProps {
@ -6,14 +7,18 @@ interface ExportSummaryProps {
selectedFilters: string[];
}
const ExportSummary: React.FC<ExportSummaryProps> = ({ dateRange, selectedFilters }) => {
const ExportSummary: React.FC<ExportSummaryProps> = ({
dateRange,
selectedFilters,
}) => {
return (
<div className="text-sm text-gray-500">
{dateRange.from?.toLocaleDateString()} - {dateRange.to?.toLocaleDateString()}
{selectedFilters.length > 0 && ` · ${selectedFilters.length} filter${selectedFilters.length > 1 ? "s" : ""}`}
<div className="text-sm text-muted-foreground">
{dateRange.from?.toLocaleDateString()} -{" "}
{dateRange.to?.toLocaleDateString()}
{selectedFilters.length > 0 &&
` · ${selectedFilters.length} filter${selectedFilters.length > 1 ? "s" : ""}`}
</div>
);
};
export default ExportSummary;

View file

@ -1,5 +1,6 @@
import React from "react";
import { Radio } from "antd";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import type { ExportScope, EntityType } from "./types";
interface ExportTypeSelectorProps {
@ -8,37 +9,58 @@ interface ExportTypeSelectorProps {
entityType: EntityType;
}
const ExportTypeSelector: React.FC<ExportTypeSelectorProps> = ({ value, onChange, entityType }) => {
const items: Array<{
value: ExportScope;
title: (t: EntityType) => string;
description: (t: EntityType) => string;
}> = [
{
value: "daily",
title: (t) => `Day-by-day breakdown by ${t}`,
description: (t) => `Daily metrics for each ${t}`,
},
{
value: "daily_with_keys",
title: (t) => `Day-by-day breakdown by ${t} and key`,
description: (t) => `Daily metrics for each ${t}, split by API key`,
},
{
value: "daily_with_models",
title: (t) => `Day-by-day by ${t} and model`,
description: () => `Daily metrics split by model`,
},
];
const ExportTypeSelector: React.FC<ExportTypeSelectorProps> = ({
value,
onChange,
entityType,
}) => {
return (
<div>
<label className="text-sm font-medium text-gray-700 block mb-2">Export type</label>
<Radio.Group value={value} onChange={(e) => onChange(e.target.value)} className="w-full">
<div className="space-y-2">
<label className="flex items-start p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors">
<Radio value="daily" className="mt-0.5" />
<Label className="text-sm font-medium block mb-2">Export type</Label>
<RadioGroup
value={value}
onValueChange={(v) => onChange(v as ExportScope)}
className="w-full space-y-2"
>
{items.map((item) => (
<label
key={item.value}
className="flex items-start p-3 border border-border rounded-lg hover:bg-muted cursor-pointer transition-colors"
>
<RadioGroupItem value={item.value} className="mt-0.5" />
<div className="ml-3 flex-1">
<div className="font-medium text-sm">Day-by-day breakdown by {entityType}</div>
<div className="text-xs text-gray-500 mt-0.5">Daily metrics for each {entityType}</div>
<div className="font-medium text-sm">
{item.title(entityType)}
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{item.description(entityType)}
</div>
</div>
</label>
<label className="flex items-start p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors">
<Radio value="daily_with_keys" className="mt-0.5" />
<div className="ml-3 flex-1">
<div className="font-medium text-sm">Day-by-day breakdown by {entityType} and key</div>
<div className="text-xs text-gray-500 mt-0.5">Daily metrics for each {entityType}, split by API key</div>
</div>
</label>
<label className="flex items-start p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors">
<Radio value="daily_with_models" className="mt-0.5" />
<div className="ml-3 flex-1">
<div className="font-medium text-sm">Day-by-day by {entityType} and model</div>
<div className="text-xs text-gray-500 mt-0.5">Daily metrics split by model</div>
</div>
</label>
</div>
</Radio.Group>
))}
</RadioGroup>
</div>
);
};