mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - A stable agent persona is useful only when the same identity appears across the app. > - Lists, task messages, selectors, and activity feeds need inexpensive static avatars. > - Onboarding and agent headers need a larger character with expressions and pointer tracking. > - This pull request connects the persona foundation to those existing views and preserves onboarding draft assignments. > - Full-page stories and Linux checks make the placements and performance contract reviewable. ## Linked Issues or Issue Description **Problem or motivation** Agents need a stable visual identity in lists, tasks, onboarding, and configuration. External tools also need an image URL for that identity. **Proposed solution** Assign each agent a permanent palette from a fixed ClipLab character library. Store the assignment on the agent. Render and cache preset PNG URLs on demand. Use static images in dense views and one animated character in larger placements. **Alternatives considered** A generated image bundle requires a separate asset build. A live renderer in every avatar adds unnecessary work in large lists. Arbitrary uploaded images do not provide the requested shared character system. **Roadmap alignment** This improves agent identity across existing control-plane views. It preserves agent permissions, company boundaries, and status labels. ROADMAP.md has no separate ClipLab persona milestone. Related approaches: #2422 adds configurable image URLs and DiceBear generation; #5578 adds optional uploaded avatars. This work uses a fixed, versioned character library and preset URLs. ## What Changed - Replace agent icons with static persona images across lists, the sidebar, org charts, tasks, comments, selectors, activity, and dashboard views. - Put one animated character in the agent header. Let it follow the pointer across the page, with reduced-motion and touch fallbacks. - Add larger padded characters to agent creation. Keep the palette stable across draft refreshes and connection retries, then reveal it after success. - Pass appearance through shared projections rather than fetching each agent separately. - Add real full-page Storybook examples for the agent list, overview, task, dashboard, new-agent dialog, and connection page. - Add Linux screenshot, clipping, density, and 500-avatar performance checks. ## Verification - `pnpm -r typecheck`, `pnpm build`, and token gates pass on the rebased tree. Persona lifecycle tests pass. - The rebased feature passes 38 Linux screenshot/performance checks, including both display densities, corner pointer positions, and the no-WebGL/no-live-download contract for 500 avatars. - The final Linux persona suite passes all 38 visual, lifecycle, density, and full-page checks using the standard Storybook configuration and real on-demand avatar endpoint. - Final local focused verification: 45 avatar/native-recovery tests pass; UI identity/routine tests, typecheck/build, token gates, and Storybook build pass. - Current-head CI passes: full workspace/server tests, all serialized server groups, typecheck/release checks, build, canary validation, and end-to-end shards. The build passed after retrying a native-runner concurrency-test failure; its three targeted cases also pass locally. - Manual inspection covered stable identities in the app, header placement, full-page mouse tracking, onboarding size, and task/dashboard placements. ### Screenshots Linux captures use synthetic Storybook fixtures. Full-page captures use reduced motion. The live character, mouse tracking, and disposal are checked separately. <details> <summary>Agent overview with the character in its header</summary> <img src="https://raw.githubusercontent.com/paperclipai/paperclip/8c68f42b268ada22b67d79d0fe1bb0a2f84ec25c/screenshots/full-page-agent-overview.png" width="900" alt="Agent overview with the character in its header" /> </details> <details> <summary>Task messages and assignee identity</summary> <img src="https://raw.githubusercontent.com/paperclipai/paperclip/8c68f42b268ada22b67d79d0fe1bb0a2f84ec25c/screenshots/full-page-task.png" width="900" alt="Task messages and assignee identity" /> </details> <details> <summary>Larger onboarding character with room for expressions</summary> <img src="https://raw.githubusercontent.com/paperclipai/paperclip/8c68f42b268ada22b67d79d0fe1bb0a2f84ec25c/screenshots/full-page-meet-your-next-agent.png" width="900" alt="Larger onboarding character with room for expressions" /> </details> <details> <summary>Dashboard agent activity</summary> <img src="https://raw.githubusercontent.com/paperclipai/paperclip/8c68f42b268ada22b67d79d0fe1bb0a2f84ec25c/screenshots/full-page-company-dashboard.png" width="900" alt="Dashboard agent activity" /> </details> ## Risks - This PR depends on #13170, the persona foundation. Merge the foundation first, then retarget this PR to master. - Many placements change from icons to character silhouettes. Human avatars and authoritative agent status labels retain their existing behavior. - Only one character can render live per view. Reduced motion, hidden/offscreen content, touch input, and renderer failures use the defined fallbacks. - The full-page stories use fixture data. They do not contact a real company or complete real provider sign-in. ## Model Used OpenAI Codex, GPT-6 family. The exact model identifier and context window are not exposed in this session. Used code editing, shell execution, browser inspection, and Linux visual testing. ## 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 tests locally and they pass - [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> Co-authored-by: Tonio <tonework@gmail.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1133 lines
49 KiB
TypeScript
1133 lines
49 KiB
TypeScript
import { AgentIdentity } from "@/components/AgentIdentity";
|
|
import { useEffect, useMemo, useRef, useState, type ComponentType } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import type {
|
|
BudgetPolicySummary,
|
|
CostByAgentModel,
|
|
CostByBiller,
|
|
CostByProviderModel,
|
|
CostWindowSpendRow,
|
|
FinanceEvent,
|
|
QuotaWindow,
|
|
} 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 { 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 { 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";
|
|
|
|
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: string;
|
|
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 [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(() => {
|
|
setMainTab(initialTab);
|
|
}, [initialTab]);
|
|
|
|
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;
|
|
|
|
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: (input: {
|
|
scopeType: BudgetPolicySummary["scopeType"];
|
|
scopeId: string;
|
|
amount: number;
|
|
windowKind: BudgetPolicySummary["windowKind"];
|
|
}) =>
|
|
budgetsApi.upsertPolicy(companyId, {
|
|
scopeType: input.scopeType,
|
|
scopeId: input.scopeId,
|
|
amount: input.amount,
|
|
windowKind: input.windowKind,
|
|
}),
|
|
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, from || undefined, to || undefined),
|
|
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,
|
|
});
|
|
|
|
const { data: financeData, isLoading: financeLoading, error: financeError } = useQuery({
|
|
queryKey: [
|
|
queryKeys.financeSummary(companyId, from || undefined, to || undefined),
|
|
queryKeys.financeByBiller(companyId, from || undefined, to || undefined),
|
|
queryKeys.financeByKind(companyId, from || undefined, to || undefined),
|
|
queryKeys.financeEvents(companyId, from || undefined, to || undefined, 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,
|
|
});
|
|
|
|
const [expandedAgents, setExpandedAgents] = useState<Set<string>>(new Set());
|
|
useEffect(() => {
|
|
setExpandedAgents(new Set());
|
|
}, [companyId, from, to]);
|
|
|
|
function toggleAgent(agentId: string) {
|
|
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, 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 } = useQuery({
|
|
queryKey: queryKeys.usageByProvider(companyId, from || undefined, to || undefined),
|
|
queryFn: () => costsApi.byProvider(companyId, from || undefined, to || undefined),
|
|
enabled: !!selectedCompanyId && customReady && (mainTab === "providers" || mainTab === "billers"),
|
|
refetchInterval: 30_000,
|
|
staleTime: 10_000,
|
|
});
|
|
|
|
const { data: billerData } = useQuery({
|
|
queryKey: queryKeys.usageByBiller(companyId, from || undefined, to || undefined),
|
|
queryFn: () => costsApi.byBiller(companyId, from || undefined, to || undefined),
|
|
enabled: !!selectedCompanyId && customReady && mainTab === "billers",
|
|
refetchInterval: 30_000,
|
|
staleTime: 10_000,
|
|
});
|
|
|
|
const { data: weekData } = 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 } = 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 } = useQuery({
|
|
queryKey: queryKeys.usageWindowSpend(companyId),
|
|
queryFn: () => costsApi.windowSpend(companyId),
|
|
enabled: !!selectedCompanyId && mainTab === "providers",
|
|
refetchInterval: 30_000,
|
|
staleTime: 10_000,
|
|
});
|
|
|
|
const { data: quotaData, isLoading: quotaLoading } = useQuery({
|
|
queryKey: queryKeys.usageQuotaWindows(companyId),
|
|
queryFn: () => costsApi.quotaWindows(companyId),
|
|
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.ok && 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 && result.error) map.set(result.provider, result.error);
|
|
}
|
|
return map;
|
|
}, [quotaData]);
|
|
|
|
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 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((sum, row) => sum + row.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 = 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 inferenceTokenTotal =
|
|
(spendData?.byAgent ?? []).reduce(
|
|
(sum, row) => sum + row.inputTokens + row.cachedInputTokens + row.outputTokens,
|
|
0,
|
|
);
|
|
|
|
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 || financeLoading) && customReady;
|
|
const overviewError = spendError ?? financeError;
|
|
return (
|
|
<div className="space-y-6">
|
|
{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>
|
|
)}
|
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
|
Inference spend, platform fees, credits, and live quota windows.
|
|
</p>
|
|
</div>
|
|
|
|
<div 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 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}
|
|
|
|
<div className="grid gap-3 lg:grid-cols-4">
|
|
<MetricTile
|
|
label="Inference spend"
|
|
value={formatCents(spendData?.summary.spendCents ?? 0)}
|
|
subtitle={`${formatTokens(inferenceTokenTotal)} tokens across request-scoped events`}
|
|
icon={DollarSign}
|
|
/>
|
|
<MetricTile
|
|
label="Budget"
|
|
value={activeBudgetIncidents.length > 0 ? String(activeBudgetIncidents.length) : (
|
|
spendData?.summary.budgetCents && spendData.summary.budgetCents > 0
|
|
? `${spendData.summary.utilizationPercent}%`
|
|
: "Open"
|
|
)}
|
|
subtitle={
|
|
activeBudgetIncidents.length > 0
|
|
? `${budgetData?.pausedAgentCount ?? 0} agents paused · ${budgetData?.pausedProjectCount ?? 0} projects paused`
|
|
: spendData?.summary.budgetCents && spendData.summary.budgetCents > 0
|
|
? `${formatCents(spendData.summary.spendCents)} of ${formatCents(spendData.summary.budgetCents)}`
|
|
: "No monthly cap configured"
|
|
}
|
|
icon={Coins}
|
|
/>
|
|
<MetricTile
|
|
label="Finance net"
|
|
value={formatCents(financeData?.summary.netCents ?? 0)}
|
|
subtitle={`${formatCents(financeData?.summary.debitCents ?? 0)} debits · ${formatCents(financeData?.summary.creditCents ?? 0)} credits`}
|
|
icon={ReceiptText}
|
|
/>
|
|
<MetricTile
|
|
label="Finance events"
|
|
value={String(financeData?.summary.eventCount ?? 0)}
|
|
subtitle={`${formatCents(financeData?.summary.estimatedDebitCents ?? 0)} estimated in range`}
|
|
icon={ArrowUpRight}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<Tabs value={mainTab} onValueChange={(value) => setMainTab(value as typeof mainTab)}>
|
|
{!lockTab ? (
|
|
<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>
|
|
</TabsList>
|
|
) : null}
|
|
|
|
<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">{(overviewError as Error).message}</p>
|
|
) : (
|
|
<>
|
|
{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}
|
|
|
|
<div className="grid gap-4 xl:grid-cols-(--gtc-31)">
|
|
<Card>
|
|
<CardHeader className="px-5 pt-5 pb-2">
|
|
<CardTitle className="text-base">Inference ledger</CardTitle>
|
|
<CardDescription>
|
|
Request-scoped inference spend for the selected period.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4 px-5 pb-5 pt-2">
|
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
|
<div>
|
|
<div className="text-3xl font-semibold tabular-nums">
|
|
{formatCents(spendData?.summary.spendCents ?? 0)}
|
|
</div>
|
|
<div className="mt-1 text-sm text-muted-foreground">
|
|
{spendData?.summary.budgetCents && spendData.summary.budgetCents > 0
|
|
? `Budget ${formatCents(spendData.summary.budgetCents)}`
|
|
: "Unlimited budget"}
|
|
</div>
|
|
</div>
|
|
<div className="border border-border px-4 py-3 text-right">
|
|
<div className="text-(length:--text-micro) uppercase tracking-(--tracking-eyebrow) text-muted-foreground">usage</div>
|
|
<div className="mt-1 text-lg font-medium tabular-nums">
|
|
{formatTokens(inferenceTokenTotal)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{spendData?.summary.budgetCents && spendData.summary.budgetCents > 0 ? (
|
|
<div className="space-y-2">
|
|
<div className="h-2 overflow-hidden bg-muted">
|
|
<div
|
|
className={cn(
|
|
"h-full transition-(--tp-width-background-color) duration-150",
|
|
spendData.summary.utilizationPercent > 90
|
|
? "bg-(--status-task-blocked)"
|
|
: spendData.summary.utilizationPercent > 70
|
|
? "bg-(--status-task-todo)"
|
|
: "bg-(--status-task-done)",
|
|
)}
|
|
style={{ width: `${Math.min(100, spendData.summary.utilizationPercent)}%` }}
|
|
/>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{spendData.summary.utilizationPercent}% of monthly budget consumed in this range.
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<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>
|
|
|
|
<div className="grid gap-4 xl:grid-cols-(--gtc-32)">
|
|
<Card>
|
|
<CardHeader className="px-5 pt-5 pb-2">
|
|
<CardTitle className="text-base">By agent</CardTitle>
|
|
<CardDescription>What each agent consumed in the selected period.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2 px-5 pb-5 pt-2">
|
|
{(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} className="border border-border px-4 py-3">
|
|
<div
|
|
className={cn("flex items-start justify-between gap-3", 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" />
|
|
)}
|
|
<AgentIdentity agent={{ id: row.agentId, name: row.agentName ?? row.agentId, appearance: row.agentAppearance }} size="sm" />
|
|
{row.agentStatus === "terminated" ? <StatusBadge status="terminated" /> : null}
|
|
</div>
|
|
<div className="text-right text-sm tabular-nums">
|
|
<div className="font-medium">{formatCents(row.costCents)}</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
in {formatTokens(row.inputTokens + row.cachedInputTokens)} · out {formatTokens(row.outputTokens)}
|
|
</div>
|
|
{(row.apiRunCount > 0 || row.subscriptionRunCount > 0) ? (
|
|
<div className="text-xs text-muted-foreground">
|
|
{row.apiRunCount > 0 ? `${row.apiRunCount} api` : "0 api"}
|
|
{" · "}
|
|
{row.subscriptionRunCount > 0
|
|
? `${row.subscriptionRunCount} subscription`
|
|
: "0 subscription"}
|
|
</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 items-start justify-between gap-3 text-xs"
|
|
>
|
|
<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">
|
|
<div className="font-medium">
|
|
{formatCents(modelRow.costCents)}
|
|
<span className="ml-1 font-normal text-muted-foreground">({sharePct}%)</span>
|
|
</div>
|
|
<div className="text-muted-foreground">
|
|
{formatTokens(modelRow.inputTokens + modelRow.cachedInputTokens + modelRow.outputTokens)} tok
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardHeader className="px-5 pt-5 pb-2">
|
|
<CardTitle className="text-base">By project</CardTitle>
|
|
<CardDescription>Run costs attributed through project-linked tasks.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2 px-5 pb-5 pt-2">
|
|
{(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>
|
|
<span className="font-medium tabular-nums">{formatCents(row.costCents)}</span>
|
|
</div>
|
|
))
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<FinanceTimelineCard rows={topFinanceEvents.slice(0, 6)} emptyMessage="No finance events yet. Add account-level charges once biller invoices or credits land." />
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="budgets" className="mt-4 space-y-4">
|
|
{budgetLoading ? (
|
|
<PageSkeleton variant="costs" />
|
|
) : budgetError ? (
|
|
<p className="text-sm text-destructive">{(budgetError as Error).message}</p>
|
|
) : (
|
|
<>
|
|
<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">
|
|
Resolve hard stops here by raising the budget or explicitly keeping the scope paused.
|
|
</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}
|
|
isSaving={policyMutation.isPending}
|
|
onSave={(amount) =>
|
|
policyMutation.mutate({
|
|
scopeType: summary.scopeType,
|
|
scopeId: summary.scopeId,
|
|
amount,
|
|
windowKind: summary.windowKind,
|
|
})}
|
|
/>
|
|
))}
|
|
</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}
|
|
totalCompanySpendCents={spendData?.summary.spendCents ?? 0}
|
|
weekSpendCents={weekSpendByProvider.get(provider) ?? 0}
|
|
windowRows={windowSpendByProvider.get(provider) ?? []}
|
|
showDeficitNotch={deficitNotchByProvider.get(provider) ?? false}
|
|
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}
|
|
totalCompanySpendCents={spendData?.summary.spendCents ?? 0}
|
|
weekSpendCents={weekSpendByProvider.get(provider) ?? 0}
|
|
windowRows={windowSpendByProvider.get(provider) ?? []}
|
|
showDeficitNotch={deficitNotchByProvider.get(provider) ?? false}
|
|
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}
|
|
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}
|
|
totalCompanySpendCents={spendData?.summary.spendCents ?? 0}
|
|
providerRows={providerRows}
|
|
/>
|
|
</TabsContent>
|
|
);
|
|
})}
|
|
</Tabs>
|
|
</>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="finance" className="mt-4 space-y-4">
|
|
{showCustomPrompt ? (
|
|
<p className="text-sm text-muted-foreground">Select a start and end date to load data.</p>
|
|
) : financeLoading ? (
|
|
<PageSkeleton variant="costs" />
|
|
) : financeError ? (
|
|
<p className="text-sm text-destructive">{(financeError as Error).message}</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={row} />)
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
<FinanceTimelineCard rows={topFinanceEvents} />
|
|
</div>
|
|
|
|
<FinanceKindCard rows={financeData?.byKind ?? []} />
|
|
</div>
|
|
</>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|