feat: budgets scope picker with live dropdowns

Workspace/team/project selects are populated from API on mount.
Falls back to source_apps when projects collection is empty.
Table now shows resolved name + truncated ID hint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-04-27 16:25:17 +01:00
parent fe65b3b309
commit 327deed1dd
2 changed files with 123 additions and 13 deletions

View file

@ -809,6 +809,9 @@ input, button, textarea, select { font: inherit; }
}
.form-input-wide { width: 240px; }
.form-select-wide { width: 240px; }
.scope-name { font-weight: 500; color: #1e293b; }
.scope-id-hint { font-size: 11px; color: #94a3b8; font-family: var(--font-mono, monospace); }
.form-input-sm { height: 28px; padding: 0 8px; font-size: 12px; min-width: 0; width: 100%; }
.row-editing td { background: rgba(99, 102, 241, 0.03); }

View file

@ -1,6 +1,8 @@
import { useEffect, useState } from "react";
import { api } from "../../lib/api";
interface ScopeOption { id: string; label: string; }
export default function BudgetList() {
const [items, setItems] = useState<any[]>([]);
const [creating, setCreating] = useState(false);
@ -13,13 +15,55 @@ export default function BudgetList() {
recipients: "",
});
const [workspaces, setWorkspaces] = useState<ScopeOption[]>([]);
const [teams, setTeams] = useState<ScopeOption[]>([]);
const [projects, setProjects] = useState<ScopeOption[]>([]);
const [nameMap, setNameMap] = useState<Record<string, string>>({});
useEffect(() => {
load();
loadScopes();
}, []);
async function loadScopes() {
const [ws, ts, ps] = await Promise.allSettled([
api.get("/workspaces"),
api.get("/teams"),
api.get("/projects"),
]);
const map: Record<string, string> = {};
const wsItems: ScopeOption[] = ws.status === "fulfilled"
? (ws.value.data ?? []).map((w: any) => { map[w._id] = w.name || w.slug; return { id: w._id, label: w.name || w.slug }; })
: [];
const tsItems: ScopeOption[] = ts.status === "fulfilled"
? (ts.value.data?.items ?? ts.value.data ?? []).map((t: any) => { map[t._id] = t.name || t.slug; return { id: t._id, label: t.name || t.slug }; })
: [];
const psItems: ScopeOption[] = ps.status === "fulfilled"
? (ps.value.data ?? []).map((p: any) => { map[p._id] = p.name || p.slug; return { id: p._id, label: p.name || p.slug }; })
: [];
// Fallback: use source_apps as projects if projects collection is empty
if (psItems.length === 0) {
const sa = await api.get("/analytics/breakdown?dim=source_app&limit=50").catch(() => ({ data: [] }));
for (const x of sa.data ?? []) {
if (x.name) { map[x.name] = x.name; psItems.push({ id: x.name, label: x.name }); }
}
}
setWorkspaces(wsItems);
setTeams(tsItems);
setProjects(psItems);
setNameMap(map);
}
async function load() {
const r = await api.get("/budgets");
setItems(r.data);
}
useEffect(() => { load(); }, []);
async function create(e: React.FormEvent) {
e.preventDefault();
await api.post("/budgets", {
@ -34,6 +78,16 @@ export default function BudgetList() {
load();
}
function scopeOptions(): ScopeOption[] {
if (form.scope_type === "workspace") return workspaces;
if (form.scope_type === "team") return teams;
return projects;
}
function scopeName(id: string) {
return nameMap[id] || id;
}
function pct(item: any) {
if (!item.current_spent_usd || !item.amount_usd) return 0;
return Math.round((item.current_spent_usd / item.amount_usd) * 100);
@ -45,6 +99,8 @@ export default function BudgetList() {
return "progress-fill";
}
const options = scopeOptions();
return (
<div className="page">
<div className="page-header">
@ -62,34 +118,79 @@ export default function BudgetList() {
{creating && (
<form className="inline-form" onSubmit={create}>
<span className="inline-form-title">Create budget</span>
<div className="form-field">
<span className="form-label">Scope Type</span>
<select className="form-select" value={form.scope_type} onChange={e => setForm(f => ({ ...f, scope_type: e.target.value }))}>
<option value="workspace">workspace</option>
<option value="team">team</option>
<option value="project">project</option>
<select
className="form-select"
value={form.scope_type}
onChange={e => setForm(f => ({ ...f, scope_type: e.target.value, scope_id: "" }))}
>
<option value="workspace">Workspace</option>
<option value="team">Team</option>
<option value="project">Project / App</option>
</select>
</div>
<div className="form-field">
<span className="form-label">Scope ID</span>
<input required className="form-input form-input-wide" placeholder="MongoDB _id" value={form.scope_id} onChange={e => setForm(f => ({ ...f, scope_id: e.target.value }))} />
<span className="form-label">
{form.scope_type === "workspace" ? "Workspace" : form.scope_type === "team" ? "Team" : "Project"}
</span>
{options.length > 0 ? (
<select
required
className="form-select form-select-wide"
value={form.scope_id}
onChange={e => setForm(f => ({ ...f, scope_id: e.target.value }))}
>
<option value=""> select </option>
{options.map(o => (
<option key={o.id} value={o.id}>{o.label}</option>
))}
</select>
) : (
<input
required
className="form-input form-input-wide"
placeholder="No entries found — enter ID manually"
value={form.scope_id}
onChange={e => setForm(f => ({ ...f, scope_id: e.target.value }))}
/>
)}
</div>
<div className="form-field">
<span className="form-label">Monthly Limit (USD)</span>
<input required type="number" step="0.01" className="form-input" placeholder="100.00" value={form.amount_usd} onChange={e => setForm(f => ({ ...f, amount_usd: e.target.value }))} />
<input
required type="number" step="0.01" className="form-input"
placeholder="100.00" value={form.amount_usd}
onChange={e => setForm(f => ({ ...f, amount_usd: e.target.value }))}
/>
</div>
<label className="check-label">
<input type="checkbox" checked={form.hard_limit} onChange={e => setForm(f => ({ ...f, hard_limit: e.target.checked }))} />
Hard limit (block calls)
</label>
<div className="form-field">
<span className="form-label">Thresholds</span>
<input className="form-input" placeholder="0.5,0.8,1.0" value={form.alert_thresholds} onChange={e => setForm(f => ({ ...f, alert_thresholds: e.target.value }))} />
<input
className="form-input" placeholder="0.5,0.8,1.0"
value={form.alert_thresholds}
onChange={e => setForm(f => ({ ...f, alert_thresholds: e.target.value }))}
/>
</div>
<div className="form-field">
<span className="form-label">Recipients</span>
<input className="form-input form-input-wide" placeholder="email1@co.com, email2@co.com" value={form.recipients} onChange={e => setForm(f => ({ ...f, recipients: e.target.value }))} />
<input
className="form-input form-input-wide" placeholder="email1@co.com, email2@co.com"
value={form.recipients}
onChange={e => setForm(f => ({ ...f, recipients: e.target.value }))}
/>
</div>
<button type="submit" className="btn btn-primary">Save</button>
<button type="button" className="btn btn-secondary" onClick={() => setCreating(false)}>Cancel</button>
</form>
@ -100,7 +201,7 @@ export default function BudgetList() {
<thead>
<tr>
<th>Scope Type</th>
<th>Scope ID</th>
<th>Name</th>
<th className="num">Limit (USD)</th>
<th className="num">Spent (USD)</th>
<th>Usage</th>
@ -114,10 +215,16 @@ export default function BudgetList() {
)}
{items.map(b => {
const p = pct(b);
const name = scopeName(b.scope_id);
return (
<tr key={b._id}>
<td><span className="badge badge-indigo">{b.scope_type}</span></td>
<td><code className="code-snippet">{b.scope_id}</code></td>
<td>
<span className="scope-name">{name}</span>
{name !== b.scope_id && (
<span className="scope-id-hint" title={b.scope_id}> · {b.scope_id.slice(0, 8)}</span>
)}
</td>
<td className="num">${b.amount_usd.toFixed(2)}</td>
<td className="num">{b.current_spent_usd != null ? `$${b.current_spent_usd.toFixed(4)}` : "—"}</td>
<td>