diff --git a/frontend/src/index.css b/frontend/src/index.css index 7e10df8..34a2599 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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); } diff --git a/frontend/src/routes/budgets/List.tsx b/frontend/src/routes/budgets/List.tsx index 8ac387e..8a97a3b 100644 --- a/frontend/src/routes/budgets/List.tsx +++ b/frontend/src/routes/budgets/List.tsx @@ -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([]); const [creating, setCreating] = useState(false); @@ -13,13 +15,55 @@ export default function BudgetList() { recipients: "", }); + const [workspaces, setWorkspaces] = useState([]); + const [teams, setTeams] = useState([]); + const [projects, setProjects] = useState([]); + const [nameMap, setNameMap] = useState>({}); + + 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 = {}; + + 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 (
@@ -62,34 +118,79 @@ export default function BudgetList() { {creating && (
Create budget +
Scope Type - setForm(f => ({ ...f, scope_type: e.target.value, scope_id: "" }))} + > + + +
+
- Scope ID - setForm(f => ({ ...f, scope_id: e.target.value }))} /> + + {form.scope_type === "workspace" ? "Workspace" : form.scope_type === "team" ? "Team" : "Project"} + + {options.length > 0 ? ( + + ) : ( + setForm(f => ({ ...f, scope_id: e.target.value }))} + /> + )}
+
Monthly Limit (USD) - setForm(f => ({ ...f, amount_usd: e.target.value }))} /> + setForm(f => ({ ...f, amount_usd: e.target.value }))} + />
+ +
Thresholds - setForm(f => ({ ...f, alert_thresholds: e.target.value }))} /> + setForm(f => ({ ...f, alert_thresholds: e.target.value }))} + />
+
Recipients - setForm(f => ({ ...f, recipients: e.target.value }))} /> + setForm(f => ({ ...f, recipients: e.target.value }))} + />
+
@@ -100,7 +201,7 @@ export default function BudgetList() { Scope Type - Scope ID + Name Limit (USD) Spent (USD) Usage @@ -114,10 +215,16 @@ export default function BudgetList() { )} {items.map(b => { const p = pct(b); + const name = scopeName(b.scope_id); return ( {b.scope_type} - {b.scope_id} + + {name} + {name !== b.scope_id && ( + · {b.scope_id.slice(0, 8)}… + )} + ${b.amount_usd.toFixed(2)} {b.current_spent_usd != null ? `$${b.current_spent_usd.toFixed(4)}` : "—"}