Add budget tabs and sidebar budget indicators
This commit is contained in:
@@ -133,6 +133,7 @@ function boardRoutes() {
|
|||||||
<Route path="projects/:projectId/issues" element={<ProjectDetail />} />
|
<Route path="projects/:projectId/issues" element={<ProjectDetail />} />
|
||||||
<Route path="projects/:projectId/issues/:filter" element={<ProjectDetail />} />
|
<Route path="projects/:projectId/issues/:filter" element={<ProjectDetail />} />
|
||||||
<Route path="projects/:projectId/configuration" element={<ProjectDetail />} />
|
<Route path="projects/:projectId/configuration" element={<ProjectDetail />} />
|
||||||
|
<Route path="projects/:projectId/budget" element={<ProjectDetail />} />
|
||||||
<Route path="issues" element={<Issues />} />
|
<Route path="issues" element={<Issues />} />
|
||||||
<Route path="issues/all" element={<Navigate to="/issues" replace />} />
|
<Route path="issues/all" element={<Navigate to="/issues" replace />} />
|
||||||
<Route path="issues/active" element={<Navigate to="/issues" replace />} />
|
<Route path="issues/active" element={<Navigate to="/issues" replace />} />
|
||||||
|
|||||||
@@ -33,11 +33,13 @@ export function BudgetPolicyCard({
|
|||||||
onSave,
|
onSave,
|
||||||
isSaving,
|
isSaving,
|
||||||
compact = false,
|
compact = false,
|
||||||
|
variant = "card",
|
||||||
}: {
|
}: {
|
||||||
summary: BudgetPolicySummary;
|
summary: BudgetPolicySummary;
|
||||||
onSave?: (amountCents: number) => void;
|
onSave?: (amountCents: number) => void;
|
||||||
isSaving?: boolean;
|
isSaving?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
variant?: "card" | "plain";
|
||||||
}) {
|
}) {
|
||||||
const [draftBudget, setDraftBudget] = useState(centsInputValue(summary.amount));
|
const [draftBudget, setDraftBudget] = useState(centsInputValue(summary.amount));
|
||||||
|
|
||||||
@@ -49,6 +51,142 @@ export function BudgetPolicyCard({
|
|||||||
const canSave = typeof parsedDraft === "number" && parsedDraft !== summary.amount && Boolean(onSave);
|
const canSave = typeof parsedDraft === "number" && parsedDraft !== summary.amount && Boolean(onSave);
|
||||||
const progress = summary.amount > 0 ? Math.min(100, summary.utilizationPercent) : 0;
|
const progress = summary.amount > 0 ? Math.min(100, summary.utilizationPercent) : 0;
|
||||||
const StatusIcon = summary.status === "hard_stop" ? ShieldAlert : summary.status === "warning" ? AlertTriangle : Wallet;
|
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 (
|
return (
|
||||||
<Card className={cn("overflow-hidden border-border/70 bg-card/80", compact ? "" : "shadow-[0_20px_80px_-40px_rgba(0,0,0,0.55)]")}>
|
<Card className={cn("overflow-hidden border-border/70 bg-card/80", compact ? "" : "shadow-[0_20px_80px_-40px_rgba(0,0,0,0.55)]")}>
|
||||||
@@ -68,84 +206,12 @@ export function BudgetPolicyCard({
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className={cn("space-y-4", compact ? "px-4 pb-4 pt-0" : "px-5 pb-5 pt-0")}>
|
<CardContent className={cn("space-y-4", compact ? "px-4 pb-4 pt-0" : "px-5 pb-5 pt-0")}>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
{observedBudgetGrid}
|
||||||
<div className="rounded-xl border border-border/70 bg-black/[0.18] px-4 py-3">
|
{progressSection}
|
||||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Observed</div>
|
{pausedPane}
|
||||||
<div className="mt-2 text-xl font-semibold tabular-nums">{formatCents(summary.observedAmount)}</div>
|
{saveSection}
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
{parsedDraft === null ? (
|
||||||
{summary.amount > 0 ? `${summary.utilizationPercent}% of limit` : "No cap configured"}
|
<p className="text-xs text-destructive">Enter a valid non-negative dollar amount.</p>
|
||||||
</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>
|
|
||||||
|
|
||||||
<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="h-2 overflow-hidden rounded-full 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>
|
|
||||||
|
|
||||||
{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}
|
|
||||||
|
|
||||||
{onSave ? (
|
|
||||||
<div className="rounded-xl border border-border/70 bg-background/50 p-3">
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
|
||||||
<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>
|
|
||||||
{parsedDraft === null ? (
|
|
||||||
<p className="mt-2 text-xs text-destructive">Enter a valid non-negative dollar amount.</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { heartbeatsApi } from "../api/heartbeats";
|
|||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { cn, agentRouteRef, agentUrl } from "../lib/utils";
|
import { cn, agentRouteRef, agentUrl } from "../lib/utils";
|
||||||
import { AgentIcon } from "./AgentIconPicker";
|
import { AgentIcon } from "./AgentIconPicker";
|
||||||
|
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
@@ -124,15 +125,22 @@ export function SidebarAgents() {
|
|||||||
>
|
>
|
||||||
<AgentIcon icon={agent.icon} className="shrink-0 h-3.5 w-3.5 text-muted-foreground" />
|
<AgentIcon icon={agent.icon} className="shrink-0 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<span className="flex-1 truncate">{agent.name}</span>
|
<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="ml-auto flex items-center gap-1.5 shrink-0">
|
||||||
<span className="relative flex h-2 w-2">
|
{agent.pauseReason === "budget" ? (
|
||||||
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
<BudgetSidebarMarker title="Agent paused by budget" />
|
||||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
) : null}
|
||||||
</span>
|
{runCount > 0 ? (
|
||||||
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">
|
<span className="relative flex h-2 w-2">
|
||||||
{runCount} live
|
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||||
</span>
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { projectsApi } from "../api/projects";
|
|||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { cn, projectRouteRef } from "../lib/utils";
|
import { cn, projectRouteRef } from "../lib/utils";
|
||||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||||
|
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
@@ -88,6 +89,7 @@ function SortableProjectItem({
|
|||||||
style={{ backgroundColor: project.color ?? "#6366f1" }}
|
style={{ backgroundColor: project.color ?? "#6366f1" }}
|
||||||
/>
|
/>
|
||||||
<span className="flex-1 truncate">{project.name}</span>
|
<span className="flex-1 truncate">{project.name}</span>
|
||||||
|
{project.pauseReason === "budget" ? <BudgetSidebarMarker title="Project paused by budget" /> : null}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
{projectSidebarSlots.length > 0 && (
|
{projectSidebarSlots.length > 0 && (
|
||||||
<div className="ml-5 flex flex-col gap-0.5">
|
<div className="ml-5 flex flex-col gap-0.5">
|
||||||
|
|||||||
@@ -185,10 +185,11 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh
|
|||||||
container.scrollTo({ top: container.scrollHeight, behavior });
|
container.scrollTo({ top: container.scrollHeight, behavior });
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentDetailView = "dashboard" | "configuration" | "runs";
|
type AgentDetailView = "dashboard" | "configuration" | "runs" | "budget";
|
||||||
|
|
||||||
function parseAgentDetailView(value: string | null): AgentDetailView {
|
function parseAgentDetailView(value: string | null): AgentDetailView {
|
||||||
if (value === "configure" || value === "configuration") return "configuration";
|
if (value === "configure" || value === "configuration") return "configuration";
|
||||||
|
if (value === "budget") return "budget";
|
||||||
if (value === "runs") return value;
|
if (value === "runs") return value;
|
||||||
return "dashboard";
|
return "dashboard";
|
||||||
}
|
}
|
||||||
@@ -638,6 +639,7 @@ export function AgentDetail() {
|
|||||||
{ value: "dashboard", label: "Dashboard" },
|
{ value: "dashboard", label: "Dashboard" },
|
||||||
{ value: "configuration", label: "Configuration" },
|
{ value: "configuration", label: "Configuration" },
|
||||||
{ value: "runs", label: "Runs" },
|
{ value: "runs", label: "Runs" },
|
||||||
|
{ value: "budget", label: "Budget" },
|
||||||
]}
|
]}
|
||||||
value={activeView}
|
value={activeView}
|
||||||
onValueChange={(value) => navigate(`/agents/${canonicalAgentRef}/${value}`)}
|
onValueChange={(value) => navigate(`/agents/${canonicalAgentRef}/${value}`)}
|
||||||
@@ -645,15 +647,6 @@ export function AgentDetail() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!urlRunId && resolvedCompanyId ? (
|
|
||||||
<BudgetPolicyCard
|
|
||||||
summary={agentBudgetSummary}
|
|
||||||
isSaving={budgetMutation.isPending}
|
|
||||||
compact
|
|
||||||
onSave={(amount) => budgetMutation.mutate(amount)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
||||||
{isPendingApproval && (
|
{isPendingApproval && (
|
||||||
<p className="text-sm text-amber-500">
|
<p className="text-sm text-amber-500">
|
||||||
@@ -752,6 +745,17 @@ export function AgentDetail() {
|
|||||||
adapterType={agent.adapterType}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slo
|
|||||||
|
|
||||||
/* ── Top-level tab types ── */
|
/* ── Top-level tab types ── */
|
||||||
|
|
||||||
type ProjectBaseTab = "overview" | "list" | "configuration";
|
type ProjectBaseTab = "overview" | "list" | "configuration" | "budget";
|
||||||
type ProjectPluginTab = `plugin:${string}`;
|
type ProjectPluginTab = `plugin:${string}`;
|
||||||
type ProjectTab = ProjectBaseTab | ProjectPluginTab;
|
type ProjectTab = ProjectBaseTab | ProjectPluginTab;
|
||||||
|
|
||||||
@@ -41,6 +41,7 @@ function resolveProjectTab(pathname: string, projectId: string): ProjectTab | nu
|
|||||||
const tab = segments[projectsIdx + 2];
|
const tab = segments[projectsIdx + 2];
|
||||||
if (tab === "overview") return "overview";
|
if (tab === "overview") return "overview";
|
||||||
if (tab === "configuration") return "configuration";
|
if (tab === "configuration") return "configuration";
|
||||||
|
if (tab === "budget") return "budget";
|
||||||
if (tab === "issues") return "list";
|
if (tab === "issues") return "list";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -328,6 +329,10 @@ export function ProjectDetail() {
|
|||||||
navigate(`/projects/${canonicalProjectRef}/configuration`, { replace: true });
|
navigate(`/projects/${canonicalProjectRef}/configuration`, { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (activeTab === "budget") {
|
||||||
|
navigate(`/projects/${canonicalProjectRef}/budget`, { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (activeTab === "list") {
|
if (activeTab === "list") {
|
||||||
if (filter) {
|
if (filter) {
|
||||||
navigate(`/projects/${canonicalProjectRef}/issues/${filter}`, { replace: true });
|
navigate(`/projects/${canonicalProjectRef}/issues/${filter}`, { replace: true });
|
||||||
@@ -454,6 +459,8 @@ export function ProjectDetail() {
|
|||||||
}
|
}
|
||||||
if (tab === "overview") {
|
if (tab === "overview") {
|
||||||
navigate(`/projects/${canonicalProjectRef}/overview`);
|
navigate(`/projects/${canonicalProjectRef}/overview`);
|
||||||
|
} else if (tab === "budget") {
|
||||||
|
navigate(`/projects/${canonicalProjectRef}/budget`);
|
||||||
} else if (tab === "configuration") {
|
} else if (tab === "configuration") {
|
||||||
navigate(`/projects/${canonicalProjectRef}/configuration`);
|
navigate(`/projects/${canonicalProjectRef}/configuration`);
|
||||||
} else {
|
} else {
|
||||||
@@ -470,12 +477,20 @@ export function ProjectDetail() {
|
|||||||
onSelect={(color) => updateProject.mutate({ color })}
|
onSelect={(color) => updateProject.mutate({ color })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<InlineEditor
|
<div className="min-w-0 space-y-2">
|
||||||
value={project.name}
|
<InlineEditor
|
||||||
onSave={(name) => updateProject.mutate({ name })}
|
value={project.name}
|
||||||
as="h2"
|
onSave={(name) => updateProject.mutate({ name })}
|
||||||
className="text-xl font-bold"
|
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>
|
</div>
|
||||||
|
|
||||||
<PluginSlotOutlet
|
<PluginSlotOutlet
|
||||||
@@ -515,6 +530,7 @@ export function ProjectDetail() {
|
|||||||
{ value: "overview", label: "Overview" },
|
{ value: "overview", label: "Overview" },
|
||||||
{ value: "list", label: "List" },
|
{ value: "list", label: "List" },
|
||||||
{ value: "configuration", label: "Configuration" },
|
{ value: "configuration", label: "Configuration" },
|
||||||
|
{ value: "budget", label: "Budget" },
|
||||||
...pluginTabItems.map((item) => ({
|
...pluginTabItems.map((item) => ({
|
||||||
value: item.value,
|
value: item.value,
|
||||||
label: item.label,
|
label: item.label,
|
||||||
@@ -526,15 +542,6 @@ export function ProjectDetail() {
|
|||||||
/>
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{resolvedCompanyId ? (
|
|
||||||
<BudgetPolicyCard
|
|
||||||
summary={projectBudgetSummary}
|
|
||||||
compact
|
|
||||||
isSaving={budgetMutation.isPending}
|
|
||||||
onSave={(amount) => budgetMutation.mutate(amount)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{activeTab === "overview" && (
|
{activeTab === "overview" && (
|
||||||
<OverviewContent
|
<OverviewContent
|
||||||
project={project}
|
project={project}
|
||||||
@@ -563,6 +570,17 @@ export function ProjectDetail() {
|
|||||||
</div>
|
</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 && (
|
{activePluginTab && (
|
||||||
<PluginSlotMount
|
<PluginSlotMount
|
||||||
slot={activePluginTab.slot}
|
slot={activePluginTab.slot}
|
||||||
|
|||||||
Reference in New Issue
Block a user