feat(costs): consolidate /usage into /costs with Spend + Providers tabs
merge Usage page into Costs as two tabs ('Spend' and 'Providers'),
extract shared date-range logic to useDateRange() hook, delete /usage
route and sidebar entry, fix quota-windows bugs from prior review
This commit is contained in:
@@ -47,12 +47,23 @@ interface AnthropicUsageResponse {
|
|||||||
|
|
||||||
function toPercent(utilization: number | null | undefined): number | null {
|
function toPercent(utilization: number | null | undefined): number | null {
|
||||||
if (utilization == null) return null;
|
if (utilization == null) return null;
|
||||||
// utilization is 0-1 fraction
|
// utilization is 0-1 fraction; clamp to 100 in case of floating-point overshoot
|
||||||
return Math.round(utilization * 100);
|
return Math.min(100, Math.round(utilization * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch with an abort-based timeout so a hanging provider api doesn't block the response indefinitely
|
||||||
|
async function fetchWithTimeout(url: string, init: RequestInit, ms = 8000): Promise<Response> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), ms);
|
||||||
|
try {
|
||||||
|
return await fetch(url, { ...init, signal: controller.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchClaudeQuota(token: string): Promise<QuotaWindow[]> {
|
async function fetchClaudeQuota(token: string): Promise<QuotaWindow[]> {
|
||||||
const resp = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
const resp = await fetchWithTimeout("https://api.anthropic.com/api/oauth/usage", {
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bearer ${token}`,
|
"Authorization": `Bearer ${token}`,
|
||||||
"anthropic-beta": "oauth-2025-04-20",
|
"anthropic-beta": "oauth-2025-04-20",
|
||||||
@@ -167,7 +178,7 @@ async function fetchCodexQuota(token: string, accountId: string | null): Promise
|
|||||||
};
|
};
|
||||||
if (accountId) headers["ChatGPT-Account-Id"] = accountId;
|
if (accountId) headers["ChatGPT-Account-Id"] = accountId;
|
||||||
|
|
||||||
const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers });
|
const resp = await fetchWithTimeout("https://chatgpt.com/backend-api/wham/usage", { headers });
|
||||||
if (!resp.ok) throw new Error(`chatgpt wham api returned ${resp.status}`);
|
if (!resp.ok) throw new Error(`chatgpt wham api returned ${resp.status}`);
|
||||||
const body = (await resp.json()) as WhamUsageResponse;
|
const body = (await resp.json()) as WhamUsageResponse;
|
||||||
const windows: QuotaWindow[] = [];
|
const windows: QuotaWindow[] = [];
|
||||||
@@ -185,7 +196,7 @@ async function fetchCodexQuota(token: string, accountId: string | null): Promise
|
|||||||
if (rateLimit?.secondary_window != null) {
|
if (rateLimit?.secondary_window != null) {
|
||||||
const w = rateLimit.secondary_window;
|
const w = rateLimit.secondary_window;
|
||||||
windows.push({
|
windows.push({
|
||||||
label: "Weekly",
|
label: secondsToWindowLabel(w.limit_window_seconds),
|
||||||
usedPercent: w.used_percent ?? null,
|
usedPercent: w.used_percent ?? null,
|
||||||
resetsAt: w.reset_at ?? null,
|
resetsAt: w.reset_at ?? null,
|
||||||
valueLabel: null,
|
valueLabel: null,
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { GoalDetail } from "./pages/GoalDetail";
|
|||||||
import { Approvals } from "./pages/Approvals";
|
import { Approvals } from "./pages/Approvals";
|
||||||
import { ApprovalDetail } from "./pages/ApprovalDetail";
|
import { ApprovalDetail } from "./pages/ApprovalDetail";
|
||||||
import { Costs } from "./pages/Costs";
|
import { Costs } from "./pages/Costs";
|
||||||
import { Usage } from "./pages/Usage";
|
|
||||||
import { Activity } from "./pages/Activity";
|
import { Activity } from "./pages/Activity";
|
||||||
import { Inbox } from "./pages/Inbox";
|
import { Inbox } from "./pages/Inbox";
|
||||||
import { CompanySettings } from "./pages/CompanySettings";
|
import { CompanySettings } from "./pages/CompanySettings";
|
||||||
@@ -148,7 +147,6 @@ function boardRoutes() {
|
|||||||
<Route path="approvals/all" element={<Approvals />} />
|
<Route path="approvals/all" element={<Approvals />} />
|
||||||
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
|
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
|
||||||
<Route path="costs" element={<Costs />} />
|
<Route path="costs" element={<Costs />} />
|
||||||
<Route path="usage" element={<Usage />} />
|
|
||||||
<Route path="activity" element={<Activity />} />
|
<Route path="activity" element={<Activity />} />
|
||||||
<Route path="inbox" element={<InboxRootRedirect />} />
|
<Route path="inbox" element={<InboxRootRedirect />} />
|
||||||
<Route path="inbox/recent" element={<Inbox />} />
|
<Route path="inbox/recent" element={<Inbox />} />
|
||||||
|
|||||||
@@ -162,16 +162,17 @@ export function ProviderQuotaCard({
|
|||||||
Subscription quota
|
Subscription quota
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
{quotaWindows.map((qw) => {
|
{quotaWindows.map((qw, i) => {
|
||||||
const pct = qw.usedPercent ?? 0;
|
|
||||||
const fillColor =
|
const fillColor =
|
||||||
pct >= 90
|
qw.usedPercent == null
|
||||||
? "bg-red-400"
|
? null
|
||||||
: pct >= 70
|
: qw.usedPercent >= 90
|
||||||
? "bg-yellow-400"
|
? "bg-red-400"
|
||||||
: "bg-green-400";
|
: qw.usedPercent >= 70
|
||||||
|
? "bg-yellow-400"
|
||||||
|
: "bg-green-400";
|
||||||
return (
|
return (
|
||||||
<div key={qw.label} className="space-y-1">
|
<div key={`${qw.label}-${i}`} className="space-y-1">
|
||||||
<div className="flex items-center justify-between gap-2 text-xs">
|
<div className="flex items-center justify-between gap-2 text-xs">
|
||||||
<span className="font-mono text-muted-foreground shrink-0">{qw.label}</span>
|
<span className="font-mono text-muted-foreground shrink-0">{qw.label}</span>
|
||||||
<span className="flex-1" />
|
<span className="flex-1" />
|
||||||
@@ -181,11 +182,11 @@ export function ProviderQuotaCard({
|
|||||||
<span className="font-medium tabular-nums">{qw.usedPercent}% used</span>
|
<span className="font-medium tabular-nums">{qw.usedPercent}% used</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{qw.usedPercent != null && (
|
{qw.usedPercent != null && fillColor != null && (
|
||||||
<div className="h-1.5 w-full border border-border overflow-hidden">
|
<div className="h-1.5 w-full border border-border overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`h-full transition-[width] duration-150 ${fillColor}`}
|
className={`h-full transition-[width] duration-150 ${fillColor}`}
|
||||||
style={{ width: `${pct}%` }}
|
style={{ width: `${qw.usedPercent}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
Target,
|
Target,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Gauge,
|
|
||||||
History,
|
History,
|
||||||
Search,
|
Search,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
@@ -108,7 +107,6 @@ export function Sidebar() {
|
|||||||
<SidebarSection label="Company">
|
<SidebarSection label="Company">
|
||||||
<SidebarNavItem to="/org" label="Org" icon={Network} />
|
<SidebarNavItem to="/org" label="Org" icon={Network} />
|
||||||
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
||||||
<SidebarNavItem to="/usage" label="Usage" icon={Gauge} />
|
|
||||||
<SidebarNavItem to="/activity" label="Activity" icon={History} />
|
<SidebarNavItem to="/activity" label="Activity" icon={History} />
|
||||||
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
|
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
|
|||||||
@@ -415,7 +415,6 @@ function invalidateActivityQueries(
|
|||||||
queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.usageByProvider(companyId) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.usageByProvider(companyId) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.usageWindowSpend(companyId) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.usageWindowSpend(companyId) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.usageQuotaWindows(companyId) });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
96
ui/src/hooks/useDateRange.ts
Normal file
96
ui/src/hooks/useDateRange.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
export type DatePreset = "mtd" | "7d" | "30d" | "ytd" | "all" | "custom";
|
||||||
|
|
||||||
|
export const PRESET_LABELS: Record<DatePreset, string> = {
|
||||||
|
mtd: "Month to Date",
|
||||||
|
"7d": "Last 7 Days",
|
||||||
|
"30d": "Last 30 Days",
|
||||||
|
ytd: "Year to Date",
|
||||||
|
all: "All Time",
|
||||||
|
custom: "Custom",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PRESET_KEYS: DatePreset[] = ["mtd", "7d", "30d", "ytd", "all", "custom"];
|
||||||
|
|
||||||
|
function computeRange(preset: DatePreset): { from: string; to: string } {
|
||||||
|
const now = new Date();
|
||||||
|
const to = now.toISOString();
|
||||||
|
switch (preset) {
|
||||||
|
case "mtd": {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
return { from: d.toISOString(), to };
|
||||||
|
}
|
||||||
|
case "7d": {
|
||||||
|
const d = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
return { from: d.toISOString(), to };
|
||||||
|
}
|
||||||
|
case "30d": {
|
||||||
|
const d = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
return { from: d.toISOString(), to };
|
||||||
|
}
|
||||||
|
case "ytd": {
|
||||||
|
const d = new Date(now.getFullYear(), 0, 1);
|
||||||
|
return { from: d.toISOString(), to };
|
||||||
|
}
|
||||||
|
case "all":
|
||||||
|
case "custom":
|
||||||
|
return { from: "", to: "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseDateRangeResult {
|
||||||
|
preset: DatePreset;
|
||||||
|
setPreset: (p: DatePreset) => void;
|
||||||
|
customFrom: string;
|
||||||
|
setCustomFrom: (v: string) => void;
|
||||||
|
customTo: string;
|
||||||
|
setCustomTo: (v: string) => void;
|
||||||
|
/** resolved iso strings ready to pass to api calls; empty string means unbounded */
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
/** false when preset=custom but both dates are not yet selected */
|
||||||
|
customReady: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDateRange(): UseDateRangeResult {
|
||||||
|
const [preset, setPreset] = useState<DatePreset>("mtd");
|
||||||
|
const [customFrom, setCustomFrom] = useState("");
|
||||||
|
const [customTo, setCustomTo] = useState("");
|
||||||
|
|
||||||
|
const { from, to } = useMemo(() => {
|
||||||
|
if (preset === "custom") {
|
||||||
|
// treat custom date strings as local-date boundaries so the full day is included
|
||||||
|
// regardless of the user's timezone. "from" starts at local midnight, "to" at 23:59:59.999.
|
||||||
|
const fromDate = customFrom ? new Date(customFrom + "T00:00:00") : null;
|
||||||
|
const toDate = customTo ? new Date(customTo + "T23:59:59.999") : null;
|
||||||
|
return {
|
||||||
|
from: fromDate ? fromDate.toISOString() : "",
|
||||||
|
to: toDate ? toDate.toISOString() : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const range = computeRange(preset);
|
||||||
|
// floor `to` to the nearest minute so the query key is stable across 30s refetch ticks
|
||||||
|
// (prevents a new cache entry being created on every poll cycle)
|
||||||
|
if (range.to) {
|
||||||
|
const d = new Date(range.to);
|
||||||
|
d.setSeconds(0, 0);
|
||||||
|
range.to = d.toISOString();
|
||||||
|
}
|
||||||
|
return range;
|
||||||
|
}, [preset, customFrom, customTo]);
|
||||||
|
|
||||||
|
const customReady = preset !== "custom" || (!!customFrom && !!customTo);
|
||||||
|
|
||||||
|
return {
|
||||||
|
preset,
|
||||||
|
setPreset,
|
||||||
|
customFrom,
|
||||||
|
setCustomFrom,
|
||||||
|
customTo,
|
||||||
|
setCustomTo,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
customReady,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,79 +1,79 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { CostByProviderModel, CostWindowSpendRow, QuotaWindow } from "@paperclipai/shared";
|
||||||
import { costsApi } from "../api/costs";
|
import { costsApi } from "../api/costs";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
import { formatCents, formatTokens } from "../lib/utils";
|
import { ProviderQuotaCard } from "../components/ProviderQuotaCard";
|
||||||
|
import { PageTabBar } from "../components/PageTabBar";
|
||||||
|
import { formatCents, formatTokens, providerDisplayName } from "../lib/utils";
|
||||||
import { Identity } from "../components/Identity";
|
import { Identity } from "../components/Identity";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { DollarSign } from "lucide-react";
|
import { DollarSign } from "lucide-react";
|
||||||
|
import { useDateRange, PRESET_LABELS, PRESET_KEYS } from "../hooks/useDateRange";
|
||||||
|
|
||||||
type DatePreset = "mtd" | "7d" | "30d" | "ytd" | "all" | "custom";
|
// ---------- helpers ----------
|
||||||
|
|
||||||
const PRESET_LABELS: Record<DatePreset, string> = {
|
/** current week mon-sun boundaries as iso strings */
|
||||||
mtd: "Month to Date",
|
function currentWeekRange(): { from: string; to: string } {
|
||||||
"7d": "Last 7 Days",
|
|
||||||
"30d": "Last 30 Days",
|
|
||||||
ytd: "Year to Date",
|
|
||||||
all: "All Time",
|
|
||||||
custom: "Custom",
|
|
||||||
};
|
|
||||||
|
|
||||||
function computeRange(preset: DatePreset): { from: string; to: string } {
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const to = now.toISOString();
|
const day = now.getDay(); // 0 = Sun
|
||||||
switch (preset) {
|
const diffToMon = day === 0 ? -6 : 1 - day;
|
||||||
case "mtd": {
|
const mon = new Date(now.getFullYear(), now.getMonth(), now.getDate() + diffToMon, 0, 0, 0, 0);
|
||||||
const d = new Date(now.getFullYear(), now.getMonth(), 1);
|
const sun = new Date(mon.getTime() + 6 * 24 * 60 * 60 * 1000 + 23 * 3600 * 1000 + 3599 * 1000 + 999);
|
||||||
return { from: d.toISOString(), to };
|
return { from: mon.toISOString(), to: sun.toISOString() };
|
||||||
}
|
|
||||||
case "7d": {
|
|
||||||
const d = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
||||||
return { from: d.toISOString(), to };
|
|
||||||
}
|
|
||||||
case "30d": {
|
|
||||||
const d = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
||||||
return { from: d.toISOString(), to };
|
|
||||||
}
|
|
||||||
case "ytd": {
|
|
||||||
const d = new Date(now.getFullYear(), 0, 1);
|
|
||||||
return { from: d.toISOString(), to };
|
|
||||||
}
|
|
||||||
case "all":
|
|
||||||
return { from: "", to: "" };
|
|
||||||
case "custom":
|
|
||||||
return { from: "", to: "" };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProviderTabLabel({ provider, rows }: { provider: string; rows: CostByProviderModel[] }) {
|
||||||
|
const totalTokens = rows.reduce((s, r) => s + r.inputTokens + r.outputTokens, 0);
|
||||||
|
const totalCost = rows.reduce((s, r) => s + r.costCents, 0);
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span>{providerDisplayName(provider)}</span>
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">{formatTokens(totalTokens)}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{formatCents(totalCost)}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- page ----------
|
||||||
|
|
||||||
export function Costs() {
|
export function Costs() {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
|
||||||
const [preset, setPreset] = useState<DatePreset>("mtd");
|
const [mainTab, setMainTab] = useState<"spend" | "providers">("spend");
|
||||||
const [customFrom, setCustomFrom] = useState("");
|
const [activeProvider, setActiveProvider] = useState("all");
|
||||||
const [customTo, setCustomTo] = useState("");
|
|
||||||
|
const {
|
||||||
|
preset,
|
||||||
|
setPreset,
|
||||||
|
customFrom,
|
||||||
|
setCustomFrom,
|
||||||
|
customTo,
|
||||||
|
setCustomTo,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
customReady,
|
||||||
|
} = useDateRange();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBreadcrumbs([{ label: "Costs" }]);
|
setBreadcrumbs([{ label: "Costs" }]);
|
||||||
}, [setBreadcrumbs]);
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
const { from, to } = useMemo(() => {
|
// key to today's date string so the week range auto-refreshes after midnight on the next render
|
||||||
if (preset === "custom") {
|
const today = new Date().toDateString();
|
||||||
return {
|
const weekRange = useMemo(() => currentWeekRange(), [today]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
from: customFrom ? new Date(customFrom).toISOString() : "",
|
|
||||||
to: customTo ? new Date(customTo + "T23:59:59.999Z").toISOString() : "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return computeRange(preset);
|
|
||||||
}, [preset, customFrom, customTo]);
|
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
// ---------- spend tab queries (no polling — cost data doesn't change in real time) ----------
|
||||||
|
|
||||||
|
const { data: spendData, isLoading: spendLoading, error: spendError } = useQuery({
|
||||||
queryKey: queryKeys.costs(selectedCompanyId!, from || undefined, to || undefined),
|
queryKey: queryKeys.costs(selectedCompanyId!, from || undefined, to || undefined),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const [summary, byAgent, byProject] = await Promise.all([
|
const [summary, byAgent, byProject] = await Promise.all([
|
||||||
@@ -83,24 +83,152 @@ export function Costs() {
|
|||||||
]);
|
]);
|
||||||
return { summary, byAgent, byProject };
|
return { summary, byAgent, byProject };
|
||||||
},
|
},
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId && customReady,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------- providers tab queries (polling — provider quota changes during agent runs) ----------
|
||||||
|
|
||||||
|
const { data: providerData } = useQuery({
|
||||||
|
queryKey: queryKeys.usageByProvider(selectedCompanyId!, from || undefined, to || undefined),
|
||||||
|
queryFn: () => costsApi.byProvider(selectedCompanyId!, from || undefined, to || undefined),
|
||||||
|
enabled: !!selectedCompanyId && customReady,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
staleTime: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: weekData } = useQuery({
|
||||||
|
queryKey: queryKeys.usageByProvider(selectedCompanyId!, weekRange.from, weekRange.to),
|
||||||
|
queryFn: () => costsApi.byProvider(selectedCompanyId!, weekRange.from, weekRange.to),
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
staleTime: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: windowData } = useQuery({
|
||||||
|
queryKey: queryKeys.usageWindowSpend(selectedCompanyId!),
|
||||||
|
queryFn: () => costsApi.windowSpend(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
staleTime: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: quotaData } = useQuery({
|
||||||
|
queryKey: queryKeys.usageQuotaWindows(selectedCompanyId!),
|
||||||
|
queryFn: () => costsApi.quotaWindows(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
|
// quota windows come from external provider apis; refresh every 5 minutes
|
||||||
|
refetchInterval: 300_000,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- providers tab derived maps ----------
|
||||||
|
|
||||||
|
const byProvider = useMemo(() => {
|
||||||
|
const map = new Map<string, CostByProviderModel[]>();
|
||||||
|
for (const row of providerData ?? []) {
|
||||||
|
const arr = map.get(row.provider) ?? [];
|
||||||
|
arr.push(row);
|
||||||
|
map.set(row.provider, arr);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [providerData]);
|
||||||
|
|
||||||
|
const weekSpendByProvider = useMemo(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const row of weekData ?? []) {
|
||||||
|
map.set(row.provider, (map.get(row.provider) ?? 0) + row.costCents);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [weekData]);
|
||||||
|
|
||||||
|
const windowSpendByProvider = useMemo(() => {
|
||||||
|
const map = new Map<string, CostWindowSpendRow[]>();
|
||||||
|
for (const row of windowData ?? []) {
|
||||||
|
const arr = map.get(row.provider) ?? [];
|
||||||
|
arr.push(row);
|
||||||
|
map.set(row.provider, arr);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [windowData]);
|
||||||
|
|
||||||
|
const quotaWindowsByProvider = useMemo(() => {
|
||||||
|
const map = new Map<string, QuotaWindow[]>();
|
||||||
|
for (const result of quotaData ?? []) {
|
||||||
|
if (result.ok && result.windows.length > 0) {
|
||||||
|
map.set(result.provider, result.windows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [quotaData]);
|
||||||
|
|
||||||
|
// deficit notch: projected month-end spend vs pro-rata budget share (mtd only)
|
||||||
|
// memoized to avoid stale closure reads when summary and byProvider arrive separately
|
||||||
|
const deficitNotchByProvider = useMemo(() => {
|
||||||
|
const map = new Map<string, boolean>();
|
||||||
|
if (preset !== "mtd") return map;
|
||||||
|
const budget = spendData?.summary.budgetCents ?? 0;
|
||||||
|
if (budget <= 0) return map;
|
||||||
|
const totalSpend = spendData?.summary.spendCents ?? 0;
|
||||||
|
const now = new Date();
|
||||||
|
const daysElapsed = now.getDate();
|
||||||
|
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
|
||||||
|
for (const [providerKey, rows] of byProvider) {
|
||||||
|
const providerCostCents = rows.reduce((s, r) => s + r.costCents, 0);
|
||||||
|
const providerShare = totalSpend > 0 ? providerCostCents / totalSpend : 0;
|
||||||
|
const providerBudget = budget * providerShare;
|
||||||
|
if (providerBudget <= 0) { map.set(providerKey, false); continue; }
|
||||||
|
const burnRate = providerCostCents / Math.max(daysElapsed, 1);
|
||||||
|
map.set(providerKey, providerCostCents + burnRate * (daysInMonth - daysElapsed) > providerBudget);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [preset, spendData, byProvider]);
|
||||||
|
|
||||||
|
const providers = Array.from(byProvider.keys());
|
||||||
|
|
||||||
|
// ---------- guards ----------
|
||||||
|
|
||||||
if (!selectedCompanyId) {
|
if (!selectedCompanyId) {
|
||||||
return <EmptyState icon={DollarSign} message="Select a company to view costs." />;
|
return <EmptyState icon={DollarSign} message="Select a company to view costs." />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (spendLoading) {
|
||||||
return <PageSkeleton variant="costs" />;
|
return <PageSkeleton variant="costs" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const presetKeys: DatePreset[] = ["mtd", "7d", "30d", "ytd", "all", "custom"];
|
// ---------- provider tab items ----------
|
||||||
|
|
||||||
|
const providerTabItems = [
|
||||||
|
{
|
||||||
|
value: "all",
|
||||||
|
label: (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span>All providers</span>
|
||||||
|
{providerData && providerData.length > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
|
{formatTokens(providerData.reduce((s, r) => s + r.inputTokens + r.outputTokens, 0))}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatCents(providerData.reduce((s, r) => s + r.costCents, 0))}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...providers.map((p) => ({
|
||||||
|
value: p,
|
||||||
|
label: <ProviderTabLabel provider={p} rows={byProvider.get(p)!} />,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- render ----------
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Date range selector */}
|
{/* date range selector */}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{presetKeys.map((p) => (
|
{PRESET_KEYS.map((p) => (
|
||||||
<Button
|
<Button
|
||||||
key={p}
|
key={p}
|
||||||
variant={preset === p ? "secondary" : "ghost"}
|
variant={preset === p ? "secondary" : "ghost"}
|
||||||
@@ -129,116 +257,180 @@ export function Costs() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
{/* main spend / providers tab switcher */}
|
||||||
|
<Tabs value={mainTab} onValueChange={(v) => setMainTab(v as "spend" | "providers")}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="spend">Spend</TabsTrigger>
|
||||||
|
<TabsTrigger value="providers">Providers</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
{data && (
|
{/* ── spend tab ─────────────────────────────────────────────── */}
|
||||||
<>
|
<TabsContent value="spend" className="mt-4 space-y-4">
|
||||||
{/* Summary card */}
|
{spendError && (
|
||||||
<Card>
|
<p className="text-sm text-destructive">{(spendError as Error).message}</p>
|
||||||
<CardContent className="p-4 space-y-3">
|
)}
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm text-muted-foreground">{PRESET_LABELS[preset]}</p>
|
{preset === "custom" && !customReady ? (
|
||||||
{data.summary.budgetCents > 0 && (
|
<p className="text-sm text-muted-foreground">Select a start and end date to load data.</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
) : spendData ? (
|
||||||
{data.summary.utilizationPercent}% utilized
|
<>
|
||||||
|
{/* summary card */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">{PRESET_LABELS[preset]}</p>
|
||||||
|
{spendData.summary.budgetCents > 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{spendData.summary.utilizationPercent}% utilized
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{formatCents(spendData.summary.spendCents)}{" "}
|
||||||
|
<span className="text-base font-normal text-muted-foreground">
|
||||||
|
{spendData.summary.budgetCents > 0
|
||||||
|
? `/ ${formatCents(spendData.summary.budgetCents)}`
|
||||||
|
: "Unlimited budget"}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
{spendData.summary.budgetCents > 0 && (
|
||||||
</div>
|
<div className="w-full h-2 border border-border overflow-hidden">
|
||||||
<p className="text-2xl font-bold tabular-nums">
|
|
||||||
{formatCents(data.summary.spendCents)}{" "}
|
|
||||||
<span className="text-base font-normal text-muted-foreground">
|
|
||||||
{data.summary.budgetCents > 0
|
|
||||||
? `/ ${formatCents(data.summary.budgetCents)}`
|
|
||||||
: "Unlimited budget"}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
{data.summary.budgetCents > 0 && (
|
|
||||||
<div className="w-full h-2 border border-border overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full transition-[width,background-color] duration-150 ${
|
|
||||||
data.summary.utilizationPercent > 90
|
|
||||||
? "bg-red-400"
|
|
||||||
: data.summary.utilizationPercent > 70
|
|
||||||
? "bg-yellow-400"
|
|
||||||
: "bg-green-400"
|
|
||||||
}`}
|
|
||||||
style={{ width: `${Math.min(100, data.summary.utilizationPercent)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* By Agent / By Project */}
|
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<h3 className="text-sm font-semibold mb-3">By Agent</h3>
|
|
||||||
{data.byAgent.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">No cost events yet.</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{data.byAgent.map((row) => (
|
|
||||||
<div
|
<div
|
||||||
key={row.agentId}
|
className={`h-full transition-[width,background-color] duration-150 ${
|
||||||
className="flex items-start justify-between text-sm"
|
spendData.summary.utilizationPercent > 90
|
||||||
>
|
? "bg-red-400"
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
: spendData.summary.utilizationPercent > 70
|
||||||
<Identity
|
? "bg-yellow-400"
|
||||||
name={row.agentName ?? row.agentId}
|
: "bg-green-400"
|
||||||
size="sm"
|
}`}
|
||||||
/>
|
style={{ width: `${Math.min(100, spendData.summary.utilizationPercent)}%` }}
|
||||||
{row.agentStatus === "terminated" && (
|
/>
|
||||||
<StatusBadge status="terminated" />
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
<div className="text-right shrink-0 ml-2 tabular-nums">
|
</Card>
|
||||||
<span className="font-medium block">{formatCents(row.costCents)}</span>
|
|
||||||
<span className="text-xs text-muted-foreground block">
|
{/* by agent / by project */}
|
||||||
in {formatTokens(row.inputTokens)} / out {formatTokens(row.outputTokens)} tok
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
</span>
|
<Card>
|
||||||
{(row.apiRunCount > 0 || row.subscriptionRunCount > 0) && (
|
<CardContent className="p-4">
|
||||||
<span className="text-xs text-muted-foreground block">
|
<h3 className="text-sm font-semibold mb-3">By Agent</h3>
|
||||||
{row.apiRunCount > 0 ? `api runs: ${row.apiRunCount}` : null}
|
{spendData.byAgent.length === 0 ? (
|
||||||
{row.apiRunCount > 0 && row.subscriptionRunCount > 0 ? " | " : null}
|
<p className="text-sm text-muted-foreground">No cost events yet.</p>
|
||||||
{row.subscriptionRunCount > 0
|
) : (
|
||||||
? `subscription runs: ${row.subscriptionRunCount} (${formatTokens(row.subscriptionInputTokens)} in / ${formatTokens(row.subscriptionOutputTokens)} out tok)`
|
<div className="space-y-2">
|
||||||
: null}
|
{spendData.byAgent.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.agentId}
|
||||||
|
className="flex items-start justify-between text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Identity name={row.agentName ?? row.agentId} size="sm" />
|
||||||
|
{row.agentStatus === "terminated" && (
|
||||||
|
<StatusBadge status="terminated" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0 ml-2">
|
||||||
|
<span className="font-medium block">{formatCents(row.costCents)}</span>
|
||||||
|
<span className="text-xs text-muted-foreground block">
|
||||||
|
in {formatTokens(row.inputTokens)} / out {formatTokens(row.outputTokens)} tok
|
||||||
|
</span>
|
||||||
|
{(row.apiRunCount > 0 || row.subscriptionRunCount > 0) && (
|
||||||
|
<span className="text-xs text-muted-foreground block">
|
||||||
|
{row.apiRunCount > 0 ? `api runs: ${row.apiRunCount}` : null}
|
||||||
|
{row.apiRunCount > 0 && row.subscriptionRunCount > 0 ? " | " : null}
|
||||||
|
{row.subscriptionRunCount > 0
|
||||||
|
? `subscription runs: ${row.subscriptionRunCount} (${formatTokens(row.subscriptionInputTokens)} in / ${formatTokens(row.subscriptionOutputTokens)} out tok)`
|
||||||
|
: null}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<h3 className="text-sm font-semibold mb-3">By Project</h3>
|
||||||
|
{spendData.byProject.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No project-attributed run costs yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{spendData.byProject.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.projectId ?? "na"}
|
||||||
|
className="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{row.projectName ?? row.projectId ?? "Unattributed"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
<span className="font-medium">{formatCents(row.costCents)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
)}
|
</Card>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</>
|
||||||
|
) : null}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<Card>
|
{/* ── providers tab ─────────────────────────────────────────── */}
|
||||||
<CardContent className="p-4">
|
<TabsContent value="providers" className="mt-4">
|
||||||
<h3 className="text-sm font-semibold mb-3">By Project</h3>
|
{preset === "custom" && !customReady ? (
|
||||||
{data.byProject.length === 0 ? (
|
<p className="text-sm text-muted-foreground">Select a start and end date to load data.</p>
|
||||||
<p className="text-sm text-muted-foreground">No project-attributed run costs yet.</p>
|
) : (
|
||||||
|
<Tabs value={activeProvider} onValueChange={setActiveProvider}>
|
||||||
|
<PageTabBar
|
||||||
|
items={providerTabItems}
|
||||||
|
value={activeProvider}
|
||||||
|
onValueChange={setActiveProvider}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TabsContent value="all" className="mt-4">
|
||||||
|
{providers.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No cost events in this period.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
{data.byProject.map((row) => (
|
{providers.map((p) => (
|
||||||
<div
|
<ProviderQuotaCard
|
||||||
key={row.projectId ?? "na"}
|
key={p}
|
||||||
className="flex items-center justify-between text-sm"
|
provider={p}
|
||||||
>
|
rows={byProvider.get(p)!}
|
||||||
<span className="truncate">
|
budgetMonthlyCents={spendData?.summary.budgetCents ?? 0}
|
||||||
{row.projectName ?? row.projectId ?? "Unattributed"}
|
totalCompanySpendCents={spendData?.summary.spendCents ?? 0}
|
||||||
</span>
|
weekSpendCents={weekSpendByProvider.get(p) ?? 0}
|
||||||
<span className="font-medium tabular-nums">{formatCents(row.costCents)}</span>
|
windowRows={windowSpendByProvider.get(p) ?? []}
|
||||||
</div>
|
showDeficitNotch={deficitNotchByProvider.get(p) ?? false}
|
||||||
|
quotaWindows={quotaWindowsByProvider.get(p) ?? []}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</TabsContent>
|
||||||
</Card>
|
|
||||||
</div>
|
{providers.map((p) => (
|
||||||
</>
|
<TabsContent key={p} value={p} className="mt-4">
|
||||||
)}
|
<ProviderQuotaCard
|
||||||
|
provider={p}
|
||||||
|
rows={byProvider.get(p)!}
|
||||||
|
budgetMonthlyCents={spendData?.summary.budgetCents ?? 0}
|
||||||
|
totalCompanySpendCents={spendData?.summary.spendCents ?? 0}
|
||||||
|
weekSpendCents={weekSpendByProvider.get(p) ?? 0}
|
||||||
|
windowRows={windowSpendByProvider.get(p) ?? []}
|
||||||
|
showDeficitNotch={deficitNotchByProvider.get(p) ?? false}
|
||||||
|
quotaWindows={quotaWindowsByProvider.get(p) ?? []}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,340 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import type { CostByProviderModel, CostWindowSpendRow, QuotaWindow } from "@paperclipai/shared";
|
|
||||||
import { costsApi } from "../api/costs";
|
|
||||||
import { useCompany } from "../context/CompanyContext";
|
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
|
||||||
import { EmptyState } from "../components/EmptyState";
|
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
|
||||||
import { ProviderQuotaCard } from "../components/ProviderQuotaCard";
|
|
||||||
import { PageTabBar } from "../components/PageTabBar";
|
|
||||||
import { formatCents, formatTokens, providerDisplayName } from "../lib/utils";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
|
||||||
import { Gauge } from "lucide-react";
|
|
||||||
|
|
||||||
type DatePreset = "mtd" | "7d" | "30d" | "ytd" | "all" | "custom";
|
|
||||||
|
|
||||||
const PRESET_LABELS: Record<DatePreset, string> = {
|
|
||||||
mtd: "Month to Date",
|
|
||||||
"7d": "Last 7 Days",
|
|
||||||
"30d": "Last 30 Days",
|
|
||||||
ytd: "Year to Date",
|
|
||||||
all: "All Time",
|
|
||||||
custom: "Custom",
|
|
||||||
};
|
|
||||||
|
|
||||||
function computeRange(preset: DatePreset): { from: string; to: string } {
|
|
||||||
const now = new Date();
|
|
||||||
const to = now.toISOString();
|
|
||||||
switch (preset) {
|
|
||||||
case "mtd": {
|
|
||||||
const d = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
||||||
return { from: d.toISOString(), to };
|
|
||||||
}
|
|
||||||
case "7d": {
|
|
||||||
const d = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
||||||
return { from: d.toISOString(), to };
|
|
||||||
}
|
|
||||||
case "30d": {
|
|
||||||
const d = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
||||||
return { from: d.toISOString(), to };
|
|
||||||
}
|
|
||||||
case "ytd": {
|
|
||||||
const d = new Date(now.getFullYear(), 0, 1);
|
|
||||||
return { from: d.toISOString(), to };
|
|
||||||
}
|
|
||||||
case "all":
|
|
||||||
return { from: "", to: "" };
|
|
||||||
case "custom":
|
|
||||||
return { from: "", to: "" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** current week mon-sun boundaries as iso strings */
|
|
||||||
function currentWeekRange(): { from: string; to: string } {
|
|
||||||
const now = new Date();
|
|
||||||
const day = now.getDay(); // 0 = Sun, 1 = Mon, …
|
|
||||||
const diffToMon = (day === 0 ? -6 : 1 - day);
|
|
||||||
const mon = new Date(now.getFullYear(), now.getMonth(), now.getDate() + diffToMon, 0, 0, 0, 0);
|
|
||||||
const sun = new Date(mon.getTime() + 6 * 24 * 60 * 60 * 1000 + 23 * 3600 * 1000 + 3599 * 1000 + 999);
|
|
||||||
return { from: mon.toISOString(), to: sun.toISOString() };
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProviderTabLabel({ provider, rows }: { provider: string; rows: CostByProviderModel[] }) {
|
|
||||||
const totalTokens = rows.reduce((s, r) => s + r.inputTokens + r.outputTokens, 0);
|
|
||||||
const totalCost = rows.reduce((s, r) => s + r.costCents, 0);
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<span>{providerDisplayName(provider)}</span>
|
|
||||||
<span className="text-xs text-muted-foreground font-mono">{formatTokens(totalTokens)}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">{formatCents(totalCost)}</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Usage() {
|
|
||||||
const { selectedCompanyId } = useCompany();
|
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
|
||||||
|
|
||||||
const [preset, setPreset] = useState<DatePreset>("mtd");
|
|
||||||
const [customFrom, setCustomFrom] = useState("");
|
|
||||||
const [customTo, setCustomTo] = useState("");
|
|
||||||
const [activeProvider, setActiveProvider] = useState("all");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setBreadcrumbs([{ label: "Usage" }]);
|
|
||||||
}, [setBreadcrumbs]);
|
|
||||||
|
|
||||||
const { from, to } = useMemo(() => {
|
|
||||||
if (preset === "custom") {
|
|
||||||
// treat custom date strings as local-date boundaries so the full day is included
|
|
||||||
// regardless of the user's timezone. "from" starts at local midnight (00:00:00),
|
|
||||||
// "to" ends at local 23:59:59.999 (converted to utc via Date constructor).
|
|
||||||
const fromDate = customFrom ? new Date(customFrom + "T00:00:00") : null;
|
|
||||||
const toDate = customTo ? new Date(customTo + "T23:59:59.999") : null;
|
|
||||||
return {
|
|
||||||
from: fromDate ? fromDate.toISOString() : "",
|
|
||||||
to: toDate ? toDate.toISOString() : "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const range = computeRange(preset);
|
|
||||||
// floor `to` to the nearest minute so the query key is stable across 30s refetch ticks
|
|
||||||
// (prevents a new cache entry being created on every poll cycle)
|
|
||||||
if (range.to) {
|
|
||||||
const d = new Date(range.to);
|
|
||||||
d.setSeconds(0, 0);
|
|
||||||
range.to = d.toISOString();
|
|
||||||
}
|
|
||||||
return range;
|
|
||||||
}, [preset, customFrom, customTo]);
|
|
||||||
|
|
||||||
// key to today's date string so the range auto-refreshes after midnight on the next 30s refetch
|
|
||||||
const today = new Date().toDateString();
|
|
||||||
const weekRange = useMemo(() => currentWeekRange(), [today]);
|
|
||||||
|
|
||||||
// for custom preset, only fetch once both dates are selected
|
|
||||||
const customReady = preset !== "custom" || (!!customFrom && !!customTo);
|
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
|
||||||
queryKey: queryKeys.usageByProvider(selectedCompanyId!, from || undefined, to || undefined),
|
|
||||||
queryFn: () => costsApi.byProvider(selectedCompanyId!, from || undefined, to || undefined),
|
|
||||||
enabled: !!selectedCompanyId && customReady,
|
|
||||||
refetchInterval: 30_000,
|
|
||||||
staleTime: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: summary } = useQuery({
|
|
||||||
queryKey: queryKeys.costs(selectedCompanyId!, from || undefined, to || undefined),
|
|
||||||
queryFn: () =>
|
|
||||||
costsApi.summary(selectedCompanyId!, from || undefined, to || undefined),
|
|
||||||
enabled: !!selectedCompanyId && customReady,
|
|
||||||
refetchInterval: 30_000,
|
|
||||||
staleTime: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: weekData } = useQuery({
|
|
||||||
queryKey: queryKeys.usageByProvider(selectedCompanyId!, weekRange.from, weekRange.to),
|
|
||||||
queryFn: () => costsApi.byProvider(selectedCompanyId!, weekRange.from, weekRange.to),
|
|
||||||
enabled: !!selectedCompanyId,
|
|
||||||
refetchInterval: 30_000,
|
|
||||||
staleTime: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: windowData } = useQuery({
|
|
||||||
queryKey: queryKeys.usageWindowSpend(selectedCompanyId!),
|
|
||||||
queryFn: () => costsApi.windowSpend(selectedCompanyId!),
|
|
||||||
enabled: !!selectedCompanyId,
|
|
||||||
refetchInterval: 30_000,
|
|
||||||
staleTime: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: quotaData } = useQuery({
|
|
||||||
queryKey: queryKeys.usageQuotaWindows(selectedCompanyId!),
|
|
||||||
queryFn: () => costsApi.quotaWindows(selectedCompanyId!),
|
|
||||||
enabled: !!selectedCompanyId,
|
|
||||||
// quota windows change infrequently; refresh every 5 minutes
|
|
||||||
refetchInterval: 300_000,
|
|
||||||
staleTime: 60_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// rows grouped by provider
|
|
||||||
const byProvider = useMemo(() => {
|
|
||||||
const map = new Map<string, CostByProviderModel[]>();
|
|
||||||
for (const row of data ?? []) {
|
|
||||||
const arr = map.get(row.provider) ?? [];
|
|
||||||
arr.push(row);
|
|
||||||
map.set(row.provider, arr);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
// week spend per provider
|
|
||||||
const weekSpendByProvider = useMemo(() => {
|
|
||||||
const map = new Map<string, number>();
|
|
||||||
for (const row of weekData ?? []) {
|
|
||||||
map.set(row.provider, (map.get(row.provider) ?? 0) + row.costCents);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [weekData]);
|
|
||||||
|
|
||||||
// window spend rows per provider, keyed by provider with the 3-window array
|
|
||||||
const windowSpendByProvider = useMemo(() => {
|
|
||||||
const map = new Map<string, CostWindowSpendRow[]>();
|
|
||||||
for (const row of windowData ?? []) {
|
|
||||||
const arr = map.get(row.provider) ?? [];
|
|
||||||
arr.push(row);
|
|
||||||
map.set(row.provider, arr);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [windowData]);
|
|
||||||
|
|
||||||
// quota windows from the provider's own api, keyed by provider
|
|
||||||
const quotaWindowsByProvider = useMemo(() => {
|
|
||||||
const map = new Map<string, QuotaWindow[]>();
|
|
||||||
for (const result of quotaData ?? []) {
|
|
||||||
if (result.ok && result.windows.length > 0) {
|
|
||||||
map.set(result.provider, result.windows);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [quotaData]);
|
|
||||||
|
|
||||||
// compute deficit notch per provider: only meaningful for mtd — projects spend to month end
|
|
||||||
// and flags when that projection exceeds the provider's pro-rata budget share.
|
|
||||||
function providerDeficitNotch(providerKey: string): boolean {
|
|
||||||
if (preset !== "mtd") return false;
|
|
||||||
const budget = summary?.budgetCents ?? 0;
|
|
||||||
if (budget <= 0) return false;
|
|
||||||
const totalSpend = summary?.spendCents ?? 0;
|
|
||||||
const providerCostCents = (byProvider.get(providerKey) ?? []).reduce((s, r) => s + r.costCents, 0);
|
|
||||||
const providerShare = totalSpend > 0 ? providerCostCents / totalSpend : 0;
|
|
||||||
const providerBudget = budget * providerShare;
|
|
||||||
if (providerBudget <= 0) return false;
|
|
||||||
const now = new Date();
|
|
||||||
const daysElapsed = now.getDate();
|
|
||||||
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
|
|
||||||
const burnRate = providerCostCents / Math.max(daysElapsed, 1);
|
|
||||||
return providerCostCents + burnRate * (daysInMonth - daysElapsed) > providerBudget;
|
|
||||||
}
|
|
||||||
|
|
||||||
const providers = Array.from(byProvider.keys());
|
|
||||||
|
|
||||||
if (!selectedCompanyId) {
|
|
||||||
return <EmptyState icon={Gauge} message="Select a company to view usage." />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <PageSkeleton variant="costs" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const presetKeys: DatePreset[] = ["mtd", "7d", "30d", "ytd", "all", "custom"];
|
|
||||||
|
|
||||||
const tabItems = [
|
|
||||||
{
|
|
||||||
value: "all",
|
|
||||||
label: (
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<span>All providers</span>
|
|
||||||
{data && data.length > 0 && (
|
|
||||||
<>
|
|
||||||
<span className="text-xs text-muted-foreground font-mono">
|
|
||||||
{formatTokens(data.reduce((s, r) => s + r.inputTokens + r.outputTokens, 0))}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formatCents(data.reduce((s, r) => s + r.costCents, 0))}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
...providers.map((p) => ({
|
|
||||||
value: p,
|
|
||||||
label: <ProviderTabLabel provider={p} rows={byProvider.get(p)!} />,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* date range selector */}
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{presetKeys.map((p) => (
|
|
||||||
<Button
|
|
||||||
key={p}
|
|
||||||
variant={preset === p ? "secondary" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPreset(p)}
|
|
||||||
>
|
|
||||||
{PRESET_LABELS[p]}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
{preset === "custom" && (
|
|
||||||
<div className="flex items-center gap-2 ml-2">
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={customFrom}
|
|
||||||
onChange={(e) => setCustomFrom(e.target.value)}
|
|
||||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm text-foreground"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-muted-foreground">to</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={customTo}
|
|
||||||
onChange={(e) => setCustomTo(e.target.value)}
|
|
||||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm text-foreground"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{(error as Error).message}</p>}
|
|
||||||
|
|
||||||
{preset === "custom" && !customReady ? (
|
|
||||||
<p className="text-sm text-muted-foreground">Select a start and end date to load data.</p>
|
|
||||||
) : (
|
|
||||||
<Tabs value={activeProvider} onValueChange={setActiveProvider}>
|
|
||||||
<PageTabBar items={tabItems} value={activeProvider} onValueChange={setActiveProvider} />
|
|
||||||
|
|
||||||
<TabsContent value="all" className="mt-4">
|
|
||||||
{providers.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">No cost events in this period.</p>
|
|
||||||
) : (
|
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
|
||||||
{providers.map((p) => (
|
|
||||||
<ProviderQuotaCard
|
|
||||||
key={p}
|
|
||||||
provider={p}
|
|
||||||
rows={byProvider.get(p)!}
|
|
||||||
budgetMonthlyCents={summary?.budgetCents ?? 0}
|
|
||||||
totalCompanySpendCents={summary?.spendCents ?? 0}
|
|
||||||
weekSpendCents={weekSpendByProvider.get(p) ?? 0}
|
|
||||||
windowRows={windowSpendByProvider.get(p) ?? []}
|
|
||||||
showDeficitNotch={providerDeficitNotch(p)}
|
|
||||||
quotaWindows={quotaWindowsByProvider.get(p) ?? []}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{providers.map((p) => (
|
|
||||||
<TabsContent key={p} value={p} className="mt-4">
|
|
||||||
<ProviderQuotaCard
|
|
||||||
provider={p}
|
|
||||||
rows={byProvider.get(p)!}
|
|
||||||
budgetMonthlyCents={summary?.budgetCents ?? 0}
|
|
||||||
totalCompanySpendCents={summary?.spendCents ?? 0}
|
|
||||||
weekSpendCents={weekSpendByProvider.get(p) ?? 0}
|
|
||||||
windowRows={windowSpendByProvider.get(p) ?? []}
|
|
||||||
showDeficitNotch={providerDeficitNotch(p)}
|
|
||||||
quotaWindows={quotaWindowsByProvider.get(p) ?? []}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user