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:
parent
fe65b3b309
commit
327deed1dd
2 changed files with 123 additions and 13 deletions
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue