Merge public-gh/master into paperclip-company-import-export
This commit is contained in:
@@ -139,6 +139,7 @@ function boardRoutes() {
|
||||
<Route path="projects/:projectId/issues" element={<ProjectDetail />} />
|
||||
<Route path="projects/:projectId/issues/:filter" element={<ProjectDetail />} />
|
||||
<Route path="projects/:projectId/configuration" element={<ProjectDetail />} />
|
||||
<Route path="projects/:projectId/budget" element={<ProjectDetail />} />
|
||||
<Route path="issues" element={<Issues />} />
|
||||
<Route path="issues/all" element={<Navigate to="/issues" replace />} />
|
||||
<Route path="issues/active" element={<Navigate to="/issues" replace />} />
|
||||
|
||||
@@ -11,11 +11,19 @@ export const assetsApi = {
|
||||
const safeFile = new File([buffer], file.name, { type: file.type });
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file", safeFile);
|
||||
if (namespace && namespace.trim().length > 0) {
|
||||
form.append("namespace", namespace.trim());
|
||||
}
|
||||
form.append("file", safeFile);
|
||||
return api.postForm<AssetImage>(`/companies/${companyId}/assets/images`, form);
|
||||
},
|
||||
};
|
||||
|
||||
uploadCompanyLogo: async (companyId: string, file: File) => {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const safeFile = new File([buffer], file.name, { type: file.type });
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file", safeFile);
|
||||
return api.postForm<AssetImage>(`/companies/${companyId}/logo`, form);
|
||||
},
|
||||
};
|
||||
|
||||
20
ui/src/api/budgets.ts
Normal file
20
ui/src/api/budgets.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type {
|
||||
BudgetIncident,
|
||||
BudgetIncidentResolutionInput,
|
||||
BudgetOverview,
|
||||
BudgetPolicySummary,
|
||||
BudgetPolicyUpsertInput,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const budgetsApi = {
|
||||
overview: (companyId: string) =>
|
||||
api.get<BudgetOverview>(`/companies/${companyId}/budgets/overview`),
|
||||
upsertPolicy: (companyId: string, data: BudgetPolicyUpsertInput) =>
|
||||
api.post<BudgetPolicySummary>(`/companies/${companyId}/budgets/policies`, data),
|
||||
resolveIncident: (companyId: string, incidentId: string, data: BudgetIncidentResolutionInput) =>
|
||||
api.post<BudgetIncident>(
|
||||
`/companies/${companyId}/budget-incidents/${encodeURIComponent(incidentId)}/resolve`,
|
||||
data,
|
||||
),
|
||||
};
|
||||
@@ -14,14 +14,18 @@ export const companiesApi = {
|
||||
list: () => api.get<Company[]>("/companies"),
|
||||
get: (companyId: string) => api.get<Company>(`/companies/${companyId}`),
|
||||
stats: () => api.get<CompanyStats>("/companies/stats"),
|
||||
create: (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) =>
|
||||
create: (data: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
budgetMonthlyCents?: number;
|
||||
}) =>
|
||||
api.post<Company>("/companies", data),
|
||||
update: (
|
||||
companyId: string,
|
||||
data: Partial<
|
||||
Pick<
|
||||
Company,
|
||||
"name" | "description" | "status" | "budgetMonthlyCents" | "requireBoardApprovalForNewAgents" | "brandColor"
|
||||
"name" | "description" | "status" | "budgetMonthlyCents" | "requireBoardApprovalForNewAgents" | "brandColor" | "logoAssetId"
|
||||
>
|
||||
>,
|
||||
) => api.patch<Company>(`/companies/${companyId}`, data),
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import type { CostSummary, CostByAgent } from "@paperclipai/shared";
|
||||
import type {
|
||||
CostSummary,
|
||||
CostByAgent,
|
||||
CostByProviderModel,
|
||||
CostByBiller,
|
||||
CostByAgentModel,
|
||||
CostByProject,
|
||||
CostWindowSpendRow,
|
||||
FinanceSummary,
|
||||
FinanceByBiller,
|
||||
FinanceByKind,
|
||||
FinanceEvent,
|
||||
ProviderQuotaResult,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export interface CostByProject {
|
||||
projectId: string | null;
|
||||
projectName: string | null;
|
||||
costCents: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
function dateParams(from?: string, to?: string): string {
|
||||
const params = new URLSearchParams();
|
||||
if (from) params.set("from", from);
|
||||
@@ -22,6 +27,33 @@ export const costsApi = {
|
||||
api.get<CostSummary>(`/companies/${companyId}/costs/summary${dateParams(from, to)}`),
|
||||
byAgent: (companyId: string, from?: string, to?: string) =>
|
||||
api.get<CostByAgent[]>(`/companies/${companyId}/costs/by-agent${dateParams(from, to)}`),
|
||||
byAgentModel: (companyId: string, from?: string, to?: string) =>
|
||||
api.get<CostByAgentModel[]>(`/companies/${companyId}/costs/by-agent-model${dateParams(from, to)}`),
|
||||
byProject: (companyId: string, from?: string, to?: string) =>
|
||||
api.get<CostByProject[]>(`/companies/${companyId}/costs/by-project${dateParams(from, to)}`),
|
||||
byProvider: (companyId: string, from?: string, to?: string) =>
|
||||
api.get<CostByProviderModel[]>(`/companies/${companyId}/costs/by-provider${dateParams(from, to)}`),
|
||||
byBiller: (companyId: string, from?: string, to?: string) =>
|
||||
api.get<CostByBiller[]>(`/companies/${companyId}/costs/by-biller${dateParams(from, to)}`),
|
||||
financeSummary: (companyId: string, from?: string, to?: string) =>
|
||||
api.get<FinanceSummary>(`/companies/${companyId}/costs/finance-summary${dateParams(from, to)}`),
|
||||
financeByBiller: (companyId: string, from?: string, to?: string) =>
|
||||
api.get<FinanceByBiller[]>(`/companies/${companyId}/costs/finance-by-biller${dateParams(from, to)}`),
|
||||
financeByKind: (companyId: string, from?: string, to?: string) =>
|
||||
api.get<FinanceByKind[]>(`/companies/${companyId}/costs/finance-by-kind${dateParams(from, to)}`),
|
||||
financeEvents: (companyId: string, from?: string, to?: string, limit: number = 100) =>
|
||||
api.get<FinanceEvent[]>(`/companies/${companyId}/costs/finance-events${dateParamsWithLimit(from, to, limit)}`),
|
||||
windowSpend: (companyId: string) =>
|
||||
api.get<CostWindowSpendRow[]>(`/companies/${companyId}/costs/window-spend`),
|
||||
quotaWindows: (companyId: string) =>
|
||||
api.get<ProviderQuotaResult[]>(`/companies/${companyId}/costs/quota-windows`),
|
||||
};
|
||||
|
||||
function dateParamsWithLimit(from?: string, to?: string, limit?: number): string {
|
||||
const params = new URLSearchParams();
|
||||
if (from) params.set("from", from);
|
||||
if (to) params.set("to", to);
|
||||
if (limit) params.set("limit", String(limit));
|
||||
const qs = params.toString();
|
||||
return qs ? `?${qs}` : "";
|
||||
}
|
||||
|
||||
69
ui/src/components/AccountingModelCard.tsx
Normal file
69
ui/src/components/AccountingModelCard.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Database, Gauge, ReceiptText } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
|
||||
const SURFACES = [
|
||||
{
|
||||
title: "Inference ledger",
|
||||
description: "Request-scoped usage and billed runs from cost_events.",
|
||||
icon: Database,
|
||||
points: ["tokens + billed dollars", "provider, biller, model", "subscription and overage aware"],
|
||||
tone: "from-sky-500/12 via-sky-500/6 to-transparent",
|
||||
},
|
||||
{
|
||||
title: "Finance ledger",
|
||||
description: "Account-level charges that are not one prompt-response pair.",
|
||||
icon: ReceiptText,
|
||||
points: ["top-ups, refunds, fees", "Bedrock provisioned or training charges", "credit expiries and adjustments"],
|
||||
tone: "from-amber-500/14 via-amber-500/6 to-transparent",
|
||||
},
|
||||
{
|
||||
title: "Live quotas",
|
||||
description: "Provider or biller windows that can stop traffic in real time.",
|
||||
icon: Gauge,
|
||||
points: ["provider quota windows", "biller credit systems", "errors surfaced directly"],
|
||||
tone: "from-emerald-500/14 via-emerald-500/6 to-transparent",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function AccountingModelCard() {
|
||||
return (
|
||||
<Card className="relative overflow-hidden border-border/70">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(244,114,182,0.08),transparent_35%),radial-gradient(circle_at_bottom_right,rgba(56,189,248,0.1),transparent_32%)]" />
|
||||
<CardHeader className="relative px-5 pt-5 pb-2">
|
||||
<CardTitle className="text-sm font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
||||
Accounting model
|
||||
</CardTitle>
|
||||
<CardDescription className="max-w-2xl text-sm leading-6">
|
||||
Paperclip now separates request-level inference usage from account-level finance events.
|
||||
That keeps provider reporting honest when the biller is OpenRouter, Cloudflare, Bedrock, or another intermediary.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="relative grid gap-3 px-5 pb-5 md:grid-cols-3">
|
||||
{SURFACES.map((surface) => {
|
||||
const Icon = surface.icon;
|
||||
return (
|
||||
<div
|
||||
key={surface.title}
|
||||
className={`rounded-2xl border border-border/70 bg-gradient-to-br ${surface.tone} p-4 shadow-sm`}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-border/70 bg-background/80">
|
||||
<Icon className="h-4 w-4 text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold">{surface.title}</div>
|
||||
<div className="text-xs text-muted-foreground">{surface.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-xs text-muted-foreground">
|
||||
{surface.points.map((point) => (
|
||||
<div key={point}>{point}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,9 @@ export function ApprovalCard({
|
||||
}) {
|
||||
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
|
||||
const label = typeLabel[approval.type] ?? approval.type;
|
||||
const showResolutionButtons =
|
||||
approval.type !== "budget_override_required" &&
|
||||
(approval.status === "pending" || approval.status === "revision_requested");
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg p-4 space-y-0">
|
||||
@@ -67,7 +70,7 @@ export function ApprovalCard({
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{(approval.status === "pending" || approval.status === "revision_requested") && (
|
||||
{showResolutionButtons && (
|
||||
<div className="flex gap-2 mt-4 pt-3 border-t border-border">
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { UserPlus, Lightbulb, ShieldCheck } from "lucide-react";
|
||||
import { UserPlus, Lightbulb, ShieldAlert, ShieldCheck } from "lucide-react";
|
||||
import { formatCents } from "../lib/utils";
|
||||
|
||||
export const typeLabel: Record<string, string> = {
|
||||
hire_agent: "Hire Agent",
|
||||
approve_ceo_strategy: "CEO Strategy",
|
||||
budget_override_required: "Budget Override",
|
||||
};
|
||||
|
||||
export const typeIcon: Record<string, typeof UserPlus> = {
|
||||
hire_agent: UserPlus,
|
||||
approve_ceo_strategy: Lightbulb,
|
||||
budget_override_required: ShieldAlert,
|
||||
};
|
||||
|
||||
export const defaultTypeIcon = ShieldCheck;
|
||||
@@ -69,7 +72,28 @@ export function CeoStrategyPayload({ payload }: { payload: Record<string, unknow
|
||||
);
|
||||
}
|
||||
|
||||
export function BudgetOverridePayload({ payload }: { payload: Record<string, unknown> }) {
|
||||
const budgetAmount = typeof payload.budgetAmount === "number" ? payload.budgetAmount : null;
|
||||
const observedAmount = typeof payload.observedAmount === "number" ? payload.observedAmount : null;
|
||||
return (
|
||||
<div className="mt-3 space-y-1.5 text-sm">
|
||||
<PayloadField label="Scope" value={payload.scopeName ?? payload.scopeType} />
|
||||
<PayloadField label="Window" value={payload.windowKind} />
|
||||
<PayloadField label="Metric" value={payload.metric} />
|
||||
{(budgetAmount !== null || observedAmount !== null) ? (
|
||||
<div className="rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
||||
Limit {budgetAmount !== null ? formatCents(budgetAmount) : "—"} · Observed {observedAmount !== null ? formatCents(observedAmount) : "—"}
|
||||
</div>
|
||||
) : null}
|
||||
{!!payload.guidance && (
|
||||
<p className="text-muted-foreground">{String(payload.guidance)}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApprovalPayloadRenderer({ type, payload }: { type: string; payload: Record<string, unknown> }) {
|
||||
if (type === "hire_agent") return <HireAgentPayload payload={payload} />;
|
||||
if (type === "budget_override_required") return <BudgetOverridePayload payload={payload} />;
|
||||
return <CeoStrategyPayload payload={payload} />;
|
||||
}
|
||||
|
||||
145
ui/src/components/BillerSpendCard.tsx
Normal file
145
ui/src/components/BillerSpendCard.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useMemo } from "react";
|
||||
import type { CostByBiller, CostByProviderModel } from "@paperclipai/shared";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { QuotaBar } from "./QuotaBar";
|
||||
import { billingTypeDisplayName, formatCents, formatTokens, providerDisplayName } from "@/lib/utils";
|
||||
|
||||
interface BillerSpendCardProps {
|
||||
row: CostByBiller;
|
||||
weekSpendCents: number;
|
||||
budgetMonthlyCents: number;
|
||||
totalCompanySpendCents: number;
|
||||
providerRows: CostByProviderModel[];
|
||||
}
|
||||
|
||||
export function BillerSpendCard({
|
||||
row,
|
||||
weekSpendCents,
|
||||
budgetMonthlyCents,
|
||||
totalCompanySpendCents,
|
||||
providerRows,
|
||||
}: BillerSpendCardProps) {
|
||||
const providerBreakdown = useMemo(() => {
|
||||
const map = new Map<string, { provider: string; costCents: number; inputTokens: number; outputTokens: number }>();
|
||||
for (const entry of providerRows) {
|
||||
const current = map.get(entry.provider) ?? {
|
||||
provider: entry.provider,
|
||||
costCents: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
};
|
||||
current.costCents += entry.costCents;
|
||||
current.inputTokens += entry.inputTokens + entry.cachedInputTokens;
|
||||
current.outputTokens += entry.outputTokens;
|
||||
map.set(entry.provider, current);
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => b.costCents - a.costCents);
|
||||
}, [providerRows]);
|
||||
|
||||
const billingTypeBreakdown = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const entry of providerRows) {
|
||||
map.set(entry.billingType, (map.get(entry.billingType) ?? 0) + entry.costCents);
|
||||
}
|
||||
return Array.from(map.entries()).sort((a, b) => b[1] - a[1]);
|
||||
}, [providerRows]);
|
||||
|
||||
const providerBudgetShare =
|
||||
budgetMonthlyCents > 0 && totalCompanySpendCents > 0
|
||||
? (row.costCents / totalCompanySpendCents) * budgetMonthlyCents
|
||||
: budgetMonthlyCents;
|
||||
const budgetPct =
|
||||
providerBudgetShare > 0
|
||||
? Math.min(100, (row.costCents / providerBudgetShare) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-4 pb-0 gap-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-sm font-semibold">
|
||||
{providerDisplayName(row.biller)}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs mt-0.5">
|
||||
<span className="font-mono">{formatTokens(row.inputTokens + row.cachedInputTokens)}</span> in
|
||||
{" · "}
|
||||
<span className="font-mono">{formatTokens(row.outputTokens)}</span> out
|
||||
{" · "}
|
||||
{row.providerCount} provider{row.providerCount === 1 ? "" : "s"}
|
||||
{" · "}
|
||||
{row.modelCount} model{row.modelCount === 1 ? "" : "s"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<span className="text-xl font-bold tabular-nums shrink-0">
|
||||
{formatCents(row.costCents)}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-4 pb-4 pt-3 space-y-4">
|
||||
{budgetMonthlyCents > 0 && (
|
||||
<QuotaBar
|
||||
label="Period spend"
|
||||
percentUsed={budgetPct}
|
||||
leftLabel={formatCents(row.costCents)}
|
||||
rightLabel={`${Math.round(budgetPct)}% of allocation`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{row.apiRunCount > 0 ? `${row.apiRunCount} metered run${row.apiRunCount === 1 ? "" : "s"}` : "0 metered runs"}
|
||||
{" · "}
|
||||
{row.subscriptionRunCount > 0
|
||||
? `${row.subscriptionRunCount} subscription run${row.subscriptionRunCount === 1 ? "" : "s"}`
|
||||
: "0 subscription runs"}
|
||||
{" · "}
|
||||
{formatCents(weekSpendCents)} this week
|
||||
</div>
|
||||
|
||||
{billingTypeBreakdown.length > 0 && (
|
||||
<>
|
||||
<div className="border-t border-border" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Billing types
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{billingTypeBreakdown.map(([billingType, costCents]) => (
|
||||
<div key={billingType} className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{billingTypeDisplayName(billingType as any)}</span>
|
||||
<span className="font-medium tabular-nums">{formatCents(costCents)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{providerBreakdown.length > 0 && (
|
||||
<>
|
||||
<div className="border-t border-border" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Upstream providers
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{providerBreakdown.map((entry) => (
|
||||
<div key={entry.provider} className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{providerDisplayName(entry.provider)}</span>
|
||||
<div className="text-right tabular-nums">
|
||||
<div className="font-medium">{formatCents(entry.costCents)}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{formatTokens(entry.inputTokens + entry.outputTokens)} tok
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
100
ui/src/components/BudgetIncidentCard.tsx
Normal file
100
ui/src/components/BudgetIncidentCard.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useState } from "react";
|
||||
import type { BudgetIncident } from "@paperclipai/shared";
|
||||
import { AlertOctagon, ArrowUpRight, PauseCircle } from "lucide-react";
|
||||
import { formatCents } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
function centsInputValue(value: number) {
|
||||
return (value / 100).toFixed(2);
|
||||
}
|
||||
|
||||
function parseDollarInput(value: string) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return null;
|
||||
return Math.round(parsed * 100);
|
||||
}
|
||||
|
||||
export function BudgetIncidentCard({
|
||||
incident,
|
||||
onRaiseAndResume,
|
||||
onKeepPaused,
|
||||
isMutating,
|
||||
}: {
|
||||
incident: BudgetIncident;
|
||||
onRaiseAndResume: (amountCents: number) => void;
|
||||
onKeepPaused: () => void;
|
||||
isMutating?: boolean;
|
||||
}) {
|
||||
const [draftAmount, setDraftAmount] = useState(
|
||||
centsInputValue(Math.max(incident.amountObserved + 1000, incident.amountLimit)),
|
||||
);
|
||||
const parsed = parseDollarInput(draftAmount);
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden border-red-500/20 bg-[linear-gradient(180deg,rgba(255,70,70,0.10),rgba(255,255,255,0.02))]">
|
||||
<CardHeader className="px-5 pt-5 pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-[0.22em] text-red-200/80">
|
||||
{incident.scopeType} hard stop
|
||||
</div>
|
||||
<CardTitle className="mt-1 text-base text-red-50">{incident.scopeName}</CardTitle>
|
||||
<CardDescription className="mt-1 text-red-100/70">
|
||||
Spending reached {formatCents(incident.amountObserved)} against a limit of {formatCents(incident.amountLimit)}.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="rounded-full border border-red-400/30 bg-red-500/10 p-2 text-red-200">
|
||||
<AlertOctagon className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 px-5 pb-5 pt-0">
|
||||
<div className="flex items-start gap-2 rounded-xl border border-red-400/20 bg-red-500/10 px-3 py-2 text-sm text-red-50/90">
|
||||
<PauseCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
{incident.scopeType === "project"
|
||||
? "Project execution is paused. New work in this project will not start until you resolve the budget incident."
|
||||
: "This scope is paused. New heartbeats will not start until you resolve the budget incident."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/60 bg-background/60 p-3">
|
||||
<label className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
New budget (USD)
|
||||
</label>
|
||||
<div className="mt-2 flex flex-col gap-3 sm:flex-row">
|
||||
<Input
|
||||
value={draftAmount}
|
||||
onChange={(event) => setDraftAmount(event.target.value)}
|
||||
inputMode="decimal"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={isMutating || parsed === null || parsed <= incident.amountObserved}
|
||||
onClick={() => {
|
||||
if (typeof parsed === "number") onRaiseAndResume(parsed);
|
||||
}}
|
||||
>
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
{isMutating ? "Applying..." : "Raise budget & resume"}
|
||||
</Button>
|
||||
</div>
|
||||
{parsed !== null && parsed <= incident.amountObserved ? (
|
||||
<p className="mt-2 text-xs text-red-200/80">
|
||||
The new budget must exceed current observed spend.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" className="text-muted-foreground" disabled={isMutating} onClick={onKeepPaused}>
|
||||
Keep paused
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
219
ui/src/components/BudgetPolicyCard.tsx
Normal file
219
ui/src/components/BudgetPolicyCard.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { BudgetPolicySummary } from "@paperclipai/shared";
|
||||
import { AlertTriangle, PauseCircle, ShieldAlert, Wallet } from "lucide-react";
|
||||
import { cn, formatCents } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
function centsInputValue(value: number) {
|
||||
return (value / 100).toFixed(2);
|
||||
}
|
||||
|
||||
function parseDollarInput(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (normalized.length === 0) return 0;
|
||||
const parsed = Number(normalized);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return null;
|
||||
return Math.round(parsed * 100);
|
||||
}
|
||||
|
||||
function windowLabel(windowKind: BudgetPolicySummary["windowKind"]) {
|
||||
return windowKind === "lifetime" ? "Lifetime budget" : "Monthly UTC budget";
|
||||
}
|
||||
|
||||
function statusTone(status: BudgetPolicySummary["status"]) {
|
||||
if (status === "hard_stop") return "text-red-300 border-red-500/30 bg-red-500/10";
|
||||
if (status === "warning") return "text-amber-200 border-amber-500/30 bg-amber-500/10";
|
||||
return "text-emerald-200 border-emerald-500/30 bg-emerald-500/10";
|
||||
}
|
||||
|
||||
export function BudgetPolicyCard({
|
||||
summary,
|
||||
onSave,
|
||||
isSaving,
|
||||
compact = false,
|
||||
variant = "card",
|
||||
}: {
|
||||
summary: BudgetPolicySummary;
|
||||
onSave?: (amountCents: number) => void;
|
||||
isSaving?: boolean;
|
||||
compact?: boolean;
|
||||
variant?: "card" | "plain";
|
||||
}) {
|
||||
const [draftBudget, setDraftBudget] = useState(centsInputValue(summary.amount));
|
||||
|
||||
useEffect(() => {
|
||||
setDraftBudget(centsInputValue(summary.amount));
|
||||
}, [summary.amount]);
|
||||
|
||||
const parsedDraft = parseDollarInput(draftBudget);
|
||||
const canSave = typeof parsedDraft === "number" && parsedDraft !== summary.amount && Boolean(onSave);
|
||||
const progress = summary.amount > 0 ? Math.min(100, summary.utilizationPercent) : 0;
|
||||
const StatusIcon = summary.status === "hard_stop" ? ShieldAlert : summary.status === "warning" ? AlertTriangle : Wallet;
|
||||
const isPlain = variant === "plain";
|
||||
|
||||
const observedBudgetGrid = isPlain ? (
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Observed</div>
|
||||
<div className="mt-2 text-xl font-semibold tabular-nums">{formatCents(summary.observedAmount)}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{summary.amount > 0 ? `${summary.utilizationPercent}% of limit` : "No cap configured"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Budget</div>
|
||||
<div className="mt-2 text-xl font-semibold tabular-nums">
|
||||
{summary.amount > 0 ? formatCents(summary.amount) : "Disabled"}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Soft alert at {summary.warnPercent}%{summary.paused && summary.pauseReason ? ` · ${summary.pauseReason} pause` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-border/70 bg-black/[0.18] px-4 py-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Observed</div>
|
||||
<div className="mt-2 text-xl font-semibold tabular-nums">{formatCents(summary.observedAmount)}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{summary.amount > 0 ? `${summary.utilizationPercent}% of limit` : "No cap configured"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/70 bg-black/[0.18] px-4 py-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Budget</div>
|
||||
<div className="mt-2 text-xl font-semibold tabular-nums">
|
||||
{summary.amount > 0 ? formatCents(summary.amount) : "Disabled"}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Soft alert at {summary.warnPercent}%{summary.paused && summary.pauseReason ? ` · ${summary.pauseReason} pause` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const progressSection = (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Remaining</span>
|
||||
<span>{summary.amount > 0 ? formatCents(summary.remainingAmount) : "Unlimited"}</span>
|
||||
</div>
|
||||
<div className={cn("h-2 overflow-hidden rounded-full", isPlain ? "bg-border/70" : "bg-muted/70")}>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-[width,background-color] duration-200",
|
||||
summary.status === "hard_stop"
|
||||
? "bg-red-400"
|
||||
: summary.status === "warning"
|
||||
? "bg-amber-300"
|
||||
: "bg-emerald-300",
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const pausedPane = summary.paused ? (
|
||||
<div className="flex items-start gap-2 rounded-xl border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-100">
|
||||
<PauseCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
{summary.scopeType === "project"
|
||||
? "Execution is paused for this project until the budget is raised or the incident is dismissed."
|
||||
: "Heartbeats are paused for this scope until the budget is raised or the incident is dismissed."}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const saveSection = onSave ? (
|
||||
<div className={cn("flex flex-col gap-3 sm:flex-row sm:items-end", isPlain ? "" : "rounded-xl border border-border/70 bg-background/50 p-3")}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<label className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Budget (USD)
|
||||
</label>
|
||||
<Input
|
||||
value={draftBudget}
|
||||
onChange={(event) => setDraftBudget(event.target.value)}
|
||||
className="mt-2"
|
||||
inputMode="decimal"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (typeof parsedDraft === "number" && onSave) onSave(parsedDraft);
|
||||
}}
|
||||
disabled={!canSave || isSaving || parsedDraft === null}
|
||||
>
|
||||
{isSaving ? "Saving..." : summary.amount > 0 ? "Update budget" : "Set budget"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (isPlain) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
|
||||
{summary.scopeType}
|
||||
</div>
|
||||
<div className="mt-2 text-xl font-semibold">{summary.scopeName}</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">{windowLabel(summary.windowKind)}</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 text-[11px] uppercase tracking-[0.18em]",
|
||||
summary.status === "hard_stop"
|
||||
? "text-red-300"
|
||||
: summary.status === "warning"
|
||||
? "text-amber-200"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<StatusIcon className="h-3.5 w-3.5" />
|
||||
{summary.paused ? "Paused" : summary.status === "warning" ? "Warning" : summary.status === "hard_stop" ? "Hard stop" : "Healthy"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{observedBudgetGrid}
|
||||
{progressSection}
|
||||
{pausedPane}
|
||||
{saveSection}
|
||||
{parsedDraft === null ? (
|
||||
<p className="text-xs text-destructive">Enter a valid non-negative dollar amount.</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn("overflow-hidden border-border/70 bg-card/80", compact ? "" : "shadow-[0_20px_80px_-40px_rgba(0,0,0,0.55)]")}>
|
||||
<CardHeader className={cn("gap-3", compact ? "px-4 pt-4 pb-2" : "px-5 pt-5 pb-3")}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
|
||||
{summary.scopeType}
|
||||
</div>
|
||||
<CardTitle className="mt-1 text-base">{summary.scopeName}</CardTitle>
|
||||
<CardDescription className="mt-1">{windowLabel(summary.windowKind)}</CardDescription>
|
||||
</div>
|
||||
<div className={cn("inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[11px] uppercase tracking-[0.18em]", statusTone(summary.status))}>
|
||||
<StatusIcon className="h-3.5 w-3.5" />
|
||||
{summary.paused ? "Paused" : summary.status === "warning" ? "Warning" : summary.status === "hard_stop" ? "Hard stop" : "Healthy"}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className={cn("space-y-4", compact ? "px-4 pb-4 pt-0" : "px-5 pb-5 pt-0")}>
|
||||
{observedBudgetGrid}
|
||||
{progressSection}
|
||||
{pausedPane}
|
||||
{saveSection}
|
||||
{parsedDraft === null ? (
|
||||
<p className="text-xs text-destructive">Enter a valid non-negative dollar amount.</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
13
ui/src/components/BudgetSidebarMarker.tsx
Normal file
13
ui/src/components/BudgetSidebarMarker.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { DollarSign } from "lucide-react";
|
||||
|
||||
export function BudgetSidebarMarker({ title = "Paused by budget" }: { title?: string }) {
|
||||
return (
|
||||
<span
|
||||
title={title}
|
||||
aria-label={title}
|
||||
className="ml-auto inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-red-500/90 text-white shadow-[0_0_0_1px_rgba(255,255,255,0.08)]"
|
||||
>
|
||||
<DollarSign className="h-3 w-3" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
140
ui/src/components/ClaudeSubscriptionPanel.tsx
Normal file
140
ui/src/components/ClaudeSubscriptionPanel.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { QuotaWindow } from "@paperclipai/shared";
|
||||
import { cn, quotaSourceDisplayName } from "@/lib/utils";
|
||||
|
||||
interface ClaudeSubscriptionPanelProps {
|
||||
windows: QuotaWindow[];
|
||||
source?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
const WINDOW_ORDER = [
|
||||
"currentsession",
|
||||
"currentweekallmodels",
|
||||
"currentweeksonnetonly",
|
||||
"currentweeksonnet",
|
||||
"currentweekopusonly",
|
||||
"currentweekopus",
|
||||
"extrausage",
|
||||
] as const;
|
||||
|
||||
function normalizeLabel(text: string): string {
|
||||
return text.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
function detailText(window: QuotaWindow): string | null {
|
||||
if (typeof window.detail === "string" && window.detail.trim().length > 0) return window.detail.trim();
|
||||
if (window.resetsAt) {
|
||||
const formatted = new Date(window.resetsAt).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
});
|
||||
return `Resets ${formatted}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function orderedWindows(windows: QuotaWindow[]): QuotaWindow[] {
|
||||
return [...windows].sort((a, b) => {
|
||||
const aIndex = WINDOW_ORDER.indexOf(normalizeLabel(a.label) as (typeof WINDOW_ORDER)[number]);
|
||||
const bIndex = WINDOW_ORDER.indexOf(normalizeLabel(b.label) as (typeof WINDOW_ORDER)[number]);
|
||||
return (aIndex === -1 ? WINDOW_ORDER.length : aIndex) - (bIndex === -1 ? WINDOW_ORDER.length : bIndex);
|
||||
});
|
||||
}
|
||||
|
||||
function fillClass(usedPercent: number | null): string {
|
||||
if (usedPercent == null) return "bg-zinc-700";
|
||||
if (usedPercent >= 90) return "bg-red-400";
|
||||
if (usedPercent >= 70) return "bg-amber-400";
|
||||
return "bg-primary/70";
|
||||
}
|
||||
|
||||
export function ClaudeSubscriptionPanel({
|
||||
windows,
|
||||
source = null,
|
||||
error = null,
|
||||
}: ClaudeSubscriptionPanelProps) {
|
||||
const ordered = orderedWindows(windows);
|
||||
|
||||
return (
|
||||
<div className="border border-border px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Anthropic subscription
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
Live Claude quota windows.
|
||||
</div>
|
||||
</div>
|
||||
{source ? (
|
||||
<span className="shrink-0 border border-border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{quotaSourceDisplayName(source)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{ordered.map((window) => {
|
||||
const normalized = normalizeLabel(window.label);
|
||||
const detail = detailText(window);
|
||||
if (normalized === "extrausage") {
|
||||
return (
|
||||
<div
|
||||
key={window.label}
|
||||
className="border border-border px-3.5 py-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium text-foreground">{window.label}</div>
|
||||
{window.valueLabel ? (
|
||||
<div className="text-sm font-medium text-foreground">{window.valueLabel}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{detail ? (
|
||||
<div className="mt-2 text-sm text-muted-foreground">{detail}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const width = Math.min(100, Math.max(0, window.usedPercent ?? 0));
|
||||
return (
|
||||
<div
|
||||
key={window.label}
|
||||
className="border border-border px-3.5 py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">{window.label}</div>
|
||||
{detail ? (
|
||||
<div className="mt-1 text-xs text-muted-foreground">{detail}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{window.usedPercent != null ? (
|
||||
<div className="shrink-0 text-sm font-semibold tabular-nums text-foreground">
|
||||
{window.usedPercent}% used
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 h-2 overflow-hidden bg-muted">
|
||||
<div
|
||||
className={cn("h-full transition-[width] duration-200", fillClass(window.usedPercent))}
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
ui/src/components/CodexSubscriptionPanel.tsx
Normal file
157
ui/src/components/CodexSubscriptionPanel.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { QuotaWindow } from "@paperclipai/shared";
|
||||
import { cn, quotaSourceDisplayName } from "@/lib/utils";
|
||||
|
||||
interface CodexSubscriptionPanelProps {
|
||||
windows: QuotaWindow[];
|
||||
source?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
const WINDOW_PRIORITY = [
|
||||
"5hlimit",
|
||||
"weeklylimit",
|
||||
"credits",
|
||||
] as const;
|
||||
|
||||
function normalizeLabel(text: string): string {
|
||||
return text.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
function orderedWindows(windows: QuotaWindow[]): QuotaWindow[] {
|
||||
return [...windows].sort((a, b) => {
|
||||
const aBase = normalizeLabel(a.label).replace(/^gpt53codexspark/, "");
|
||||
const bBase = normalizeLabel(b.label).replace(/^gpt53codexspark/, "");
|
||||
const aIndex = WINDOW_PRIORITY.indexOf(aBase as (typeof WINDOW_PRIORITY)[number]);
|
||||
const bIndex = WINDOW_PRIORITY.indexOf(bBase as (typeof WINDOW_PRIORITY)[number]);
|
||||
return (aIndex === -1 ? WINDOW_PRIORITY.length : aIndex) - (bIndex === -1 ? WINDOW_PRIORITY.length : bIndex);
|
||||
});
|
||||
}
|
||||
|
||||
function detailText(window: QuotaWindow): string | null {
|
||||
if (typeof window.detail === "string" && window.detail.trim().length > 0) return window.detail.trim();
|
||||
if (!window.resetsAt) return null;
|
||||
const formatted = new Date(window.resetsAt).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
});
|
||||
return `Resets ${formatted}`;
|
||||
}
|
||||
|
||||
function fillClass(usedPercent: number | null): string {
|
||||
if (usedPercent == null) return "bg-zinc-700";
|
||||
if (usedPercent >= 90) return "bg-red-400";
|
||||
if (usedPercent >= 70) return "bg-amber-400";
|
||||
return "bg-primary/70";
|
||||
}
|
||||
|
||||
function isModelSpecific(label: string): boolean {
|
||||
const normalized = normalizeLabel(label);
|
||||
return normalized.includes("gpt53codexspark") || normalized.includes("gpt5");
|
||||
}
|
||||
|
||||
export function CodexSubscriptionPanel({
|
||||
windows,
|
||||
source = null,
|
||||
error = null,
|
||||
}: CodexSubscriptionPanelProps) {
|
||||
const ordered = orderedWindows(windows);
|
||||
const accountWindows = ordered.filter((window) => !isModelSpecific(window.label));
|
||||
const modelWindows = ordered.filter((window) => isModelSpecific(window.label));
|
||||
|
||||
return (
|
||||
<div className="border border-border px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Codex subscription
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
Live Codex quota windows.
|
||||
</div>
|
||||
</div>
|
||||
{source ? (
|
||||
<span className="shrink-0 border border-border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{quotaSourceDisplayName(source)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 space-y-5">
|
||||
<div className="space-y-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Account windows
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{accountWindows.map((window) => (
|
||||
<QuotaWindowRow key={window.label} window={window} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modelWindows.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Model windows
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{modelWindows.map((window) => (
|
||||
<QuotaWindowRow key={window.label} window={window} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotaWindowRow({ window }: { window: QuotaWindow }) {
|
||||
const detail = detailText(window);
|
||||
if (window.usedPercent == null) {
|
||||
return (
|
||||
<div className="border border-border px-3.5 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium text-foreground">{window.label}</div>
|
||||
{window.valueLabel ? (
|
||||
<div className="text-sm font-semibold tabular-nums text-foreground">{window.valueLabel}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{detail ? (
|
||||
<div className="mt-2 text-xs text-muted-foreground">{detail}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-border px-3.5 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">{window.label}</div>
|
||||
{detail ? (
|
||||
<div className="mt-1 text-xs text-muted-foreground">{detail}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="shrink-0 text-sm font-semibold tabular-nums text-foreground">
|
||||
{window.usedPercent}% used
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 h-2 overflow-hidden bg-muted">
|
||||
<div
|
||||
className={cn("h-full transition-[width] duration-200", fillClass(window.usedPercent))}
|
||||
style={{ width: `${Math.max(0, Math.min(100, window.usedPercent))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
const BAYER_4X4 = [
|
||||
@@ -10,6 +10,7 @@ const BAYER_4X4 = [
|
||||
|
||||
interface CompanyPatternIconProps {
|
||||
companyName: string;
|
||||
logoUrl?: string | null;
|
||||
brandColor?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
@@ -159,8 +160,18 @@ function makeCompanyPatternDataUrl(seed: string, brandColor?: string | null, log
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
export function CompanyPatternIcon({ companyName, brandColor, className }: CompanyPatternIconProps) {
|
||||
export function CompanyPatternIcon({
|
||||
companyName,
|
||||
logoUrl,
|
||||
brandColor,
|
||||
className,
|
||||
}: CompanyPatternIconProps) {
|
||||
const initial = companyName.trim().charAt(0).toUpperCase() || "?";
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const logo = !imageError && typeof logoUrl === "string" && logoUrl.trim().length > 0 ? logoUrl : null;
|
||||
useEffect(() => {
|
||||
setImageError(false);
|
||||
}, [logoUrl]);
|
||||
const patternDataUrl = useMemo(
|
||||
() => makeCompanyPatternDataUrl(companyName.trim().toLowerCase(), brandColor),
|
||||
[companyName, brandColor],
|
||||
@@ -173,7 +184,14 @@ export function CompanyPatternIcon({ companyName, brandColor, className }: Compa
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{patternDataUrl ? (
|
||||
{logo ? (
|
||||
<img
|
||||
src={logo}
|
||||
alt={`${companyName} logo`}
|
||||
onError={() => setImageError(true)}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : patternDataUrl ? (
|
||||
<img
|
||||
src={patternDataUrl}
|
||||
alt=""
|
||||
@@ -184,9 +202,11 @@ export function CompanyPatternIcon({ companyName, brandColor, className }: Compa
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-muted" />
|
||||
)}
|
||||
<span className="relative z-10 drop-shadow-[0_1px_2px_rgba(0,0,0,0.65)]">
|
||||
{initial}
|
||||
</span>
|
||||
{!logo && (
|
||||
<span className="relative z-10 drop-shadow-[0_1px_2px_rgba(0,0,0,0.65)]">
|
||||
{initial}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ function SortableCompanyItem({
|
||||
>
|
||||
<CompanyPatternIcon
|
||||
companyName={company.name}
|
||||
logoUrl={company.logoUrl}
|
||||
brandColor={company.brandColor}
|
||||
className={cn(
|
||||
isSelected
|
||||
|
||||
44
ui/src/components/FinanceBillerCard.tsx
Normal file
44
ui/src/components/FinanceBillerCard.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { FinanceByBiller } from "@paperclipai/shared";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { formatCents, providerDisplayName } from "@/lib/utils";
|
||||
|
||||
interface FinanceBillerCardProps {
|
||||
row: FinanceByBiller;
|
||||
}
|
||||
|
||||
export function FinanceBillerCard({ row }: FinanceBillerCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-4 pb-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base">{providerDisplayName(row.biller)}</CardTitle>
|
||||
<CardDescription className="mt-1 text-xs">
|
||||
{row.eventCount} event{row.eventCount === 1 ? "" : "s"} across {row.kindCount} kind{row.kindCount === 1 ? "" : "s"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-semibold tabular-nums">{formatCents(row.netCents)}</div>
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">net</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 px-4 pb-4 pt-3">
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-3">
|
||||
<div className="border border-border p-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">debits</div>
|
||||
<div className="mt-1 font-medium tabular-nums">{formatCents(row.debitCents)}</div>
|
||||
</div>
|
||||
<div className="border border-border p-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">credits</div>
|
||||
<div className="mt-1 font-medium tabular-nums">{formatCents(row.creditCents)}</div>
|
||||
</div>
|
||||
<div className="border border-border p-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">estimated</div>
|
||||
<div className="mt-1 font-medium tabular-nums">{formatCents(row.estimatedDebitCents)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
43
ui/src/components/FinanceKindCard.tsx
Normal file
43
ui/src/components/FinanceKindCard.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { FinanceByKind } from "@paperclipai/shared";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { financeEventKindDisplayName, formatCents } from "@/lib/utils";
|
||||
|
||||
interface FinanceKindCardProps {
|
||||
rows: FinanceByKind[];
|
||||
}
|
||||
|
||||
export function FinanceKindCard({ rows }: FinanceKindCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-4 pb-1">
|
||||
<CardTitle className="text-base">Financial event mix</CardTitle>
|
||||
<CardDescription>Account-level charges grouped by event kind.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 px-4 pb-4 pt-3">
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No finance events in this period.</p>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<div
|
||||
key={row.eventKind}
|
||||
className="flex items-center justify-between gap-3 border border-border px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{financeEventKindDisplayName(row.eventKind)}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{row.eventCount} event{row.eventCount === 1 ? "" : "s"} · {row.billerCount} biller{row.billerCount === 1 ? "" : "s"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right tabular-nums">
|
||||
<div className="text-sm font-medium">{formatCents(row.netCents)}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatCents(row.debitCents)} debits
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
71
ui/src/components/FinanceTimelineCard.tsx
Normal file
71
ui/src/components/FinanceTimelineCard.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { FinanceEvent } from "@paperclipai/shared";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
financeDirectionDisplayName,
|
||||
financeEventKindDisplayName,
|
||||
formatCents,
|
||||
formatDateTime,
|
||||
providerDisplayName,
|
||||
} from "@/lib/utils";
|
||||
|
||||
interface FinanceTimelineCardProps {
|
||||
rows: FinanceEvent[];
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
export function FinanceTimelineCard({
|
||||
rows,
|
||||
emptyMessage = "No financial events in this period.",
|
||||
}: FinanceTimelineCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-4 pb-1">
|
||||
<CardTitle className="text-base">Recent financial events</CardTitle>
|
||||
<CardDescription>Top-ups, fees, credits, commitments, and other non-request charges.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 px-4 pb-4 pt-3">
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="border border-border p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{financeEventKindDisplayName(row.eventKind)}</Badge>
|
||||
<Badge variant={row.direction === "credit" ? "outline" : "secondary"}>
|
||||
{financeDirectionDisplayName(row.direction)}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">{formatDateTime(row.occurredAt)}</span>
|
||||
</div>
|
||||
<div className="text-sm font-medium">
|
||||
{providerDisplayName(row.biller)}
|
||||
{row.provider ? ` -> ${providerDisplayName(row.provider)}` : ""}
|
||||
{row.model ? <span className="ml-1 font-mono text-xs text-muted-foreground">{row.model}</span> : null}
|
||||
</div>
|
||||
{(row.description || row.externalInvoiceId || row.region || row.pricingTier) && (
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{row.description ? <div>{row.description}</div> : null}
|
||||
{row.externalInvoiceId ? <div>invoice {row.externalInvoiceId}</div> : null}
|
||||
{row.region ? <div>region {row.region}</div> : null}
|
||||
{row.pricingTier ? <div>tier {row.pricingTier}</div> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right tabular-nums">
|
||||
<div className="text-sm font-semibold">{formatCents(row.amountCents)}</div>
|
||||
<div className="text-xs text-muted-foreground">{row.currency}</div>
|
||||
{row.estimated ? <div className="text-[11px] uppercase tracking-[0.12em] text-amber-600">estimated</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
416
ui/src/components/ProviderQuotaCard.tsx
Normal file
416
ui/src/components/ProviderQuotaCard.tsx
Normal file
@@ -0,0 +1,416 @@
|
||||
import { useMemo } from "react";
|
||||
import type { CostByProviderModel, CostWindowSpendRow, QuotaWindow } from "@paperclipai/shared";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { QuotaBar } from "./QuotaBar";
|
||||
import { ClaudeSubscriptionPanel } from "./ClaudeSubscriptionPanel";
|
||||
import { CodexSubscriptionPanel } from "./CodexSubscriptionPanel";
|
||||
import {
|
||||
billingTypeDisplayName,
|
||||
formatCents,
|
||||
formatTokens,
|
||||
providerDisplayName,
|
||||
quotaSourceDisplayName,
|
||||
} from "@/lib/utils";
|
||||
|
||||
// ordered display labels for rolling-window rows
|
||||
const ROLLING_WINDOWS = ["5h", "24h", "7d"] as const;
|
||||
|
||||
interface ProviderQuotaCardProps {
|
||||
provider: string;
|
||||
rows: CostByProviderModel[];
|
||||
/** company monthly budget in cents (0 means unlimited) */
|
||||
budgetMonthlyCents: number;
|
||||
/** total company spend in this period in cents, all providers */
|
||||
totalCompanySpendCents: number;
|
||||
/** spend in the current calendar week in cents, this provider only */
|
||||
weekSpendCents: number;
|
||||
/** rolling window rows for this provider: 5h, 24h, 7d */
|
||||
windowRows: CostWindowSpendRow[];
|
||||
showDeficitNotch: boolean;
|
||||
/** live subscription quota windows from the provider's own api */
|
||||
quotaWindows?: QuotaWindow[];
|
||||
quotaError?: string | null;
|
||||
quotaSource?: string | null;
|
||||
quotaLoading?: boolean;
|
||||
}
|
||||
|
||||
export function ProviderQuotaCard({
|
||||
provider,
|
||||
rows,
|
||||
budgetMonthlyCents,
|
||||
totalCompanySpendCents,
|
||||
weekSpendCents,
|
||||
windowRows,
|
||||
showDeficitNotch,
|
||||
quotaWindows = [],
|
||||
quotaError = null,
|
||||
quotaSource = null,
|
||||
quotaLoading = false,
|
||||
}: ProviderQuotaCardProps) {
|
||||
// single-pass aggregation over rows — memoized so the 8 derived values are not
|
||||
// recomputed on every parent render tick (providers tab polls every 30s, and each
|
||||
// card is mounted twice: once in the "all" tab grid and once in its per-provider tab).
|
||||
const totals = useMemo(() => {
|
||||
let inputTokens = 0, outputTokens = 0, costCents = 0;
|
||||
let apiRunCount = 0, subRunCount = 0, subInputTokens = 0, subOutputTokens = 0;
|
||||
for (const r of rows) {
|
||||
inputTokens += r.inputTokens;
|
||||
outputTokens += r.outputTokens;
|
||||
costCents += r.costCents;
|
||||
apiRunCount += r.apiRunCount;
|
||||
subRunCount += r.subscriptionRunCount;
|
||||
subInputTokens += r.subscriptionInputTokens;
|
||||
subOutputTokens += r.subscriptionOutputTokens;
|
||||
}
|
||||
const totalTokens = inputTokens + outputTokens;
|
||||
const subTokens = subInputTokens + subOutputTokens;
|
||||
// denominator: api-billed tokens (from cost_events) + subscription tokens (from heartbeat_runs)
|
||||
const allTokens = totalTokens + subTokens;
|
||||
return {
|
||||
totalInputTokens: inputTokens,
|
||||
totalOutputTokens: outputTokens,
|
||||
totalTokens,
|
||||
totalCostCents: costCents,
|
||||
totalApiRuns: apiRunCount,
|
||||
totalSubRuns: subRunCount,
|
||||
totalSubInputTokens: subInputTokens,
|
||||
totalSubOutputTokens: subOutputTokens,
|
||||
totalSubTokens: subTokens,
|
||||
subSharePct: allTokens > 0 ? (subTokens / allTokens) * 100 : 0,
|
||||
};
|
||||
}, [rows]);
|
||||
|
||||
const {
|
||||
totalInputTokens,
|
||||
totalOutputTokens,
|
||||
totalTokens,
|
||||
totalCostCents,
|
||||
totalApiRuns,
|
||||
totalSubRuns,
|
||||
totalSubInputTokens,
|
||||
totalSubOutputTokens,
|
||||
totalSubTokens,
|
||||
subSharePct,
|
||||
} = totals;
|
||||
|
||||
// budget bars: use this provider's own spend vs its pro-rata share of budget
|
||||
// pro-rata: if a provider is 40% of total spend, it gets 40% of the budget allocated.
|
||||
// falls back to raw provider spend vs total budget when totalCompanySpend is 0.
|
||||
const providerBudgetShare =
|
||||
budgetMonthlyCents > 0 && totalCompanySpendCents > 0
|
||||
? (totalCostCents / totalCompanySpendCents) * budgetMonthlyCents
|
||||
: budgetMonthlyCents;
|
||||
|
||||
const budgetPct =
|
||||
providerBudgetShare > 0
|
||||
? Math.min(100, (totalCostCents / providerBudgetShare) * 100)
|
||||
: 0;
|
||||
|
||||
// 4.33 = average weeks per calendar month (52 / 12)
|
||||
const weeklyBudgetShare = providerBudgetShare > 0 ? providerBudgetShare / 4.33 : 0;
|
||||
const weekPct =
|
||||
weeklyBudgetShare > 0 ? Math.min(100, (weekSpendCents / weeklyBudgetShare) * 100) : 0;
|
||||
|
||||
const hasBudget = budgetMonthlyCents > 0;
|
||||
|
||||
// memoized so the Map and max are not reconstructed on every parent render tick
|
||||
const windowMap = useMemo(
|
||||
() => new Map(windowRows.map((r) => [r.window, r])),
|
||||
[windowRows],
|
||||
);
|
||||
const maxWindowCents = useMemo(
|
||||
() => Math.max(...windowRows.map((r) => r.costCents), 0),
|
||||
[windowRows],
|
||||
);
|
||||
const isClaudeQuotaPanel = provider === "anthropic";
|
||||
const isCodexQuotaPanel = provider === "openai" && quotaSource?.startsWith("codex-");
|
||||
const supportsSubscriptionQuota = provider === "anthropic" || provider === "openai";
|
||||
const showSubscriptionQuotaSection =
|
||||
supportsSubscriptionQuota && (quotaLoading || quotaWindows.length > 0 || quotaError != null);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-4 pb-0 gap-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-sm font-semibold">
|
||||
{providerDisplayName(provider)}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs mt-0.5">
|
||||
<span className="font-mono">{formatTokens(totalInputTokens)}</span> in
|
||||
{" · "}
|
||||
<span className="font-mono">{formatTokens(totalOutputTokens)}</span> out
|
||||
{(totalApiRuns > 0 || totalSubRuns > 0) && (
|
||||
<span className="ml-1.5">
|
||||
·{" "}
|
||||
{totalApiRuns > 0 && `~${totalApiRuns} api`}
|
||||
{totalApiRuns > 0 && totalSubRuns > 0 && " / "}
|
||||
{totalSubRuns > 0 && `~${totalSubRuns} sub`}
|
||||
{" runs"}
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<span className="text-xl font-bold tabular-nums shrink-0">
|
||||
{formatCents(totalCostCents)}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-4 pb-4 pt-3 space-y-4">
|
||||
{hasBudget && (
|
||||
<div className="space-y-3">
|
||||
<QuotaBar
|
||||
label="Period spend"
|
||||
percentUsed={budgetPct}
|
||||
leftLabel={formatCents(totalCostCents)}
|
||||
rightLabel={`${Math.round(budgetPct)}% of allocation`}
|
||||
showDeficitNotch={showDeficitNotch}
|
||||
/>
|
||||
<QuotaBar
|
||||
label="This week"
|
||||
percentUsed={weekPct}
|
||||
leftLabel={formatCents(weekSpendCents)}
|
||||
rightLabel={`~${formatCents(Math.round(weeklyBudgetShare))} / wk`}
|
||||
showDeficitNotch={weekPct >= 100}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* rolling window consumption — always shown when data is available */}
|
||||
{windowRows.length > 0 && (
|
||||
<>
|
||||
<div className="border-t border-border" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Rolling windows
|
||||
</p>
|
||||
<div className="space-y-2.5">
|
||||
{ROLLING_WINDOWS.map((w) => {
|
||||
const row = windowMap.get(w);
|
||||
// omit windows with no data rather than showing false $0.00 zeros
|
||||
if (!row) return null;
|
||||
const cents = row.costCents;
|
||||
const tokens = row.inputTokens + row.outputTokens;
|
||||
const barPct = maxWindowCents > 0 ? (cents / maxWindowCents) * 100 : 0;
|
||||
return (
|
||||
<div key={w} className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="font-mono text-muted-foreground w-6 shrink-0">{w}</span>
|
||||
<span className="text-muted-foreground font-mono flex-1">
|
||||
{formatTokens(tokens)} tok
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">{formatCents(cents)}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full border border-border overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary/60 transition-[width] duration-150"
|
||||
style={{ width: `${barPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* subscription usage — shown when any subscription-billed runs exist */}
|
||||
{totalSubRuns > 0 && (
|
||||
<>
|
||||
<div className="border-t border-border" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Subscription
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-mono text-foreground">{totalSubRuns}</span> runs
|
||||
{" · "}
|
||||
{totalSubTokens > 0 && (
|
||||
<>
|
||||
<span className="font-mono text-foreground">{formatTokens(totalSubTokens)}</span> total
|
||||
{" · "}
|
||||
</>
|
||||
)}
|
||||
<span className="font-mono text-foreground">{formatTokens(totalSubInputTokens)}</span> in
|
||||
{" · "}
|
||||
<span className="font-mono text-foreground">{formatTokens(totalSubOutputTokens)}</span> out
|
||||
</p>
|
||||
{subSharePct > 0 && (
|
||||
<>
|
||||
<div className="h-1.5 w-full border border-border overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary/60 transition-[width] duration-150"
|
||||
style={{ width: `${subSharePct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{Math.round(subSharePct)}% of token usage via subscription
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* model breakdown — always shown, with token-share bars */}
|
||||
{rows.length > 0 && (
|
||||
<>
|
||||
<div className="border-t border-border" />
|
||||
<div className="space-y-3">
|
||||
{rows.map((row) => {
|
||||
const rowTokens = row.inputTokens + row.outputTokens;
|
||||
const tokenPct = totalTokens > 0 ? (rowTokens / totalTokens) * 100 : 0;
|
||||
const costPct = totalCostCents > 0 ? (row.costCents / totalCostCents) * 100 : 0;
|
||||
return (
|
||||
<div key={`${row.provider}:${row.model}`} className="space-y-1.5">
|
||||
{/* model name and cost */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<span className="text-xs text-muted-foreground truncate font-mono block">
|
||||
{row.model}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground truncate block">
|
||||
{providerDisplayName(row.biller)} · {billingTypeDisplayName(row.billingType)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0 tabular-nums text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{formatTokens(rowTokens)} tok
|
||||
</span>
|
||||
<span className="font-medium">{formatCents(row.costCents)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* token share bar */}
|
||||
<div className="relative h-2 w-full border border-border overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-primary/60 transition-[width] duration-150"
|
||||
style={{ width: `${tokenPct}%` }}
|
||||
title={`${Math.round(tokenPct)}% of provider tokens`}
|
||||
/>
|
||||
{/* cost share overlay — narrower, opaque, shows relative cost weight */}
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-primary/85 transition-[width] duration-150"
|
||||
style={{ width: `${costPct}%` }}
|
||||
title={`${Math.round(costPct)}% of provider cost`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* subscription quota windows from provider api — shown when data is available */}
|
||||
{showSubscriptionQuotaSection && (
|
||||
<>
|
||||
<div className="border-t border-border" />
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Subscription quota
|
||||
</p>
|
||||
{quotaSource && !isClaudeQuotaPanel && !isCodexQuotaPanel ? (
|
||||
<span className="text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{quotaSourceDisplayName(quotaSource)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{quotaLoading ? (
|
||||
<QuotaPanelSkeleton />
|
||||
) : isClaudeQuotaPanel ? (
|
||||
<ClaudeSubscriptionPanel windows={quotaWindows} source={quotaSource} error={quotaError} />
|
||||
) : isCodexQuotaPanel ? (
|
||||
<CodexSubscriptionPanel windows={quotaWindows} source={quotaSource} error={quotaError} />
|
||||
) : (
|
||||
<>
|
||||
{quotaError ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{quotaError}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="space-y-2.5">
|
||||
{quotaWindows.map((qw) => {
|
||||
const fillColor =
|
||||
qw.usedPercent == null
|
||||
? null
|
||||
: qw.usedPercent >= 90
|
||||
? "bg-red-400"
|
||||
: qw.usedPercent >= 70
|
||||
? "bg-yellow-400"
|
||||
: "bg-green-400";
|
||||
return (
|
||||
<div key={qw.label} className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="font-mono text-muted-foreground shrink-0">{qw.label}</span>
|
||||
<span className="flex-1" />
|
||||
{qw.valueLabel != null ? (
|
||||
<span className="font-medium tabular-nums">{qw.valueLabel}</span>
|
||||
) : qw.usedPercent != null ? (
|
||||
<span className="font-medium tabular-nums">{qw.usedPercent}% used</span>
|
||||
) : null}
|
||||
</div>
|
||||
{qw.usedPercent != null && fillColor != null && (
|
||||
<div className="h-2 w-full border border-border overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-[width] duration-150 ${fillColor}`}
|
||||
style={{ width: `${qw.usedPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{qw.detail ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{qw.detail}
|
||||
</p>
|
||||
) : qw.resetsAt ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
resets {new Date(qw.resetsAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotaPanelSkeleton() {
|
||||
return (
|
||||
<div className="border border-border px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Skeleton className="h-3 w-36" />
|
||||
<Skeleton className="h-4 w-64 max-w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-7 w-28" />
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-border px-3.5 py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-44 max-w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<Skeleton className="mt-3 h-2 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
ui/src/components/QuotaBar.tsx
Normal file
65
ui/src/components/QuotaBar.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface QuotaBarProps {
|
||||
label: string;
|
||||
// value between 0 and 100
|
||||
percentUsed: number;
|
||||
leftLabel: string;
|
||||
rightLabel?: string;
|
||||
// shows a 2px destructive notch at the fill tip when true
|
||||
showDeficitNotch?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function fillColor(pct: number): string {
|
||||
if (pct > 90) return "bg-red-400";
|
||||
if (pct > 70) return "bg-yellow-400";
|
||||
return "bg-green-400";
|
||||
}
|
||||
|
||||
export function QuotaBar({
|
||||
label,
|
||||
percentUsed,
|
||||
leftLabel,
|
||||
rightLabel,
|
||||
showDeficitNotch = false,
|
||||
className,
|
||||
}: QuotaBarProps) {
|
||||
const clampedPct = Math.min(100, Math.max(0, percentUsed));
|
||||
// keep the notch visible even near the edges
|
||||
const notchLeft = Math.min(clampedPct, 97);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-1.5", className)}>
|
||||
{/* row header */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-xs font-medium tabular-nums">{leftLabel}</span>
|
||||
{rightLabel && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{rightLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* track — boxed border, square corners to match the theme */}
|
||||
<div className="relative h-2 w-full border border-border overflow-hidden">
|
||||
{/* fill */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 transition-[width,background-color] duration-150",
|
||||
fillColor(clampedPct),
|
||||
)}
|
||||
style={{ width: `${clampedPct}%` }}
|
||||
/>
|
||||
{/* deficit notch — 2px wide, sits at the fill tip */}
|
||||
{showDeficitNotch && clampedPct > 0 && (
|
||||
<div
|
||||
className="absolute inset-y-0 w-[2px] bg-destructive z-10"
|
||||
style={{ left: `${notchLeft}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, agentRouteRef, agentUrl } from "../lib/utils";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@@ -124,15 +125,22 @@ export function SidebarAgents() {
|
||||
>
|
||||
<AgentIcon icon={agent.icon} className="shrink-0 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">{agent.name}</span>
|
||||
{runCount > 0 && (
|
||||
{(agent.pauseReason === "budget" || runCount > 0) && (
|
||||
<span className="ml-auto flex items-center gap-1.5 shrink-0">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">
|
||||
{runCount} live
|
||||
</span>
|
||||
{agent.pauseReason === "budget" ? (
|
||||
<BudgetSidebarMarker title="Agent paused by budget" />
|
||||
) : null}
|
||||
{runCount > 0 ? (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||
</span>
|
||||
) : null}
|
||||
{runCount > 0 ? (
|
||||
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">
|
||||
{runCount} live
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { projectsApi } from "../api/projects";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, projectRouteRef } from "../lib/utils";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@@ -88,6 +89,7 @@ function SortableProjectItem({
|
||||
style={{ backgroundColor: project.color ?? "#6366f1" }}
|
||||
/>
|
||||
<span className="flex-1 truncate">{project.name}</span>
|
||||
{project.pauseReason === "budget" ? <BudgetSidebarMarker title="Project paused by budget" /> : null}
|
||||
</NavLink>
|
||||
{projectSidebarSlots.length > 0 && (
|
||||
<div className="ml-5 flex flex-col gap-0.5">
|
||||
|
||||
@@ -85,7 +85,11 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
|
||||
}, [queryClient]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) =>
|
||||
mutationFn: (data: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
budgetMonthlyCents?: number;
|
||||
}) =>
|
||||
companiesApi.create(data),
|
||||
onSuccess: (company) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
@@ -94,7 +98,11 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
|
||||
const createCompany = useCallback(
|
||||
async (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) => {
|
||||
async (data: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
budgetMonthlyCents?: number;
|
||||
}) => {
|
||||
return createMutation.mutateAsync(data);
|
||||
},
|
||||
[createMutation],
|
||||
|
||||
@@ -413,6 +413,10 @@ function invalidateActivityQueries(
|
||||
|
||||
if (entityType === "cost_event") {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.usageByProvider(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.usageWindowSpend(companyId) });
|
||||
// usageQuotaWindows is intentionally excluded: quota windows come from external provider
|
||||
// apis on a 5-minute poll and do not change in response to cost events logged by agents
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
120
ui/src/hooks/useDateRange.ts
Normal file
120
ui/src/hooks/useDateRange.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
export type DatePreset = "mtd" | "7d" | "30d" | "ytd" | "all" | "custom";
|
||||
|
||||
export const PRESET_LABELS: Record<DatePreset, string> = {
|
||||
mtd: "Month to Date",
|
||||
"7d": "Last 7 Days",
|
||||
"30d": "Last 30 Days",
|
||||
ytd: "Year to Date",
|
||||
all: "All Time",
|
||||
custom: "Custom",
|
||||
};
|
||||
|
||||
export const PRESET_KEYS: DatePreset[] = ["mtd", "7d", "30d", "ytd", "all", "custom"];
|
||||
|
||||
// note: computeRange is called inside a useMemo that re-evaluates once per minute
|
||||
// (driven by minuteTick). this means sliding windows (7d, 30d) advance their upper
|
||||
// bound at most once per minute — acceptable for a cost dashboard.
|
||||
function computeRange(preset: DatePreset): { from: string; to: string } {
|
||||
const now = new Date();
|
||||
const to = now.toISOString();
|
||||
switch (preset) {
|
||||
case "mtd": {
|
||||
const d = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
return { from: d.toISOString(), to };
|
||||
}
|
||||
case "7d": {
|
||||
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7, 0, 0, 0, 0);
|
||||
return { from: d.toISOString(), to };
|
||||
}
|
||||
case "30d": {
|
||||
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30, 0, 0, 0, 0);
|
||||
return { from: d.toISOString(), to };
|
||||
}
|
||||
case "ytd": {
|
||||
const d = new Date(now.getFullYear(), 0, 1);
|
||||
return { from: d.toISOString(), to };
|
||||
}
|
||||
case "all":
|
||||
case "custom":
|
||||
return { from: "", to: "" };
|
||||
}
|
||||
}
|
||||
|
||||
// floor a Date to the nearest minute so the query key is stable across
|
||||
// 30s refetch ticks (prevents new cache entries on every poll cycle)
|
||||
function floorToMinute(d: Date): string {
|
||||
const floored = new Date(d);
|
||||
floored.setSeconds(0, 0);
|
||||
return floored.toISOString();
|
||||
}
|
||||
|
||||
export interface UseDateRangeResult {
|
||||
preset: DatePreset;
|
||||
setPreset: (p: DatePreset) => void;
|
||||
customFrom: string;
|
||||
setCustomFrom: (v: string) => void;
|
||||
customTo: string;
|
||||
setCustomTo: (v: string) => void;
|
||||
/** resolved iso strings ready to pass to api calls; empty string means unbounded */
|
||||
from: string;
|
||||
to: string;
|
||||
/** false when preset=custom but both dates are not yet selected */
|
||||
customReady: boolean;
|
||||
}
|
||||
|
||||
export function useDateRange(): UseDateRangeResult {
|
||||
const [preset, setPreset] = useState<DatePreset>("mtd");
|
||||
const [customFrom, setCustomFrom] = useState("");
|
||||
const [customTo, setCustomTo] = useState("");
|
||||
|
||||
// tick at the next calendar minute boundary, then every 60s, so sliding presets
|
||||
// (7d, 30d) advance their upper bound in sync with wall clock minutes rather than
|
||||
// drifting by the mount offset.
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [minuteTick, setMinuteTick] = useState(() => floorToMinute(new Date()));
|
||||
useEffect(() => {
|
||||
const now = new Date();
|
||||
const msToNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds();
|
||||
const timeout = setTimeout(() => {
|
||||
setMinuteTick(floorToMinute(new Date()));
|
||||
intervalRef.current = setInterval(
|
||||
() => setMinuteTick(floorToMinute(new Date())),
|
||||
60_000,
|
||||
);
|
||||
}, msToNextMinute);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
if (intervalRef.current != null) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { from, to } = useMemo(() => {
|
||||
if (preset !== "custom") return computeRange(preset);
|
||||
// treat custom date strings as local-date boundaries so the full day is included
|
||||
// regardless of the user's timezone. "from" starts at local midnight, "to" at 23:59:59.999.
|
||||
const fromDate = customFrom ? new Date(customFrom + "T00:00:00") : null;
|
||||
const toDate = customTo ? new Date(customTo + "T23:59:59.999") : null;
|
||||
return {
|
||||
from: fromDate ? fromDate.toISOString() : "",
|
||||
to: toDate ? toDate.toISOString() : "",
|
||||
};
|
||||
// minuteTick drives re-evaluation of sliding presets once per minute.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [preset, customFrom, customTo, minuteTick]);
|
||||
|
||||
const customReady = preset !== "custom" || (!!customFrom && !!customTo);
|
||||
|
||||
return {
|
||||
preset,
|
||||
setPreset,
|
||||
customFrom,
|
||||
setCustomFrom,
|
||||
customTo,
|
||||
setCustomTo,
|
||||
from,
|
||||
to,
|
||||
customReady,
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
||||
"goals",
|
||||
"approvals",
|
||||
"costs",
|
||||
"usage",
|
||||
"activity",
|
||||
"inbox",
|
||||
"design-guide",
|
||||
|
||||
@@ -164,6 +164,12 @@ const dashboard: DashboardSummary = {
|
||||
monthUtilizationPercent: 90,
|
||||
},
|
||||
pendingApprovals: 1,
|
||||
budgets: {
|
||||
activeIncidents: 0,
|
||||
pendingApprovals: 0,
|
||||
pausedAgents: 0,
|
||||
pausedProjects: 0,
|
||||
},
|
||||
};
|
||||
|
||||
describe("inbox helpers", () => {
|
||||
|
||||
@@ -52,6 +52,9 @@ export const queryKeys = {
|
||||
list: (companyId: string) => ["goals", companyId] as const,
|
||||
detail: (id: string) => ["goals", "detail", id] as const,
|
||||
},
|
||||
budgets: {
|
||||
overview: (companyId: string) => ["budgets", "overview", companyId] as const,
|
||||
},
|
||||
approvals: {
|
||||
list: (companyId: string, status?: string) =>
|
||||
["approvals", companyId, status] as const,
|
||||
@@ -80,6 +83,22 @@ export const queryKeys = {
|
||||
activity: (companyId: string) => ["activity", companyId] as const,
|
||||
costs: (companyId: string, from?: string, to?: string) =>
|
||||
["costs", companyId, from, to] as const,
|
||||
usageByProvider: (companyId: string, from?: string, to?: string) =>
|
||||
["usage-by-provider", companyId, from, to] as const,
|
||||
usageByBiller: (companyId: string, from?: string, to?: string) =>
|
||||
["usage-by-biller", companyId, from, to] as const,
|
||||
financeSummary: (companyId: string, from?: string, to?: string) =>
|
||||
["finance-summary", companyId, from, to] as const,
|
||||
financeByBiller: (companyId: string, from?: string, to?: string) =>
|
||||
["finance-by-biller", companyId, from, to] as const,
|
||||
financeByKind: (companyId: string, from?: string, to?: string) =>
|
||||
["finance-by-kind", companyId, from, to] as const,
|
||||
financeEvents: (companyId: string, from?: string, to?: string, limit: number = 100) =>
|
||||
["finance-events", companyId, from, to, limit] as const,
|
||||
usageWindowSpend: (companyId: string) =>
|
||||
["usage-window-spend", companyId] as const,
|
||||
usageQuotaWindows: (companyId: string) =>
|
||||
["usage-quota-windows", companyId] as const,
|
||||
heartbeats: (companyId: string, agentId?: string) =>
|
||||
["heartbeats", companyId, agentId] as const,
|
||||
runDetail: (runId: string) => ["heartbeat-run", runId] as const,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { deriveAgentUrlKey, deriveProjectUrlKey } from "@paperclipai/shared";
|
||||
import type { BillingType, FinanceDirection, FinanceEventKind } from "@paperclipai/shared";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
@@ -48,6 +49,98 @@ export function formatTokens(n: number): string {
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/** Map a raw provider slug to a display-friendly name. */
|
||||
export function providerDisplayName(provider: string): string {
|
||||
const map: Record<string, string> = {
|
||||
anthropic: "Anthropic",
|
||||
openai: "OpenAI",
|
||||
openrouter: "OpenRouter",
|
||||
chatgpt: "ChatGPT",
|
||||
google: "Google",
|
||||
cursor: "Cursor",
|
||||
jetbrains: "JetBrains AI",
|
||||
};
|
||||
return map[provider.toLowerCase()] ?? provider;
|
||||
}
|
||||
|
||||
export function billingTypeDisplayName(billingType: BillingType): string {
|
||||
const map: Record<BillingType, string> = {
|
||||
metered_api: "Metered API",
|
||||
subscription_included: "Subscription",
|
||||
subscription_overage: "Subscription overage",
|
||||
credits: "Credits",
|
||||
fixed: "Fixed",
|
||||
unknown: "Unknown",
|
||||
};
|
||||
return map[billingType];
|
||||
}
|
||||
|
||||
export function quotaSourceDisplayName(source: string): string {
|
||||
const map: Record<string, string> = {
|
||||
"anthropic-oauth": "Anthropic OAuth",
|
||||
"claude-cli": "Claude CLI",
|
||||
"codex-rpc": "Codex app server",
|
||||
"codex-wham": "ChatGPT WHAM",
|
||||
};
|
||||
return map[source] ?? source;
|
||||
}
|
||||
|
||||
function coerceBillingType(value: unknown): BillingType | null {
|
||||
if (
|
||||
value === "metered_api" ||
|
||||
value === "subscription_included" ||
|
||||
value === "subscription_overage" ||
|
||||
value === "credits" ||
|
||||
value === "fixed" ||
|
||||
value === "unknown"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readRunCostUsd(payload: Record<string, unknown> | null): number {
|
||||
if (!payload) return 0;
|
||||
for (const key of ["costUsd", "cost_usd", "total_cost_usd"] as const) {
|
||||
const value = payload[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function visibleRunCostUsd(
|
||||
usage: Record<string, unknown> | null,
|
||||
result: Record<string, unknown> | null = null,
|
||||
): number {
|
||||
const billingType = coerceBillingType(usage?.billingType) ?? coerceBillingType(result?.billingType);
|
||||
if (billingType === "subscription_included") return 0;
|
||||
return readRunCostUsd(usage) || readRunCostUsd(result);
|
||||
}
|
||||
|
||||
export function financeEventKindDisplayName(eventKind: FinanceEventKind): string {
|
||||
const map: Record<FinanceEventKind, string> = {
|
||||
inference_charge: "Inference charge",
|
||||
platform_fee: "Platform fee",
|
||||
credit_purchase: "Credit purchase",
|
||||
credit_refund: "Credit refund",
|
||||
credit_expiry: "Credit expiry",
|
||||
byok_fee: "BYOK fee",
|
||||
gateway_overhead: "Gateway overhead",
|
||||
log_storage_charge: "Log storage",
|
||||
logpush_charge: "Logpush",
|
||||
provisioned_capacity_charge: "Provisioned capacity",
|
||||
training_charge: "Training",
|
||||
custom_model_import_charge: "Custom model import",
|
||||
custom_model_storage_charge: "Custom model storage",
|
||||
manual_adjustment: "Manual adjustment",
|
||||
};
|
||||
return map[eventKind];
|
||||
}
|
||||
|
||||
export function financeDirectionDisplayName(direction: FinanceDirection): string {
|
||||
return direction === "credit" ? "Credit" : "Debit";
|
||||
}
|
||||
|
||||
/** Build an issue URL using the human-readable identifier when available. */
|
||||
export function issueUrl(issue: { id: string; identifier?: string | null }): string {
|
||||
return `/issues/${issue.identifier ?? issue.id}`;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate, Link, Navigate, useBeforeUnload } from "@/lib/r
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents";
|
||||
import { companySkillsApi } from "../api/companySkills";
|
||||
import { budgetsApi } from "../api/budgets";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { ApiError } from "../api/client";
|
||||
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
|
||||
@@ -25,8 +26,9 @@ import { CopyText } from "../components/CopyText";
|
||||
import { EntityRow } from "../components/EntityRow";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
||||
import { ScrollToBottom } from "../components/ScrollToBottom";
|
||||
import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils";
|
||||
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
@@ -60,7 +62,16 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
|
||||
import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
|
||||
import { isUuidLike, type Agent, type AgentRuntimeState, type AgentSkillSnapshot, type HeartbeatRun, type HeartbeatRunEvent, type LiveEvent } from "@paperclipai/shared";
|
||||
import {
|
||||
isUuidLike,
|
||||
type Agent,
|
||||
type AgentSkillSnapshot,
|
||||
type BudgetPolicySummary,
|
||||
type HeartbeatRun,
|
||||
type HeartbeatRunEvent,
|
||||
type AgentRuntimeState,
|
||||
type LiveEvent,
|
||||
} from "@paperclipai/shared";
|
||||
import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils";
|
||||
import { agentRouteRef } from "../lib/utils";
|
||||
|
||||
@@ -183,11 +194,12 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh
|
||||
container.scrollTo({ top: container.scrollHeight, behavior });
|
||||
}
|
||||
|
||||
type AgentDetailView = "dashboard" | "configuration" | "skills" | "runs";
|
||||
type AgentDetailView = "dashboard" | "configuration" | "skills" | "runs" | "budget";
|
||||
|
||||
function parseAgentDetailView(value: string | null): AgentDetailView {
|
||||
if (value === "configure" || value === "configuration") return "configuration";
|
||||
if (value === "skills") return "skills";
|
||||
if (value === "budget") return "budget";
|
||||
if (value === "runs") return value;
|
||||
return "dashboard";
|
||||
}
|
||||
@@ -213,8 +225,7 @@ function runMetrics(run: HeartbeatRun) {
|
||||
"cache_read_input_tokens",
|
||||
);
|
||||
const cost =
|
||||
usageNumber(usage, "costUsd", "cost_usd", "total_cost_usd") ||
|
||||
usageNumber(result, "total_cost_usd", "cost_usd", "costUsd");
|
||||
visibleRunCostUsd(usage, result);
|
||||
return {
|
||||
input,
|
||||
output,
|
||||
@@ -306,11 +317,50 @@ export function AgentDetail() {
|
||||
enabled: !!resolvedCompanyId && needsDashboardData,
|
||||
});
|
||||
|
||||
const { data: budgetOverview } = useQuery({
|
||||
queryKey: queryKeys.budgets.overview(resolvedCompanyId ?? "__none__"),
|
||||
queryFn: () => budgetsApi.overview(resolvedCompanyId!),
|
||||
enabled: !!resolvedCompanyId,
|
||||
refetchInterval: 30_000,
|
||||
staleTime: 5_000,
|
||||
});
|
||||
|
||||
const assignedIssues = (allIssues ?? [])
|
||||
.filter((i) => i.assigneeAgentId === agent?.id)
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo);
|
||||
const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agent?.id && a.status !== "terminated");
|
||||
const agentBudgetSummary = useMemo(() => {
|
||||
const matched = budgetOverview?.policies.find(
|
||||
(policy) => policy.scopeType === "agent" && policy.scopeId === (agent?.id ?? routeAgentRef),
|
||||
);
|
||||
if (matched) return matched;
|
||||
const budgetMonthlyCents = agent?.budgetMonthlyCents ?? 0;
|
||||
const spentMonthlyCents = agent?.spentMonthlyCents ?? 0;
|
||||
return {
|
||||
policyId: "",
|
||||
companyId: resolvedCompanyId ?? "",
|
||||
scopeType: "agent",
|
||||
scopeId: agent?.id ?? routeAgentRef,
|
||||
scopeName: agent?.name ?? "Agent",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: budgetMonthlyCents,
|
||||
observedAmount: spentMonthlyCents,
|
||||
remainingAmount: Math.max(0, budgetMonthlyCents - spentMonthlyCents),
|
||||
utilizationPercent:
|
||||
budgetMonthlyCents > 0 ? Number(((spentMonthlyCents / budgetMonthlyCents) * 100).toFixed(2)) : 0,
|
||||
warnPercent: 80,
|
||||
hardStopEnabled: true,
|
||||
notifyEnabled: true,
|
||||
isActive: budgetMonthlyCents > 0,
|
||||
status: budgetMonthlyCents > 0 && spentMonthlyCents >= budgetMonthlyCents ? "hard_stop" : "ok",
|
||||
paused: agent?.status === "paused",
|
||||
pauseReason: agent?.pauseReason ?? null,
|
||||
windowStart: new Date(),
|
||||
windowEnd: new Date(),
|
||||
} satisfies BudgetPolicySummary;
|
||||
}, [agent, budgetOverview?.policies, resolvedCompanyId, routeAgentRef]);
|
||||
const mobileLiveRun = useMemo(
|
||||
() => (heartbeats ?? []).find((r) => r.status === "running" || r.status === "queued") ?? null,
|
||||
[heartbeats],
|
||||
@@ -331,7 +381,9 @@ export function AgentDetail() {
|
||||
? "skills"
|
||||
: activeView === "runs"
|
||||
? "runs"
|
||||
: "dashboard";
|
||||
: activeView === "budget"
|
||||
? "budget"
|
||||
: "dashboard";
|
||||
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
|
||||
navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true });
|
||||
return;
|
||||
@@ -374,6 +426,24 @@ export function AgentDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
const budgetMutation = useMutation({
|
||||
mutationFn: (amount: number) =>
|
||||
budgetsApi.upsertPolicy(resolvedCompanyId!, {
|
||||
scopeType: "agent",
|
||||
scopeId: agent?.id ?? routeAgentRef,
|
||||
amount,
|
||||
windowKind: "calendar_month_utc",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
if (!resolvedCompanyId) return;
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.budgets.overview(resolvedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(resolvedCompanyId) });
|
||||
},
|
||||
});
|
||||
|
||||
const updateIcon = useMutation({
|
||||
mutationFn: (icon: string) => agentsApi.update(agentLookupRef, { icon }, resolvedCompanyId ?? undefined),
|
||||
onSuccess: () => {
|
||||
@@ -432,6 +502,8 @@ export function AgentDetail() {
|
||||
crumbs.push({ label: "Skills" });
|
||||
} else if (activeView === "runs") {
|
||||
crumbs.push({ label: "Runs" });
|
||||
} else if (activeView === "budget") {
|
||||
crumbs.push({ label: "Budget" });
|
||||
} else {
|
||||
crumbs.push({ label: "Dashboard" });
|
||||
}
|
||||
@@ -589,6 +661,7 @@ export function AgentDetail() {
|
||||
{ value: "configuration", label: "Configuration" },
|
||||
{ value: "skills", label: "Skills" },
|
||||
{ value: "runs", label: "Runs" },
|
||||
{ value: "budget", label: "Budget" },
|
||||
]}
|
||||
value={activeView}
|
||||
onValueChange={(value) => navigate(`/agents/${canonicalAgentRef}/${value}`)}
|
||||
@@ -701,6 +774,17 @@ export function AgentDetail() {
|
||||
adapterType={agent.adapterType}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeView === "budget" && resolvedCompanyId ? (
|
||||
<div className="max-w-3xl">
|
||||
<BudgetPolicyCard
|
||||
summary={agentBudgetSummary}
|
||||
isSaving={budgetMutation.isPending}
|
||||
onSave={(amount) => budgetMutation.mutate(amount)}
|
||||
variant="plain"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -873,8 +957,8 @@ function CostsSection({
|
||||
}) {
|
||||
const runsWithCost = runs
|
||||
.filter((r) => {
|
||||
const u = r.usageJson as Record<string, unknown> | null;
|
||||
return u && (u.cost_usd || u.total_cost_usd || u.input_tokens);
|
||||
const metrics = runMetrics(r);
|
||||
return metrics.cost > 0 || metrics.input > 0 || metrics.output > 0 || metrics.cached > 0;
|
||||
})
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
@@ -916,16 +1000,16 @@ function CostsSection({
|
||||
</thead>
|
||||
<tbody>
|
||||
{runsWithCost.slice(0, 10).map((run) => {
|
||||
const u = run.usageJson as Record<string, unknown>;
|
||||
const metrics = runMetrics(run);
|
||||
return (
|
||||
<tr key={run.id} className="border-b border-border last:border-b-0">
|
||||
<td className="px-3 py-2">{formatDate(run.createdAt)}</td>
|
||||
<td className="px-3 py-2 font-mono">{run.id.slice(0, 8)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{formatTokens(Number(u.input_tokens ?? 0))}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{formatTokens(Number(u.output_tokens ?? 0))}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{formatTokens(metrics.input)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{formatTokens(metrics.output)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{(u.cost_usd || u.total_cost_usd)
|
||||
? `$${Number(u.cost_usd ?? u.total_cost_usd ?? 0).toFixed(4)}`
|
||||
{metrics.cost > 0
|
||||
? `$${metrics.cost.toFixed(4)}`
|
||||
: "-"
|
||||
}
|
||||
</td>
|
||||
|
||||
@@ -147,6 +147,7 @@ export function ApprovalDetail() {
|
||||
const payload = approval.payload as Record<string, unknown>;
|
||||
const linkedAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
|
||||
const isActionable = approval.status === "pending" || approval.status === "revision_requested";
|
||||
const isBudgetApproval = approval.type === "budget_override_required";
|
||||
const TypeIcon = typeIcon[approval.type] ?? defaultTypeIcon;
|
||||
const showApprovedBanner = searchParams.get("resolved") === "approved" && approval.status === "approved";
|
||||
const primaryLinkedIssue = linkedIssues?.[0] ?? null;
|
||||
@@ -260,7 +261,7 @@ export function ApprovalDetail() {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isActionable && (
|
||||
{isActionable && !isBudgetApproval && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -280,6 +281,11 @@ export function ApprovalDetail() {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isBudgetApproval && approval.status === "pending" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Resolve this budget stop from the budget controls on <Link to="/costs" className="underline underline-offset-2">/costs</Link>.
|
||||
</p>
|
||||
)}
|
||||
{approval.status === "pending" && (
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ChangeEvent, useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { companiesApi } from "../api/companies";
|
||||
import { accessApi } from "../api/access";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Settings, Check, Download, Upload } from "lucide-react";
|
||||
@@ -35,6 +36,8 @@ export function CompanySettings() {
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [brandColor, setBrandColor] = useState("");
|
||||
const [logoUrl, setLogoUrl] = useState("");
|
||||
const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
|
||||
|
||||
// Sync local state from selected company
|
||||
useEffect(() => {
|
||||
@@ -42,6 +45,7 @@ export function CompanySettings() {
|
||||
setCompanyName(selectedCompany.name);
|
||||
setDescription(selectedCompany.description ?? "");
|
||||
setBrandColor(selectedCompany.brandColor ?? "");
|
||||
setLogoUrl(selectedCompany.logoUrl ?? "");
|
||||
}, [selectedCompany]);
|
||||
|
||||
const [inviteError, setInviteError] = useState<string | null>(null);
|
||||
@@ -129,6 +133,42 @@ export function CompanySettings() {
|
||||
}
|
||||
});
|
||||
|
||||
const syncLogoState = (nextLogoUrl: string | null) => {
|
||||
setLogoUrl(nextLogoUrl ?? "");
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
};
|
||||
|
||||
const logoUploadMutation = useMutation({
|
||||
mutationFn: (file: File) =>
|
||||
assetsApi
|
||||
.uploadCompanyLogo(selectedCompanyId!, file)
|
||||
.then((asset) => companiesApi.update(selectedCompanyId!, { logoAssetId: asset.assetId })),
|
||||
onSuccess: (company) => {
|
||||
syncLogoState(company.logoUrl);
|
||||
setLogoUploadError(null);
|
||||
}
|
||||
});
|
||||
|
||||
const clearLogoMutation = useMutation({
|
||||
mutationFn: () => companiesApi.update(selectedCompanyId!, { logoAssetId: null }),
|
||||
onSuccess: (company) => {
|
||||
setLogoUploadError(null);
|
||||
syncLogoState(company.logoUrl);
|
||||
}
|
||||
});
|
||||
|
||||
function handleLogoFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
event.currentTarget.value = "";
|
||||
if (!file) return;
|
||||
setLogoUploadError(null);
|
||||
logoUploadMutation.mutate(file);
|
||||
}
|
||||
|
||||
function handleClearLogo() {
|
||||
clearLogoMutation.mutate();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setInviteError(null);
|
||||
setInviteSnippet(null);
|
||||
@@ -226,11 +266,53 @@ export function CompanySettings() {
|
||||
<div className="shrink-0">
|
||||
<CompanyPatternIcon
|
||||
companyName={companyName || selectedCompany.name}
|
||||
logoUrl={logoUrl || null}
|
||||
brandColor={brandColor || null}
|
||||
className="rounded-[14px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex-1 space-y-3">
|
||||
<Field
|
||||
label="Logo"
|
||||
hint="Upload a PNG, JPEG, WEBP, GIF, or SVG logo image."
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml"
|
||||
onChange={handleLogoFileChange}
|
||||
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none file:mr-4 file:rounded-md file:border-0 file:bg-muted file:px-2.5 file:py-1 file:text-xs"
|
||||
/>
|
||||
{logoUrl && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleClearLogo}
|
||||
disabled={clearLogoMutation.isPending}
|
||||
>
|
||||
{clearLogoMutation.isPending ? "Removing..." : "Remove logo"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{(logoUploadMutation.isError || logoUploadError) && (
|
||||
<span className="text-xs text-destructive">
|
||||
{logoUploadError ??
|
||||
(logoUploadMutation.error instanceof Error
|
||||
? logoUploadMutation.error.message
|
||||
: "Logo upload failed")}
|
||||
</span>
|
||||
)}
|
||||
{clearLogoMutation.isError && (
|
||||
<span className="text-xs text-destructive">
|
||||
{clearLogoMutation.error.message}
|
||||
</span>
|
||||
)}
|
||||
{logoUploadMutation.isPending && (
|
||||
<span className="text-xs text-muted-foreground">Uploading logo...</span>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
<Field
|
||||
label="Brand color"
|
||||
hint="Sets the hue for the company icon. Leave empty for auto-generated color."
|
||||
@@ -287,8 +369,8 @@ export function CompanySettings() {
|
||||
{generalMutation.isError && (
|
||||
<span className="text-xs text-destructive">
|
||||
{generalMutation.error instanceof Error
|
||||
? generalMutation.error.message
|
||||
: "Failed to save"}
|
||||
? generalMutation.error.message
|
||||
: "Failed to save"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,7 @@ import { ActivityRow } from "../components/ActivityRow";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { cn, formatCents } from "../lib/utils";
|
||||
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard } from "lucide-react";
|
||||
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard, PauseCircle } from "lucide-react";
|
||||
import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel";
|
||||
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
@@ -210,6 +210,25 @@ export function Dashboard() {
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{data.budgets.activeIncidents > 0 ? (
|
||||
<div className="flex items-start justify-between gap-3 rounded-xl border border-red-500/20 bg-[linear-gradient(180deg,rgba(255,80,80,0.12),rgba(255,255,255,0.02))] px-4 py-3">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<PauseCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-300" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-50">
|
||||
{data.budgets.activeIncidents} active budget incident{data.budgets.activeIncidents === 1 ? "" : "s"}
|
||||
</p>
|
||||
<p className="text-xs text-red-100/70">
|
||||
{data.budgets.pausedAgents} agents paused · {data.budgets.pausedProjects} projects paused · {data.budgets.pendingApprovals} pending budget approvals
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link to="/costs" className="text-sm underline underline-offset-2 text-red-100">
|
||||
Open budgets
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-2 xl:grid-cols-4 gap-1 sm:gap-2">
|
||||
<MetricCard
|
||||
icon={Bot}
|
||||
@@ -251,12 +270,14 @@ export function Dashboard() {
|
||||
/>
|
||||
<MetricCard
|
||||
icon={ShieldCheck}
|
||||
value={data.pendingApprovals}
|
||||
value={data.pendingApprovals + data.budgets.pendingApprovals}
|
||||
label="Pending Approvals"
|
||||
to="/approvals"
|
||||
description={
|
||||
<span>
|
||||
Awaiting board review
|
||||
{data.budgets.pendingApprovals > 0
|
||||
? `${data.budgets.pendingApprovals} budget overrides awaiting board review`
|
||||
: "Awaiting board review"}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { relativeTime, cn, formatTokens } from "../lib/utils";
|
||||
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { CommentThread } from "../components/CommentThread";
|
||||
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
||||
@@ -417,9 +417,7 @@ export function IssueDetail() {
|
||||
"cached_input_tokens",
|
||||
"cache_read_input_tokens",
|
||||
);
|
||||
const runCost =
|
||||
usageNumber(usage, "costUsd", "cost_usd", "total_cost_usd") ||
|
||||
usageNumber(result, "total_cost_usd", "cost_usd", "costUsd");
|
||||
const runCost = visibleRunCostUsd(usage, result);
|
||||
if (runCost > 0) hasCost = true;
|
||||
if (runInput + runOutput + runCached > 0) hasTokens = true;
|
||||
input += runInput;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { PROJECT_COLORS, isUuidLike } from "@paperclipai/shared";
|
||||
import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary } from "@paperclipai/shared";
|
||||
import { budgetsApi } from "../api/budgets";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { agentsApi } from "../api/agents";
|
||||
@@ -14,6 +15,7 @@ import { queryKeys } from "../lib/queryKeys";
|
||||
import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
||||
import { IssuesList } from "../components/IssuesList";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
@@ -24,7 +26,7 @@ import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slo
|
||||
|
||||
/* ── Top-level tab types ── */
|
||||
|
||||
type ProjectBaseTab = "overview" | "list" | "configuration";
|
||||
type ProjectBaseTab = "overview" | "list" | "configuration" | "budget";
|
||||
type ProjectPluginTab = `plugin:${string}`;
|
||||
type ProjectTab = ProjectBaseTab | ProjectPluginTab;
|
||||
|
||||
@@ -39,6 +41,7 @@ function resolveProjectTab(pathname: string, projectId: string): ProjectTab | nu
|
||||
const tab = segments[projectsIdx + 2];
|
||||
if (tab === "overview") return "overview";
|
||||
if (tab === "configuration") return "configuration";
|
||||
if (tab === "budget") return "budget";
|
||||
if (tab === "issues") return "list";
|
||||
return null;
|
||||
}
|
||||
@@ -296,6 +299,14 @@ export function ProjectDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
const { data: budgetOverview } = useQuery({
|
||||
queryKey: queryKeys.budgets.overview(resolvedCompanyId ?? "__none__"),
|
||||
queryFn: () => budgetsApi.overview(resolvedCompanyId!),
|
||||
enabled: !!resolvedCompanyId,
|
||||
refetchInterval: 30_000,
|
||||
staleTime: 5_000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: "Projects", href: "/projects" },
|
||||
@@ -318,6 +329,10 @@ export function ProjectDetail() {
|
||||
navigate(`/projects/${canonicalProjectRef}/configuration`, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (activeTab === "budget") {
|
||||
navigate(`/projects/${canonicalProjectRef}/budget`, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (activeTab === "list") {
|
||||
if (filter) {
|
||||
navigate(`/projects/${canonicalProjectRef}/issues/${filter}`, { replace: true });
|
||||
@@ -377,6 +392,53 @@ export function ProjectDetail() {
|
||||
}
|
||||
}, [invalidateProject, lookupCompanyId, projectLookupRef, resolvedCompanyId, scheduleFieldReset, setFieldState]);
|
||||
|
||||
const projectBudgetSummary = useMemo(() => {
|
||||
const matched = budgetOverview?.policies.find(
|
||||
(policy) => policy.scopeType === "project" && policy.scopeId === (project?.id ?? routeProjectRef),
|
||||
);
|
||||
if (matched) return matched;
|
||||
return {
|
||||
policyId: "",
|
||||
companyId: resolvedCompanyId ?? "",
|
||||
scopeType: "project",
|
||||
scopeId: project?.id ?? routeProjectRef,
|
||||
scopeName: project?.name ?? "Project",
|
||||
metric: "billed_cents",
|
||||
windowKind: "lifetime",
|
||||
amount: 0,
|
||||
observedAmount: 0,
|
||||
remainingAmount: 0,
|
||||
utilizationPercent: 0,
|
||||
warnPercent: 80,
|
||||
hardStopEnabled: true,
|
||||
notifyEnabled: true,
|
||||
isActive: false,
|
||||
status: "ok",
|
||||
paused: Boolean(project?.pausedAt),
|
||||
pauseReason: project?.pauseReason ?? null,
|
||||
windowStart: new Date(),
|
||||
windowEnd: new Date(),
|
||||
} satisfies BudgetPolicySummary;
|
||||
}, [budgetOverview?.policies, project, resolvedCompanyId, routeProjectRef]);
|
||||
|
||||
const budgetMutation = useMutation({
|
||||
mutationFn: (amount: number) =>
|
||||
budgetsApi.upsertPolicy(resolvedCompanyId!, {
|
||||
scopeType: "project",
|
||||
scopeId: project?.id ?? routeProjectRef,
|
||||
amount,
|
||||
windowKind: "lifetime",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
if (!resolvedCompanyId) return;
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.budgets.overview(resolvedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(routeProjectRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectLookupRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(resolvedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(resolvedCompanyId) });
|
||||
},
|
||||
});
|
||||
|
||||
if (pluginTabFromSearch && !pluginDetailSlotsLoading && !activePluginTab) {
|
||||
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
|
||||
}
|
||||
@@ -397,6 +459,8 @@ export function ProjectDetail() {
|
||||
}
|
||||
if (tab === "overview") {
|
||||
navigate(`/projects/${canonicalProjectRef}/overview`);
|
||||
} else if (tab === "budget") {
|
||||
navigate(`/projects/${canonicalProjectRef}/budget`);
|
||||
} else if (tab === "configuration") {
|
||||
navigate(`/projects/${canonicalProjectRef}/configuration`);
|
||||
} else {
|
||||
@@ -413,12 +477,20 @@ export function ProjectDetail() {
|
||||
onSelect={(color) => updateProject.mutate({ color })}
|
||||
/>
|
||||
</div>
|
||||
<InlineEditor
|
||||
value={project.name}
|
||||
onSave={(name) => updateProject.mutate({ name })}
|
||||
as="h2"
|
||||
className="text-xl font-bold"
|
||||
/>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<InlineEditor
|
||||
value={project.name}
|
||||
onSave={(name) => updateProject.mutate({ name })}
|
||||
as="h2"
|
||||
className="text-xl font-bold"
|
||||
/>
|
||||
{project.pauseReason === "budget" ? (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-red-500/30 bg-red-500/10 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.18em] text-red-200">
|
||||
<span className="h-2 w-2 rounded-full bg-red-400" />
|
||||
Paused by budget hard stop
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PluginSlotOutlet
|
||||
@@ -458,6 +530,7 @@ export function ProjectDetail() {
|
||||
{ value: "overview", label: "Overview" },
|
||||
{ value: "list", label: "List" },
|
||||
{ value: "configuration", label: "Configuration" },
|
||||
{ value: "budget", label: "Budget" },
|
||||
...pluginTabItems.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
@@ -497,6 +570,17 @@ export function ProjectDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "budget" && resolvedCompanyId ? (
|
||||
<div className="max-w-3xl">
|
||||
<BudgetPolicyCard
|
||||
summary={projectBudgetSummary}
|
||||
variant="plain"
|
||||
isSaving={budgetMutation.isPending}
|
||||
onSave={(amount) => budgetMutation.mutate(amount)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activePluginTab && (
|
||||
<PluginSlotMount
|
||||
slot={activePluginTab.slot}
|
||||
|
||||
Reference in New Issue
Block a user