"use client"; import { useState, useEffect } from "react"; import { useTranslations } from "next-intl"; import { Modal } from "@/components/ui/modal"; /** * Format remaining budget as CHF. Same adaptive precision rule as the * usage display: 2 decimals for amounts ≥ 1, 4 for smaller values * so per-request residuals don't round to zero. The currency comes * from LiteLLM via our CHF pricing config — see chf() in * usage-display.tsx for the full reasoning. */ function formatRemaining(n: number): string { const decimals = Math.abs(n) >= 1 ? 2 : 4; return `CHF ${n.toFixed(decimals)}`; } interface Props { tenantName: string; maxBudget: number | null; remaining: number | null; budgetDuration: string | null; /** Called after a successful save so the parent re-fetches usage. */ onSaved: () => void; } /** * Clickable Budget StatCard with edit modal (Feature 7). * * The display side mirrors the read-only StatCard layout exactly so * the grid stays uniform. The "click to edit" hint is implicit via * hover state — a "Set" / "Edit" link in the corner would be louder * but adds clutter on a tile that's already busy. Customers who * mouse over discover it. * * Important UX note shown in the modal: the budget is org-scoped, * not per-tenant. All tenants in the same ZITADEL org share the * underlying LiteLLM team. Without that callout, a customer with * multiple tenants might think they're capping just one. */ export function BudgetEditableCard({ tenantName, maxBudget, remaining, budgetDuration, onSaved, }: Props) { const t = useTranslations("usage"); const tCommon = useTranslations("common"); const [open, setOpen] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(""); // Form state. Mode = "unlimited" | "capped". When unlimited, the // duration dropdown is hidden because LiteLLM's reset cadence is // meaningless without a cap. const [mode, setMode] = useState<"unlimited" | "capped">( maxBudget !== null ? "capped" : "unlimited" ); const [budgetInput, setBudgetInput] = useState( maxBudget !== null ? String(maxBudget) : "" ); const [duration, setDuration] = useState<"30d" | "1mo" | "1y">( (budgetDuration === "30d" || budgetDuration === "1mo" || budgetDuration === "1y") ? budgetDuration : "1mo" ); // Reset form when modal opens — picks up any change made elsewhere // (e.g. another browser tab) since this card was last re-rendered. useEffect(() => { if (open) { setMode(maxBudget !== null ? "capped" : "unlimited"); setBudgetInput(maxBudget !== null ? String(maxBudget) : ""); setDuration( (budgetDuration === "30d" || budgetDuration === "1mo" || budgetDuration === "1y") ? budgetDuration : "1mo" ); setError(""); } }, [open, maxBudget, budgetDuration]); const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); setSaving(true); setError(""); try { let body: { maxBudget: number | null; budgetDuration: string | null }; if (mode === "unlimited") { body = { maxBudget: null, budgetDuration: null }; } else { const parsed = parseFloat(budgetInput); if (!Number.isFinite(parsed) || parsed <= 0) { throw new Error(t("budgetInvalid")); } body = { maxBudget: parsed, budgetDuration: duration }; } const res = await fetch( `/api/tenants/${encodeURIComponent(tenantName)}/budget`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), } ); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || t("budgetSaveFailed")); } setOpen(false); onSaved(); } catch (e: any) { setError(e.message); } finally { setSaving(false); } }; return ( <> setOpen(false)} ariaLabel={t("budgetEditTitle")}>

{t("budgetEditTitle")}

{t("budgetEditDescription")}

{/* Mode toggle: unlimited vs capped. Two radios are clearer than a single "max" field where 0 means unlimited (which would conflict with our zod validation requiring positive). */}
{mode === "capped" && (
CHF setBudgetInput(e.target.value)} className="w-full pl-12 pr-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary" />
)} {error && (
{error}
)}
); }