Files
PaperClipAI/ui/src/pages/Costs.tsx
T
TonioandPaperclip 56517dda6c fix(ui): bundle connector, toolbar, empty-state and agent roster fixes (#18716)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Operators use connector setup, collection headers, empty states, and
agent lists to manage that work.
> - Provider redirects replace the setup page, and collection controls
use different sizes.
> - Agent selection inserts a bar above the list. The row link
highlights only part of the row.
> - This pull request keeps sign-in in a separate window and applies the
Tasks control sizes to collection and audit headers.
> - It also reuses artifact artwork for empty collections and gives
agent selection a fixed action card.
> - These changes preserve the operator's place and keep controls and
rows stable.

## Linked Issues or Issue Description

**What happened?**

Connector sign-in replaced the current page. Inline collection controls
had different heights. Projects and Artifacts used small placeholder
empty states. Agent selection moved the list, hover covered only the
identity cell, and chat/status shared that cell without matching
headings.

**Expected behavior**

Provider sign-in opens in a new tab or window. Collection controls match
Tasks. Empty collections use the large artifact graphics. Agent actions
float above the page while rows remain aligned.

**Steps to reproduce**

Start connector sign-in from a page or dialog. Compare collection and
Audit headers. Open an empty Projects or Artifacts collection. Select an
agent, then hover across its row.

Related public work: #13584 covers an earlier connector new-tab
implementation, including same-page fallbacks that this bundle does not
use. #8528 covers Tasks toggle sizing. #17742 covers scoped agent Audit
sections. This bundle does not change server callback or audit
authorization contracts.

## What Changed

- Reserve a separate authorization window during the click. Use
validated sign-in URLs, retain a new-tab fallback, and close blank
windows after failed requests. Store Cloud reauthentication state in the
destination window.
- Use the Tasks 44px control foundation for shared collection headers
and Audit, Costs, Timeline, Secrets, Teams, Gateways, and agent skill
controls.
- Use a large artifact stack with an off-white Paperclip image with a
dark icon for Projects and artifact Tasks empty states. Use one matching
artifact for other types. Set 20px gaps and use the label “Create
project”. Action hover and keyboard focus animate the artwork.
- Portal agent bulk actions to a fixed card 48px above the bottom safe
area. Use quintic entry/exit motion and respect reduced motion. Retain
card contents during exit.
- Give chat and status separate cells, add a Status heading, use the
MessagesSquare chat action with a hover tooltip, and highlight the
entire row.
- Add linked agent chips and a manager picker to the agent profile.
Block self and descendant manager choices. Keep the name crown and
custom title inline during edits.
- Update design guidance, the connection maps, regression tests, and
empty-state Storybook fixtures.

## Verification

- Final commit `a41a6c6494a839deaaec1ef7cc50c90078ab6381`: all 55 checks
passed, three skipped. [Canonical CI
run](https://github.com/paperclipai/paperclip/actions/runs/41999792769)
includes all eight browser shards and all server/runner/workspace
groups. Greptile: 5/5, zero unresolved review findings.
- Workspace typecheck/build passed. Local UI suite passed 11,006 tests
in 880 files, plus 542 CLI tests. Subsequent connector callback changes
passed all 245 AppsConnect tests, UI typecheck, token gates, and
query-error policy checks. Manager cache/error regressions passed four
tests. Map checks passed.
- Local connector browser suite: 20 passed. All six chat integration
shards: 1,931 passed. Workspace B groups passed; database tests passed
313 cases across 72 files with one worker.
- Browser preview verified agent manager changes, overview links, inline
name/title geometry, empty-state colors, row actions and the chat
tooltip. Live provider consent and post-deployment feature verification
remain separate from these fixtures.
- Preserved failures: ten bulk-roster tests initially missed the
portaled action card; their selectors were fixed and all twelve roster
tests passed. A nested fixture selected pnpm 11 and removed local
dependencies; the local shim now pins pnpm 9. Embedded PostgreSQL native
links were restored. A Python 3.9 fixture failure passed all 19 tests
with the current runtime. Concurrent local database startup failures
passed on the one-worker rerun.
- The broad local serialized server run passed 3,791 tests with one
scheduling assertion failure, which passed in isolation. The local
general-server run reproduced a native recovery provider/model fixture
failure and has not completed. These server sources are unchanged by
this bundle; all final canonical CI server shards passed. This is not a
claim that every broad local run passed.
- [Earlier CI
run](https://github.com/paperclipai/paperclip/actions/runs/41995119880)
failed a browser test during cleanup when heartbeat-run cancellation hit
a PostgreSQL lock timeout. All final browser shards passed without
weakening that test.
- Review follow-ups cover Slack popup/fallback recovery, a single
artwork hover transform, shared manager error rendering, detached
standalone OAuth windows, and verified task-bound callbacks in both
hosts.

## Risks

- Browser popup policy and provider callback behavior can affect sign-in
completion. The original setup page stays open; blocked windows have an
explicit recovery path.
- Shared toolbar styles affect several surfaces. Menu content remains in
its existing portals; canonical label tabs and search clear buttons
retain their own geometry.
- No schema, server, API authorization, or migration change.
- Required CI and review are complete. Deployment provenance and hosted
feature verification are checked separately after merge.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, repository tools, test
execution, and browser inspection. The exact model ID and context-window
size were not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run relevant tests locally and they pass (broader local
exceptions are recorded above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-05 01:23:53 -08:00

1200 lines
57 KiB
TypeScript

import { FastResponseHistory } from "../components/fast-responses/FastResponseHistory";
import { DecisionHistory } from "../components/decision-models/DecisionHistory";
import { SubscriptionCostCard, SubscriptionTokenUsage } from "../components/SubscriptionCostCard";
import { subscriptionsApi } from "../api/subscriptions";
import { useSearchParams } from "@/lib/router";
import { CostEstimateLabel } from "../components/CostEstimateLabel";
import { CostAmount } from "../components/CostAmount";
import { CostByUserTable } from "../components/CostByUserTable";
import { CostDataQualityNotice } from "../components/CostDataQualityNotice";
import { AgentIdentity } from "@/components/AgentIdentity";
import { useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
BudgetPolicySummary,
CostByAgentModel,
CostByBiller,
CostByProviderModel,
CostWindowSpendRow,
FinanceEvent,
QuotaWindow,
ProviderQuotaResult,
} from "@paperclipai/shared";
import { ArrowDownLeft, ArrowUpRight, ChevronDown, ChevronRight, Coins, DollarSign, ReceiptText } from "lucide-react";
import { budgetsApi } from "../api/budgets";
import { costsApi } from "../api/costs";
import { agentsApi } from "../api/agents";
import { BillerSpendCard } from "../components/BillerSpendCard";
import { BudgetIncidentCard } from "../components/BudgetIncidentCard";
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
import { EmptyState } from "../components/EmptyState";
import { FinanceBillerCard } from "../components/FinanceBillerCard";
import { FinanceKindCard } from "../components/FinanceKindCard";
import { FinanceTimelineCard } from "../components/FinanceTimelineCard";
import { Identity } from "../components/Identity";
import { PageSkeleton } from "../components/PageSkeleton";
import { PageTabBar } from "../components/PageTabBar";
import { ProviderQuotaCard } from "../components/ProviderQuotaCard";
import { StatusBadge } from "../components/StatusBadge";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useCompany } from "../context/CompanyContext";
import { useDateRange, PRESET_KEYS, PRESET_LABELS } from "../hooks/useDateRange";
import { retainQuotaWindows } from "../lib/quota-refresh";
import { queryKeys } from "../lib/queryKeys";
import { billingTypeDisplayName, cn, formatCents, formatTokens, providerDisplayName } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const NO_COMPANY = "__none__";
export type CostsMainTab = "overview" | "budgets" | "providers" | "billers" | "finance" | "decisions" | "fast-responses";
export interface CostsProps {
/** Render inside Audit without a second page-level title or breadcrumb. */
embedded?: boolean;
initialTab?: CostsMainTab;
/** Pin the surface to one tab (used by Audit > Budgets). */
lockTab?: boolean;
/** Budgets is a peer Audit section, so omit it from the Costs sub-navigation. */
hideBudgetsTab?: boolean;
}
function currentWeekRange(): { from: string; to: string } {
const now = new Date();
const day = now.getDay();
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.getFullYear(), mon.getMonth(), mon.getDate() + 6, 23, 59, 59, 999);
return { from: mon.toISOString(), to: sun.toISOString() };
}
function ProviderTabLabel({ provider, rows }: { provider: string; rows: CostByProviderModel[] }) {
const totalTokens = rows.reduce((sum, row) => sum + row.inputTokens + row.cachedInputTokens + row.outputTokens, 0);
const totalCost = rows.reduce((sum, row) => sum + row.costCents, 0);
return (
<span className="flex items-center gap-1.5">
<span>{providerDisplayName(provider)}</span>
<span className="font-mono text-xs text-muted-foreground">{formatTokens(totalTokens)}</span>
<span className="text-xs text-muted-foreground">{formatCents(totalCost)}</span>
</span>
);
}
function BillerTabLabel({ biller, rows }: { biller: string; rows: CostByBiller[] }) {
const totalTokens = rows.reduce((sum, row) => sum + row.inputTokens + row.cachedInputTokens + row.outputTokens, 0);
const totalCost = rows.reduce((sum, row) => sum + row.costCents, 0);
return (
<span className="flex items-center gap-1.5">
<span>{providerDisplayName(biller)}</span>
<span className="font-mono text-xs text-muted-foreground">{formatTokens(totalTokens)}</span>
<span className="text-xs text-muted-foreground">{formatCents(totalCost)}</span>
</span>
);
}
function MetricTile({
label,
value,
subtitle,
icon: Icon,
}: {
label: string;
value: string;
subtitle: ReactNode;
icon: ComponentType<{ className?: string }>;
}) {
return (
<Card className="block p-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-(length:--text-micro) uppercase tracking-(--tracking-eyebrow) text-muted-foreground">{label}</div>
<div className="mt-2 text-2xl font-semibold tabular-nums">{value}</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">{subtitle}</div>
</div>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-border">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
</div>
</Card>
);
}
function FinanceSummaryCard({
debitCents,
creditCents,
netCents,
estimatedDebitCents,
eventCount,
}: {
debitCents: number;
creditCents: number;
netCents: number;
estimatedDebitCents: number;
eventCount: number;
}) {
return (
<Card>
<CardHeader className="px-5 pt-5 pb-2">
<CardTitle className="text-base">Finance ledger</CardTitle>
<CardDescription>
Account-level charges that do not map to a single inference request.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 px-5 pb-5 pt-2 sm:grid-cols-2 xl:grid-cols-4">
<MetricTile
label="Debits"
value={formatCents(debitCents)}
subtitle={`${eventCount} total event${eventCount === 1 ? "" : "s"} in range`}
icon={ArrowUpRight}
/>
<MetricTile
label="Credits"
value={formatCents(creditCents)}
subtitle="Refunds, offsets, and credit returns"
icon={ArrowDownLeft}
/>
<MetricTile
label="Net"
value={formatCents(netCents)}
subtitle="Debit minus credit for the selected period"
icon={ReceiptText}
/>
<MetricTile
label="Estimated"
value={formatCents(estimatedDebitCents)}
subtitle="Estimated debits that are not yet invoice-authoritative"
icon={Coins}
/>
</CardContent>
</Card>
);
}
export function Costs({
embedded = false,
initialTab = "overview",
lockTab = false,
hideBudgetsTab = false,
}: CostsProps = {}) {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const scopedAgentId = searchParams.get("agentId");
const { data: scopedAgent, error: scopedAgentError } = useQuery({
queryKey: [...queryKeys.agents.detail(scopedAgentId ?? NO_COMPANY), selectedCompanyId],
queryFn: () => agentsApi.get(scopedAgentId!, selectedCompanyId!),
enabled: Boolean(selectedCompanyId && scopedAgentId),
});
const museUsageUnavailable = scopedAgent?.adapterType === "paperclip_runner" && scopedAgent.adapterConfig.provider === "muse";
const [mainTab, setMainTab] = useState<CostsMainTab>(initialTab);
const [activeProvider, setActiveProvider] = useState("all");
const [activeBiller, setActiveBiller] = useState("all");
const showSummaryChrome = !(embedded && lockTab && initialTab === "budgets");
const {
preset,
setPreset,
customFrom,
setCustomFrom,
customTo,
setCustomTo,
from,
to,
customReady,
} = useDateRange();
useEffect(() => {
if (!embedded) setBreadcrumbs([{ label: "Costs" }]);
}, [embedded, setBreadcrumbs]);
useEffect(() => {
const tab = searchParams.get("tab");
setMainTab(!lockTab && tab && ["overview", "providers", "billers", "finance", "budgets", "decisions", "fast-responses"].includes(tab) ? tab as CostsMainTab : initialTab);
}, [initialTab, lockTab, searchParams]);
const [today, setToday] = useState(() => new Date().toDateString());
const todayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const schedule = () => {
const now = new Date();
const ms = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).getTime() - now.getTime();
todayTimerRef.current = setTimeout(() => {
setToday(new Date().toDateString());
schedule();
}, ms);
};
schedule();
return () => {
if (todayTimerRef.current != null) clearTimeout(todayTimerRef.current);
};
}, []);
const weekRange = useMemo(() => currentWeekRange(), [today]);
const companyId = selectedCompanyId ?? NO_COMPANY;
// The clock advances the request bounds, not the identity of the report.
// Keep successful data visible during polling; a different company or selected
// period still gets its own cache entry and initial loading state.
// Month/year-to-date become a different reporting period at their UTC
// boundary. Rolling windows retain identity as their lower bound advances.
const reportQueryFrom = ["custom", "mtd", "ytd"].includes(preset) ? from || undefined : undefined;
const reportQueryTo = preset === "custom" ? to || undefined : preset;
const subscriptionKey = ["cost-subscriptions", companyId, reportQueryFrom, reportQueryTo];
const { data: subscriptions, error: subscriptionError } = useQuery({
queryKey: subscriptionKey,
queryFn: () => subscriptionsApi.report(companyId, from || undefined, to || undefined),
enabled: !!selectedCompanyId && customReady && showSummaryChrome,
refetchInterval: 30_000,
});
const invalidateSubscriptions = () => { void queryClient.invalidateQueries({ queryKey: ["cost-subscriptions", companyId] }); };
const discoveryCompany = useRef<string | null>(null);
const discoverSubscriptions = useMutation({ mutationFn: (id: string) => subscriptionsApi.refresh(id),
onSuccess: (_data, id) => { void queryClient.invalidateQueries({ queryKey: ["cost-subscriptions", id] }); } });
useEffect(() => {
if (!selectedCompanyId || !showSummaryChrome || !subscriptions?.canRefresh || discoveryCompany.current === selectedCompanyId) return;
discoveryCompany.current = selectedCompanyId;
discoverSubscriptions.mutate(selectedCompanyId);
}, [selectedCompanyId, showSummaryChrome, subscriptions?.canRefresh, discoverSubscriptions.mutate]);
const { data: budgetData, isLoading: budgetLoading, error: budgetError } = useQuery({
queryKey: queryKeys.budgets.overview(companyId),
queryFn: () => budgetsApi.overview(companyId),
enabled: !!selectedCompanyId && customReady,
refetchInterval: 30_000,
staleTime: 5_000,
});
const invalidateBudgetViews = () => {
if (!selectedCompanyId) return;
queryClient.invalidateQueries({ queryKey: queryKeys.budgets.overview(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all(selectedCompanyId) });
};
const policyMutation = useMutation({
mutationFn: ({ summary, changes }: {
summary: BudgetPolicySummary;
changes: Partial<Pick<BudgetPolicySummary, "amount" | "reservationCents" | "unpricedUsagePolicy">>;
}) => budgetsApi.upsertPolicy(companyId, {
scopeType: summary.scopeType,
scopeId: summary.scopeId,
metric: summary.metric,
windowKind: summary.windowKind,
...changes,
}),
onSuccess: invalidateBudgetViews,
});
const incidentMutation = useMutation({
mutationFn: (input: { incidentId: string; action: "keep_paused" | "raise_budget_and_resume"; amount?: number }) =>
budgetsApi.resolveIncident(companyId, input.incidentId, input),
onSuccess: invalidateBudgetViews,
});
const { data: spendData, isLoading: spendLoading, error: spendError } = useQuery({
queryKey: queryKeys.costs(companyId, reportQueryFrom, reportQueryTo),
queryFn: async () => {
const [summary, byAgent, byProject, byAgentModel] = await Promise.all([
costsApi.summary(companyId, from || undefined, to || undefined),
costsApi.byAgent(companyId, from || undefined, to || undefined),
costsApi.byProject(companyId, from || undefined, to || undefined),
costsApi.byAgentModel(companyId, from || undefined, to || undefined),
]);
return { summary, byAgent, byProject, byAgentModel };
},
enabled: !!selectedCompanyId && customReady && showSummaryChrome,
refetchInterval: 30_000,
});
const { data: userSpendData, error: userSpendError } = useQuery({
queryKey: [...queryKeys.costs(companyId, reportQueryFrom, reportQueryTo), "by-user"],
queryFn: () => costsApi.byUser(companyId, from || undefined, to || undefined),
enabled: !!selectedCompanyId && customReady && showSummaryChrome && mainTab === "overview",
refetchInterval: 30_000,
});
const { data: financeData, isLoading: financeLoading, error: financeError } = useQuery({
queryKey: [
queryKeys.financeSummary(companyId, reportQueryFrom, reportQueryTo),
queryKeys.financeByBiller(companyId, reportQueryFrom, reportQueryTo),
queryKeys.financeByKind(companyId, reportQueryFrom, reportQueryTo),
queryKeys.financeEvents(companyId, reportQueryFrom, reportQueryTo, 18),
],
queryFn: async () => {
const [summary, byBiller, byKind, events] = await Promise.all([
costsApi.financeSummary(companyId, from || undefined, to || undefined),
costsApi.financeByBiller(companyId, from || undefined, to || undefined),
costsApi.financeByKind(companyId, from || undefined, to || undefined),
costsApi.financeEvents(companyId, from || undefined, to || undefined, 18),
]);
return { summary, byBiller, byKind, events };
},
enabled: !!selectedCompanyId && customReady && showSummaryChrome,
refetchInterval: 30_000,
});
const [expandedAgents, setExpandedAgents] = useState<Set<string | null>>(new Set());
useEffect(() => {
setExpandedAgents(new Set());
}, [companyId, preset, customFrom, customTo]);
function toggleAgent(agentId: string | null) {
setExpandedAgents((prev) => {
const next = new Set(prev);
if (next.has(agentId)) next.delete(agentId);
else next.add(agentId);
return next;
});
}
const agentModelRows = useMemo(() => {
const map = new Map<string | null, CostByAgentModel[]>();
for (const row of spendData?.byAgentModel ?? []) {
const rows = map.get(row.agentId) ?? [];
rows.push(row);
map.set(row.agentId, rows);
}
for (const [agentId, rows] of map) {
map.set(agentId, rows.slice().sort((a, b) => b.costCents - a.costCents));
}
return map;
}, [spendData?.byAgentModel]);
const { data: providerData, error: providerError } = useQuery({
queryKey: queryKeys.usageByProvider(companyId, reportQueryFrom, reportQueryTo),
queryFn: () => costsApi.byProvider(companyId, from || undefined, to || undefined),
enabled: !!selectedCompanyId && customReady && (mainTab === "providers" || mainTab === "billers"),
refetchInterval: 30_000,
staleTime: 10_000,
});
const { data: billerData, error: billerError } = useQuery({
queryKey: queryKeys.usageByBiller(companyId, reportQueryFrom, reportQueryTo),
queryFn: () => costsApi.byBiller(companyId, from || undefined, to || undefined),
enabled: !!selectedCompanyId && customReady && mainTab === "billers",
refetchInterval: 30_000,
staleTime: 10_000,
});
const { data: weekData, error: weekError } = useQuery({
queryKey: queryKeys.usageByProvider(companyId, weekRange.from, weekRange.to),
queryFn: () => costsApi.byProvider(companyId, weekRange.from, weekRange.to),
enabled: !!selectedCompanyId && (mainTab === "providers" || mainTab === "billers"),
refetchInterval: 30_000,
staleTime: 10_000,
});
const { data: weekBillerData, error: weekBillerError } = useQuery({
queryKey: queryKeys.usageByBiller(companyId, weekRange.from, weekRange.to),
queryFn: () => costsApi.byBiller(companyId, weekRange.from, weekRange.to),
enabled: !!selectedCompanyId && mainTab === "billers",
refetchInterval: 30_000,
staleTime: 10_000,
});
const { data: windowData, error: windowError } = useQuery({
queryKey: queryKeys.usageWindowSpend(companyId),
queryFn: () => costsApi.windowSpend(companyId),
enabled: !!selectedCompanyId && mainTab === "providers",
refetchInterval: 30_000,
staleTime: 10_000,
});
const { data: quotaData, isLoading: quotaLoading, error: quotaFetchError } = useQuery({
queryKey: queryKeys.usageQuotaWindows(companyId),
queryFn: () => costsApi.quotaWindows(companyId),
structuralSharing: (previous, incoming) => retainQuotaWindows(
previous as ProviderQuotaResult[] | undefined,
incoming as ProviderQuotaResult[],
),
enabled: !!selectedCompanyId && mainTab === "providers",
refetchInterval: 300_000,
staleTime: 60_000,
});
const byProvider = useMemo(() => {
const map = new Map<string, CostByProviderModel[]>();
for (const row of providerData ?? []) {
const rows = map.get(row.provider) ?? [];
rows.push(row);
map.set(row.provider, rows);
}
return map;
}, [providerData]);
const byBiller = useMemo(() => {
const map = new Map<string, CostByBiller[]>();
for (const row of billerData ?? []) {
const rows = map.get(row.biller) ?? [];
rows.push(row);
map.set(row.biller, rows);
}
return map;
}, [billerData]);
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 weekSpendByBiller = useMemo(() => {
const map = new Map<string, number>();
for (const row of weekBillerData ?? []) {
map.set(row.biller, (map.get(row.biller) ?? 0) + row.costCents);
}
return map;
}, [weekBillerData]);
const windowSpendByProvider = useMemo(() => {
const map = new Map<string, CostWindowSpendRow[]>();
for (const row of windowData ?? []) {
const rows = map.get(row.provider) ?? [];
rows.push(row);
map.set(row.provider, rows);
}
return map;
}, [windowData]);
const quotaWindowsByProvider = useMemo(() => {
const map = new Map<string, QuotaWindow[]>();
for (const result of quotaData ?? []) {
if (result.windows.length > 0) {
map.set(result.provider, result.windows);
}
}
return map;
}, [quotaData]);
const quotaErrorsByProvider = useMemo(() => {
const map = new Map<string, string>();
for (const result of quotaData ?? []) {
if (!result.ok) map.set(result.provider, "unavailable");
}
if (quotaFetchError) {
map.set("anthropic", "unavailable");
map.set("openai", "unavailable");
}
return map;
}, [quotaData, quotaFetchError]);
const quotaSourcesByProvider = useMemo(() => {
const map = new Map<string, string>();
for (const result of quotaData ?? []) {
if (typeof result.source === "string" && result.source.length > 0) {
map.set(result.provider, result.source);
}
}
return map;
}, [quotaData]);
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 now = new Date();
const daysElapsed = now.getUTCDate();
const daysInMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0)).getUTCDate();
for (const [providerKey, rows] of byProvider) {
const providerCostCents = rows.reduce((sum, row) => sum + row.costCents, 0);
const burnRate = providerCostCents / Math.max(daysElapsed, 1);
map.set(providerKey, providerCostCents + burnRate * (daysInMonth - daysElapsed) > budget);
}
return map;
}, [preset, spendData, byProvider]);
const providers = useMemo(() => Array.from(byProvider.keys()), [byProvider]);
const billers = useMemo(() => Array.from(byBiller.keys()), [byBiller]);
const effectiveProvider =
activeProvider === "all" || providers.includes(activeProvider) ? activeProvider : "all";
useEffect(() => {
if (effectiveProvider !== activeProvider) setActiveProvider("all");
}, [effectiveProvider, activeProvider]);
const effectiveBiller =
activeBiller === "all" || billers.includes(activeBiller) ? activeBiller : "all";
useEffect(() => {
if (effectiveBiller !== activeBiller) setActiveBiller("all");
}, [effectiveBiller, activeBiller]);
const providerTabItems = useMemo(() => {
const providerKeys = Array.from(byProvider.keys());
const allTokens = providerKeys.reduce(
(sum, provider) => sum + (byProvider.get(provider)?.reduce((acc, row) => acc + row.inputTokens + row.cachedInputTokens + row.outputTokens, 0) ?? 0),
0,
);
const allCents = providerKeys.reduce(
(sum, provider) => sum + (byProvider.get(provider)?.reduce((acc, row) => acc + row.costCents, 0) ?? 0),
0,
);
return [
{
value: "all",
label: (
<span className="flex items-center gap-1.5">
<span>All providers</span>
{providerKeys.length > 0 ? (
<>
<span className="font-mono text-xs text-muted-foreground">{formatTokens(allTokens)}</span>
<span className="text-xs text-muted-foreground">{formatCents(allCents)}</span>
</>
) : null}
</span>
),
},
...providerKeys.map((provider) => ({
value: provider,
label: <ProviderTabLabel provider={provider} rows={byProvider.get(provider) ?? []} />,
})),
];
}, [byProvider]);
const billerTabItems = useMemo(() => {
const billerKeys = Array.from(byBiller.keys());
const allTokens = billerKeys.reduce(
(sum, biller) => sum + (byBiller.get(biller)?.reduce((acc, row) => acc + row.inputTokens + row.cachedInputTokens + row.outputTokens, 0) ?? 0),
0,
);
const allCents = billerKeys.reduce(
(sum, biller) => sum + (byBiller.get(biller)?.reduce((acc, row) => acc + row.costCents, 0) ?? 0),
0,
);
return [
{
value: "all",
label: (
<span className="flex items-center gap-1.5">
<span>All billers</span>
{billerKeys.length > 0 ? (
<>
<span className="font-mono text-xs text-muted-foreground">{formatTokens(allTokens)}</span>
<span className="text-xs text-muted-foreground">{formatCents(allCents)}</span>
</>
) : null}
</span>
),
},
...billerKeys.map((biller) => ({
value: biller,
label: <BillerTabLabel biller={biller} rows={byBiller.get(biller) ?? []} />,
})),
];
}, [byBiller]);
const topFinanceEvents = (financeData?.events ?? []) as FinanceEvent[];
const budgetPolicies = budgetData?.policies ?? [];
const activeBudgetIncidents = budgetData?.activeIncidents ?? [];
const budgetPoliciesByScope = useMemo(() => ({
company: budgetPolicies.filter((policy) => policy.scopeType === "company"),
agent: budgetPolicies.filter((policy) => policy.scopeType === "agent"),
project: budgetPolicies.filter((policy) => policy.scopeType === "project"),
}), [budgetPolicies]);
if (!selectedCompanyId) {
return <EmptyState icon={DollarSign} message="Select an organization to view costs." />;
}
const showCustomPrompt = preset === "custom" && !customReady;
const showOverviewLoading = spendLoading && customReady;
const overviewError = !spendData && spendError;
return (
<div className="space-y-6">
{scopedAgentError && (
<p role="alert" className="text-sm text-destructive">Could not load agent details. Totals below show recorded organization costs.</p>
)}
{museUsageUnavailable && (
<p role="status" className="text-sm text-muted-foreground">
{scopedAgent.name}'s Muse activity has no reported usage or cost. Totals below show recorded organization charges only. Paperclip cannot enforce a Muse spend ceiling.
</p>
)}
{showSummaryChrome ? (
<div className="space-y-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
{embedded ? (
<h2 className="text-lg font-semibold text-foreground">Costs</h2>
) : (
<h1 className="text-3xl font-semibold tracking-tight">Costs</h1>
)}
</div>
<div data-canonical-toolbar="cost-range" role="toolbar" aria-label="Cost date range" className="flex flex-wrap items-center gap-2">
{PRESET_KEYS.map((key) => (
<Button
key={key}
variant={preset === key ? "secondary" : "ghost"}
size="sm"
onClick={() => setPreset(key)}
aria-pressed={preset === key}
>
{PRESET_LABELS[key]}
</Button>
))}
</div>
</div>
{preset === "custom" ? (
<div data-canonical-toolbar="cost-dates" className="flex flex-wrap items-center gap-2 border border-border p-3">
<input
type="date"
value={customFrom}
onChange={(event) => setCustomFrom(event.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm text-foreground"
/>
<span className="text-sm text-muted-foreground">to</span>
<input
type="date"
value={customTo}
onChange={(event) => setCustomTo(event.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm text-foreground"
/>
</div>
) : null}
{financeData?.summary.currencies?.some((row) => row.currency !== "USD") && (
<p role="status" className="text-sm text-muted-foreground">
Finance headline totals are USD only. Other currencies are listed separately; no exchange-rate conversion is applied.
</p>
)}
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<MetricTile
label="API spend"
value={subscriptions ? subscriptions.api.unpricedEventCount === subscriptions.api.eventCount && subscriptions.api.eventCount > 0 ? "—" : formatCents(Number(subscriptions.api.costCents)) : "—"}
subtitle={subscriptions ? <div className="space-y-1">
<CostEstimateLabel eventCount={subscriptions.api.eventCount} estimatedEventCount={subscriptions.api.estimatedEventCount} />
<div><SubscriptionTokenUsage usage={subscriptions.api} /></div>
</div> : subscriptionError ? "API usage is unavailable" : "API usage is loading"}
icon={DollarSign}
/>
<SubscriptionCostCard key={companyId} companyId={companyId} report={subscriptions} error={subscriptionError} onChanged={invalidateSubscriptions} />
<MetricTile
label="Budget"
value={activeBudgetIncidents.length > 0 ? String(activeBudgetIncidents.length) : (
spendData?.summary.budgetCents && spendData.summary.budgetCents > 0
? preset === "mtd" ? `${spendData.summary.utilizationPercent}%` : formatCents(spendData.summary.budgetCents)
: "Open"
)}
subtitle={
activeBudgetIncidents.length > 0
? `${budgetData?.pausedAgentCount ?? 0} agents paused · ${budgetData?.pausedProjectCount ?? 0} projects paused`
: spendData?.summary.budgetCents && spendData.summary.budgetCents > 0
? preset === "mtd" ? `${formatCents(spendData.summary.spendCents)} of ${formatCents(spendData.summary.budgetCents)} this month` : "Monthly limit"
: "No monthly cap configured"
}
icon={Coins}
/>
<MetricTile
label="Recorded charges"
value={financeData ? formatCents(financeData.summary.netCents) : "—"}
subtitle={financeData ? (
<>
<span className="block whitespace-nowrap">{formatCents(financeData.summary.debitCents)} debits</span>
<span className="block whitespace-nowrap">{formatCents(financeData.summary.creditCents)} credits</span>
</>
) : "Financial data unavailable"}
icon={ReceiptText}
/>
<MetricTile
label="Finance events"
value={financeData ? String(financeData.summary.eventCount) : "—"}
subtitle={financeData ? `${formatCents(financeData.summary.estimatedDebitCents)} estimated in range` : "Financial data unavailable"}
icon={ArrowUpRight}
/>
</div>
{subscriptions && Number(subscriptions.subscription.costCents) > 0 && <p className="text-sm text-muted-foreground">Subscription accounts also have {formatCents(Number(subscriptions.subscription.costCents))} in recorded usage charges for this period, separate from monthly fees.</p>}
{subscriptions && subscriptions.unknown.eventCount > 0 && <p className="text-sm text-muted-foreground">Other billing usage: {formatTokens(subscriptions.unknown.inputTokens + subscriptions.unknown.cachedInputTokens + subscriptions.unknown.outputTokens)} tokens{Number(subscriptions.unknown.costCents) > 0 ? ` · ${formatCents(Number(subscriptions.unknown.costCents))} in recorded charges` : ""}, included in the inference ledger.</p>}
{subscriptionError && subscriptions && <p role="status" className="text-sm text-muted-foreground">Showing the last loaded API and subscription usage. Updates will resume automatically.</p>}
{discoverSubscriptions.error && <div role="status" className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<p>Connected accounts could not be checked. Existing subscription estimates are still shown.</p>
{subscriptions?.canRefresh && <Button variant="outline" size="sm" disabled={discoverSubscriptions.isPending}
onClick={() => discoverSubscriptions.mutate(companyId)}>Retry account check</Button>}
</div>}
{mainTab === "overview" && spendData && (spendData.summary.unpricedEventCount > 0 || spendData.summary.pendingRunCount > 0 || (spendData.summary.unknownBillingEventCount ?? 0) > 0) && (
<CostDataQualityNotice key={`${companyId}:${reportQueryFrom}:${reportQueryTo}`} companyId={companyId}
from={from || undefined} to={to || undefined} reportKey={queryKeys.costs(companyId, reportQueryFrom, reportQueryTo)}
unpricedCount={spendData.summary.unpricedEventCount} pendingCount={spendData.summary.pendingRunCount}
unknownBillingCount={spendData.summary.unknownBillingEventCount ?? 0} />
)}
</div>
) : null}
{budgetError && !budgetData ? (
<p role="status" className="text-sm text-muted-foreground">
Budget data could not be loaded. Please try again shortly.
</p>
) : null}
{((spendData && spendError) || (financeData && financeError) || (budgetData && budgetError) || providerError || billerError || weekError || weekBillerError || windowError) ? (
<p role="status" className="text-sm text-muted-foreground">
Showing the last loaded data. Updates will resume automatically.
</p>
) : null}
{incidentMutation.error && <p role="alert" className="text-sm text-destructive">Could not update the budget. Check any pending runs or unpriced usage shown on this page, then try again.</p>}
<Tabs value={mainTab} onValueChange={(value) => { setMainTab(value as typeof mainTab); setSearchParams(current => { const next = new URLSearchParams(current); next.set("tab", value); return next; }, { replace: true }); }}>
{!lockTab ? (
<div className="max-w-full overflow-x-auto pb-2">
<TabsList variant="line" className="justify-start">
<TabsTrigger value="overview">Overview</TabsTrigger>
{!hideBudgetsTab ? <TabsTrigger value="budgets">Budgets</TabsTrigger> : null}
<TabsTrigger value="providers">Providers</TabsTrigger>
<TabsTrigger value="billers">Billers</TabsTrigger>
<TabsTrigger value="finance">Finance</TabsTrigger>
<TabsTrigger value="decisions">Decisions</TabsTrigger>
<TabsTrigger value="fast-responses">Fast responses</TabsTrigger>
</TabsList>
</div>
) : null}
<TabsContent value="fast-responses" className="mt-4">{showCustomPrompt ? <p className="text-sm text-muted-foreground">Select a start and end date to load data.</p> : <FastResponseHistory companyId={companyId} from={from} to={to} />}</TabsContent>
<TabsContent value="decisions" className="mt-4">{showCustomPrompt ? <p className="text-sm text-muted-foreground">Select a start and end date to load data.</p> : <DecisionHistory companyId={companyId} from={from} to={to} />}</TabsContent>
<TabsContent value="overview" className="mt-4 space-y-4">
{showCustomPrompt ? (
<p className="text-sm text-muted-foreground">Select a start and end date to load data.</p>
) : showOverviewLoading ? (
<PageSkeleton variant="costs" />
) : overviewError ? (
<p className="text-sm text-destructive">Cost data could not be loaded. Please try again shortly.</p>
) : (
<>
<div className="grid gap-4 xl:grid-cols-(--gtc-32)">
<div className="min-w-0 space-y-4">
<Card className="gap-3 py-4">
<CardHeader className="gap-0 px-5">
<CardTitle className="text-base">By agent</CardTitle>
</CardHeader>
<CardContent className="space-y-2 px-5">
{(spendData?.byAgent.length ?? 0) === 0 ? (
<p className="text-sm text-muted-foreground">No cost events yet.</p>
) : (
spendData?.byAgent.map((row) => {
const modelRows = agentModelRows.get(row.agentId) ?? [];
const isExpanded = expandedAgents.has(row.agentId);
const hasBreakdown = modelRows.length > 0;
return (
<div key={row.agentId ?? "services"} role="group" aria-label={`${row.agentName ?? row.agentId ?? "Paperclip services"} costs`} className="border border-border px-4 py-3">
<div
className={cn("flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between", hasBreakdown ? "cursor-pointer select-none" : "")}
onClick={() => hasBreakdown && toggleAgent(row.agentId)}
>
<div className="flex min-w-0 items-center gap-2">
{hasBreakdown ? (
isExpanded
? <ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
: <ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
) : (
<span className="h-3 w-3 shrink-0" />
)}
{row.agentId ? <AgentIdentity agent={{ id: row.agentId, name: row.agentName ?? row.agentId, appearance: row.agentAppearance }} size="sm" /> : <span className="font-medium">Paperclip services</span>}
{row.agentStatus === "terminated" ? <StatusBadge status="terminated" /> : null}
</div>
<div className="text-right text-sm tabular-nums">
<CostAmount costCents={row.costCents} eventCount={row.eventCount} estimatedEventCount={row.estimatedEventCount} />
<div className="text-xs text-muted-foreground">
in {formatTokens(row.inputTokens + row.cachedInputTokens)} ({formatTokens(row.cachedInputTokens)} cached) · out {formatTokens(row.outputTokens)}
</div>
{(row.apiRunCount > 0 || row.subscriptionRunCount > 0) ? (
<div className="text-xs text-muted-foreground">
{"runs: "}
{row.apiRunCount > 0 ? `${row.apiRunCount} api` : "0 api"}
{" · "}
{row.subscriptionRunCount > 0
? `${row.subscriptionRunCount} sub`
: "0 sub"}
</div>
) : null}
</div>
</div>
{isExpanded && modelRows.length > 0 ? (
<div className="mt-3 space-y-2 border-l border-border pl-4">
{modelRows.map((modelRow) => {
const sharePct = row.costCents > 0 ? Math.round((modelRow.costCents / row.costCents) * 100) : 0;
return (
<div
key={`${modelRow.provider}:${modelRow.model}:${modelRow.billingType}`}
className="flex flex-col gap-2 text-xs sm:flex-row sm:items-start sm:justify-between sm:gap-3"
>
<div className="min-w-0">
<div className="truncate font-medium text-foreground">
{providerDisplayName(modelRow.provider)}
<span className="mx-1 text-border">/</span>
<span className="font-mono">{modelRow.model}</span>
</div>
<div className="truncate text-muted-foreground">
{providerDisplayName(modelRow.biller)} · {billingTypeDisplayName(modelRow.billingType)}
</div>
</div>
<div className="text-right tabular-nums">
<CostAmount
costCents={modelRow.costCents}
eventCount={modelRow.eventCount}
estimatedEventCount={modelRow.estimatedEventCount}
suffix={<span className="ml-1 font-normal text-muted-foreground">({sharePct}%)</span>}
/>
<div className="text-muted-foreground">
{formatTokens(modelRow.inputTokens + modelRow.cachedInputTokens + modelRow.outputTokens)} tok
</div>
</div>
</div>
);
})}
</div>
) : null}
</div>
);
})
)}
</CardContent>
</Card>
{userSpendError && (
<p role="status" className="text-sm text-muted-foreground">
{userSpendData ? "User costs could not be refreshed. Showing the last loaded data." : "User costs could not be loaded. Please try again shortly."}
</p>
)}
{userSpendData && <CostByUserTable report={userSpendData} />}
</div>
<div className="space-y-4">
<Card className="gap-3 py-4">
<CardHeader className="gap-0 px-5">
<CardTitle className="text-base">By project</CardTitle>
</CardHeader>
<CardContent className="space-y-2 px-5">
{(spendData?.byProject.length ?? 0) === 0 ? (
<p className="text-sm text-muted-foreground">No project-attributed run costs yet.</p>
) : (
spendData?.byProject.map((row, index) => (
<div
key={row.projectId ?? `unattributed-${index}`}
className="flex items-center justify-between gap-3 border border-border px-3 py-2 text-sm"
>
<span className="truncate">{row.projectName ?? row.projectId ?? "Unattributed"}</span>
<CostAmount costCents={row.costCents} eventCount={row.eventCount} estimatedEventCount={row.estimatedEventCount} />
</div>
))
)}
</CardContent>
</Card>
</div>
</div>
{activeBudgetIncidents.length > 0 ? (
<div className="grid gap-4 xl:grid-cols-2">
{activeBudgetIncidents.slice(0, 2).map((incident) => (
<BudgetIncidentCard
key={incident.id}
incident={incident}
isMutating={incidentMutation.isPending}
onKeepPaused={() => incidentMutation.mutate({ incidentId: incident.id, action: "keep_paused" })}
onRaiseAndResume={(amount) =>
incidentMutation.mutate({
incidentId: incident.id,
action: "raise_budget_and_resume",
amount,
})}
/>
))}
</div>
) : null}
{financeData ? (
<FinanceSummaryCard
debitCents={financeData.summary.debitCents}
creditCents={financeData.summary.creditCents}
netCents={financeData.summary.netCents}
estimatedDebitCents={financeData.summary.estimatedDebitCents}
eventCount={financeData.summary.eventCount}
/>
) : (
<p role="status" className="text-sm text-muted-foreground">
{financeLoading ? "Loading financial events…" : "Financial events could not be loaded. Please try again shortly."}
</p>
)}
</>
)}
</TabsContent>
<TabsContent value="budgets" className="mt-4 space-y-4">
{policyMutation.error && <p role="alert" className="text-sm text-destructive">Could not update the budget policy. Review your settings and try again.</p>}
{budgetLoading ? (
<PageSkeleton variant="costs" />
) : budgetError && !budgetData ? null : (
<>
<Card className="border-border/70 bg-(image:--gradient-extract-2)">
<CardHeader className="px-5 pt-5 pb-3">
<CardTitle className="text-base">Budget control plane</CardTitle>
<CardDescription>
Hard-stop spend limits for agents and projects. Provider subscription quota stays separate and appears under Providers.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 px-5 pb-5 pt-0 md:grid-cols-4">
<MetricTile
label="Active incidents"
value={String(activeBudgetIncidents.length)}
subtitle="Open soft or hard threshold crossings"
icon={ReceiptText}
/>
<MetricTile
label="Pending approvals"
value={String(budgetData?.pendingApprovalCount ?? 0)}
subtitle="Budget override approvals awaiting board action"
icon={ArrowUpRight}
/>
<MetricTile
label="Paused agents"
value={String(budgetData?.pausedAgentCount ?? 0)}
subtitle="Agent heartbeats blocked by budget"
icon={Coins}
/>
<MetricTile
label="Paused projects"
value={String(budgetData?.pausedProjectCount ?? 0)}
subtitle="Project execution blocked by budget"
icon={DollarSign}
/>
</CardContent>
</Card>
{activeBudgetIncidents.length > 0 ? (
<div className="space-y-3">
<div>
<h2 className="text-lg font-semibold">Active incidents</h2>
<p className="text-sm text-muted-foreground">
Raise the budget or keep a hard stop paused. Dismiss a warning when no action is needed.
</p>
</div>
<div className="grid gap-4 xl:grid-cols-2">
{activeBudgetIncidents.map((incident) => (
<BudgetIncidentCard
key={incident.id}
incident={incident}
isMutating={incidentMutation.isPending}
onKeepPaused={() => incidentMutation.mutate({ incidentId: incident.id, action: "keep_paused" })}
onRaiseAndResume={(amount) =>
incidentMutation.mutate({
incidentId: incident.id,
action: "raise_budget_and_resume",
amount,
})}
/>
))}
</div>
</div>
) : null}
<div className="space-y-5">
{(["company", "agent", "project"] as const).map((scopeType) => {
const rows = budgetPoliciesByScope[scopeType];
if (rows.length === 0) return null;
return (
<section key={scopeType} className="space-y-3">
<div>
<h2 className="text-lg font-semibold capitalize">{scopeType === "company" ? "organization" : scopeType} budgets</h2>
<p className="text-sm text-muted-foreground">
{scopeType === "company"
? "Organization-wide monthly policy."
: scopeType === "agent"
? "Recurring monthly spend policies for individual agents."
: "Lifetime spend policies for execution-bound projects."}
</p>
</div>
<div className="grid gap-4 xl:grid-cols-2">
{rows.map((summary) => (
<BudgetPolicyCard
key={summary.policyId}
summary={summary}
onReservationChange={(reservationCents) => policyMutation.mutate({ summary, changes: { reservationCents } })}
onUnpricedUsagePolicyChange={(unpricedUsagePolicy) => policyMutation.mutate({ summary, changes: { unpricedUsagePolicy } })}
isSaving={policyMutation.isPending}
onSave={(amount) =>
policyMutation.mutate({ summary, changes: { amount } })}
/>
))}
</div>
</section>
);
})}
{budgetPolicies.length === 0 ? (
<Card>
<CardContent className="px-5 py-8 text-sm text-muted-foreground">
No budget policies yet. Set agent and project budgets from their detail pages, or use the existing organization monthly budget control.
</CardContent>
</Card>
) : null}
</div>
</>
)}
</TabsContent>
<TabsContent value="providers" className="mt-4 space-y-4">
{showCustomPrompt ? (
<p className="text-sm text-muted-foreground">Select a start and end date to load data.</p>
) : (
<>
<Tabs value={effectiveProvider} onValueChange={setActiveProvider}>
<PageTabBar items={providerTabItems} value={effectiveProvider} />
<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 gap-4 md:grid-cols-2">
{providers.map((provider) => (
<ProviderQuotaCard
key={provider}
provider={provider}
rows={byProvider.get(provider) ?? []}
budgetMonthlyCents={spendData?.summary.budgetCents ?? 0}
showBudgetUtilization={preset === "mtd"}
totalCompanySpendCents={spendData?.summary.spendCents ?? 0}
weekSpendCents={weekSpendByProvider.get(provider) ?? 0}
windowRows={windowSpendByProvider.get(provider) ?? []}
showDeficitNotch={deficitNotchByProvider.get(provider) ?? false}
quotaAccounts={quotaData?.filter(account => account.provider === provider)}
quotaRequestFailed={Boolean(quotaFetchError)}
quotaWindows={quotaWindowsByProvider.get(provider) ?? []}
quotaError={quotaErrorsByProvider.get(provider) ?? null}
quotaSource={quotaSourcesByProvider.get(provider) ?? null}
quotaLoading={quotaLoading}
/>
))}
</div>
)}
</TabsContent>
{providers.map((provider) => (
<TabsContent key={provider} value={provider} className="mt-4">
<ProviderQuotaCard
provider={provider}
rows={byProvider.get(provider) ?? []}
budgetMonthlyCents={spendData?.summary.budgetCents ?? 0}
showBudgetUtilization={preset === "mtd"}
totalCompanySpendCents={spendData?.summary.spendCents ?? 0}
weekSpendCents={weekSpendByProvider.get(provider) ?? 0}
windowRows={windowSpendByProvider.get(provider) ?? []}
showDeficitNotch={deficitNotchByProvider.get(provider) ?? false}
quotaAccounts={quotaData?.filter(account => account.provider === provider)}
quotaRequestFailed={Boolean(quotaFetchError)}
quotaWindows={quotaWindowsByProvider.get(provider) ?? []}
quotaError={quotaErrorsByProvider.get(provider) ?? null}
quotaSource={quotaSourcesByProvider.get(provider) ?? null}
quotaLoading={quotaLoading}
/>
</TabsContent>
))}
</Tabs>
</>
)}
</TabsContent>
<TabsContent value="billers" className="mt-4 space-y-4">
{showCustomPrompt ? (
<p className="text-sm text-muted-foreground">Select a start and end date to load data.</p>
) : (
<>
<Tabs value={effectiveBiller} onValueChange={setActiveBiller}>
<PageTabBar items={billerTabItems} value={effectiveBiller} />
<TabsContent value="all" className="mt-4">
{billers.length === 0 ? (
<p className="text-sm text-muted-foreground">No billable events in this period.</p>
) : (
<div className="grid gap-4 md:grid-cols-2">
{billers.map((biller) => {
const row = (byBiller.get(biller) ?? [])[0];
if (!row) return null;
const providerRows = (providerData ?? []).filter((entry) => entry.biller === biller);
return (
<BillerSpendCard
key={biller}
row={row}
weekSpendCents={weekSpendByBiller.get(biller) ?? 0}
budgetMonthlyCents={spendData?.summary.budgetCents ?? 0}
showBudgetUtilization={preset === "mtd"}
totalCompanySpendCents={spendData?.summary.spendCents ?? 0}
providerRows={providerRows}
/>
);
})}
</div>
)}
</TabsContent>
{billers.map((biller) => {
const row = (byBiller.get(biller) ?? [])[0];
if (!row) return null;
const providerRows = (providerData ?? []).filter((entry) => entry.biller === biller);
return (
<TabsContent key={biller} value={biller} className="mt-4">
<BillerSpendCard
row={row}
weekSpendCents={weekSpendByBiller.get(biller) ?? 0}
budgetMonthlyCents={spendData?.summary.budgetCents ?? 0}
showBudgetUtilization={preset === "mtd"}
totalCompanySpendCents={spendData?.summary.spendCents ?? 0}
providerRows={providerRows}
/>
</TabsContent>
);
})}
</Tabs>
</>
)}
</TabsContent>
<TabsContent value="finance" className="mt-4 space-y-4">
{financeData?.summary.providerReportedCentsExact !== undefined && <p className="text-sm text-muted-foreground">Provider API cost reports: {formatCents(financeData.summary.providerReportedCents ?? 0)}. Report totals overlap invoices and run estimates and are shown separately from recorded charges.</p>}
{showCustomPrompt ? (
<p className="text-sm text-muted-foreground">Select a start and end date to load data.</p>
) : financeLoading ? (
<PageSkeleton variant="costs" />
) : financeError && !financeData ? (
<p className="text-sm text-destructive">Financial events could not be loaded. Please try again shortly.</p>
) : (
<>
<FinanceSummaryCard
debitCents={financeData?.summary.debitCents ?? 0}
creditCents={financeData?.summary.creditCents ?? 0}
netCents={financeData?.summary.netCents ?? 0}
estimatedDebitCents={financeData?.summary.estimatedDebitCents ?? 0}
eventCount={financeData?.summary.eventCount ?? 0}
/>
<div className="grid gap-4 xl:grid-cols-(--gtc-33)">
<div className="space-y-4">
<Card>
<CardHeader className="px-5 pt-5 pb-2">
<CardTitle className="text-base">By biller</CardTitle>
<CardDescription>Account-level financial events grouped by who charged or credited them.</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 px-5 pb-5 pt-2 md:grid-cols-2">
{(financeData?.byBiller.length ?? 0) === 0 ? (
<p className="text-sm text-muted-foreground">No finance events yet.</p>
) : (
financeData?.byBiller.map((row) => <FinanceBillerCard key={`${row.biller}:${row.currency}`} row={row} />)
)}
</CardContent>
</Card>
<FinanceTimelineCard rows={topFinanceEvents} />
</div>
<FinanceKindCard rows={financeData?.byKind ?? []} />
</div>
</>
)}
</TabsContent>
</Tabs>
</div>
);
}