Fixed Codex
This commit is contained in:
1 parent
f2025cc776
commit
adf57aa0c9
18 files changed
+1419
-1627
No files matched your search
@@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, Fragment } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Card from "@/shared/components/Card";
|
||||
import Badge from "@/shared/components/Badge";
|
||||
|
||||
const fmt = (n) => new Intl.NumberFormat().format(n || 0);
|
||||
const fmtCost = (n) => `$${(n || 0).toFixed(2)}`;
|
||||
|
||||
function fmtTime(iso) {
|
||||
if (!iso) return "Never";
|
||||
const diffMins = Math.floor((Date.now() - new Date(iso)) / 60000);
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
function SortIcon({ field, currentSort, currentOrder }) {
|
||||
if (currentSort !== field) return <span className="ml-1 opacity-20">↕</span>;
|
||||
return <span className="ml-1">{currentOrder === "asc" ? "↑" : "↓"}</span>;
|
||||
}
|
||||
|
||||
SortIcon.propTypes = {
|
||||
field: PropTypes.string.isRequired,
|
||||
currentSort: PropTypes.string.isRequired,
|
||||
currentOrder: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
/**
|
||||
* Render 3 token or cost cells based on viewMode
|
||||
*/
|
||||
function ValueCells({ item, viewMode, isSummary = false }) {
|
||||
if (viewMode === "tokens") {
|
||||
return (
|
||||
<>
|
||||
<td className="px-6 py-3 text-right text-text-muted">
|
||||
{isSummary && item.promptTokens === undefined ? "—" : fmt(item.promptTokens)}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right text-text-muted">
|
||||
{isSummary && item.completionTokens === undefined ? "—" : fmt(item.completionTokens)}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right font-medium">
|
||||
{fmt(item.totalTokens)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<td className="px-6 py-3 text-right text-text-muted">
|
||||
{isSummary && item.inputCost === undefined ? "—" : fmtCost(item.inputCost)}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right text-text-muted">
|
||||
{isSummary && item.outputCost === undefined ? "—" : fmtCost(item.outputCost)}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right font-medium text-warning">
|
||||
{fmtCost(item.totalCost || item.cost)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ValueCells.propTypes = {
|
||||
item: PropTypes.object.isRequired,
|
||||
viewMode: PropTypes.string.isRequired,
|
||||
isSummary: PropTypes.bool,
|
||||
};
|
||||
|
||||
/**
|
||||
* Reusable sortable usage table with expandable group rows.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.title - Table title
|
||||
* @param {Array} props.columns - Column definitions [{field, label}]
|
||||
* @param {Array} props.groupedData - Grouped data from groupDataByKey
|
||||
* @param {string} props.tableType - Table type key for sort URL params
|
||||
* @param {string} props.sortBy - Current sort field
|
||||
* @param {string} props.sortOrder - Current sort order
|
||||
* @param {function} props.onToggleSort - Sort toggle handler
|
||||
* @param {string} props.viewMode - "tokens" or "costs"
|
||||
* @param {string} props.storageKey - localStorage key for expanded state
|
||||
* @param {function} props.renderGroupLabel - Render group summary first cell content
|
||||
* @param {function} props.renderDetailCells - Render detail row custom cells (before value cells)
|
||||
* @param {function} props.renderSummaryCells - Render summary row cells after group label (placeholder cols)
|
||||
* @param {string} props.emptyMessage - Empty state message
|
||||
*/
|
||||
export default function UsageTable({
|
||||
title,
|
||||
columns,
|
||||
groupedData,
|
||||
tableType,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onToggleSort,
|
||||
viewMode,
|
||||
storageKey,
|
||||
renderDetailCells,
|
||||
renderSummaryCells,
|
||||
emptyMessage,
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(new Set());
|
||||
|
||||
// Load expanded state from localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved) setExpanded(new Set(JSON.parse(saved)));
|
||||
} catch (e) {
|
||||
console.error(`Failed to load ${storageKey}:`, e);
|
||||
}
|
||||
}, [storageKey]);
|
||||
|
||||
// Save expanded state to localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify([...expanded]));
|
||||
} catch (e) {
|
||||
console.error(`Failed to save ${storageKey}:`, e);
|
||||
}
|
||||
}, [expanded, storageKey]);
|
||||
|
||||
const toggleGroup = useCallback((groupKey) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(groupKey) ? next.delete(groupKey) : next.add(groupKey);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const valueColumns = useMemo(() => {
|
||||
if (viewMode === "tokens") {
|
||||
return [
|
||||
{ field: "promptTokens", label: "Input Tokens" },
|
||||
{ field: "completionTokens", label: "Output Tokens" },
|
||||
{ field: "totalTokens", label: "Total Tokens" },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ field: "promptTokens", label: "Input Cost" },
|
||||
{ field: "completionTokens", label: "Output Cost" },
|
||||
{ field: "cost", label: "Total Cost" },
|
||||
];
|
||||
}, [viewMode]);
|
||||
|
||||
const totalColSpan = columns.length + valueColumns.length;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="p-4 border-b border-border bg-bg-subtle/50">
|
||||
<h3 className="font-semibold">{title}</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-bg-subtle/30 text-text-muted uppercase text-xs">
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.field}
|
||||
className={`px-6 py-3 cursor-pointer hover:bg-bg-subtle/50 ${col.align === "right" ? "text-right" : ""}`}
|
||||
onClick={() => onToggleSort(tableType, col.field)}
|
||||
>
|
||||
{col.label}{" "}
|
||||
<SortIcon field={col.field} currentSort={sortBy} currentOrder={sortOrder} />
|
||||
</th>
|
||||
))}
|
||||
{valueColumns.map((col) => (
|
||||
<th
|
||||
key={col.field}
|
||||
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||
onClick={() => onToggleSort(tableType, col.field)}
|
||||
>
|
||||
{col.label}{" "}
|
||||
<SortIcon field={col.field} currentSort={sortBy} currentOrder={sortOrder} />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{groupedData.map((group) => (
|
||||
<Fragment key={group.groupKey}>
|
||||
{/* Group summary row */}
|
||||
<tr
|
||||
className="group-summary cursor-pointer hover:bg-bg-subtle/50 transition-colors"
|
||||
onClick={() => toggleGroup(group.groupKey)}
|
||||
>
|
||||
<td className="px-6 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`material-symbols-outlined text-[18px] text-text-muted transition-transform ${expanded.has(group.groupKey) ? "rotate-90" : ""}`}>
|
||||
chevron_right
|
||||
</span>
|
||||
<span className={`font-medium transition-colors ${group.summary.pending > 0 ? "text-primary" : ""}`}>
|
||||
{group.groupKey}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
{renderSummaryCells(group)}
|
||||
<ValueCells item={group.summary} viewMode={viewMode} isSummary />
|
||||
</tr>
|
||||
{/* Detail rows */}
|
||||
{expanded.has(group.groupKey) && group.items.map((item) => (
|
||||
<tr
|
||||
key={`detail-${item.key}`}
|
||||
className="group-detail hover:bg-bg-subtle/20 transition-colors"
|
||||
>
|
||||
{renderDetailCells(item)}
|
||||
<ValueCells item={item} viewMode={viewMode} />
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
{groupedData.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={totalColSpan} className="px-6 py-8 text-center text-text-muted">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
UsageTable.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
columns: PropTypes.arrayOf(PropTypes.shape({
|
||||
field: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
align: PropTypes.string,
|
||||
})).isRequired,
|
||||
groupedData: PropTypes.array.isRequired,
|
||||
tableType: PropTypes.string.isRequired,
|
||||
sortBy: PropTypes.string.isRequired,
|
||||
sortOrder: PropTypes.string.isRequired,
|
||||
onToggleSort: PropTypes.func.isRequired,
|
||||
viewMode: PropTypes.string.isRequired,
|
||||
storageKey: PropTypes.string.isRequired,
|
||||
renderDetailCells: PropTypes.func.isRequired,
|
||||
renderSummaryCells: PropTypes.func.isRequired,
|
||||
emptyMessage: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
// Re-export utilities for use in UsageStats orchestrator
|
||||
export { fmt, fmtCost, fmtTime };
|
||||
Reference in new issue
Block a user