From c7ab4c6b4eb5b4b403bae1dee5de13c1d7f3035e Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 2 May 2026 22:33:35 +0200 Subject: [PATCH] Budget setting and all dollar to chf --- .../dashboard/budget-editable-card.tsx | 262 ++++++++++++++++++ src/components/dashboard/usage-display.tsx | 74 ++++- 2 files changed, 327 insertions(+), 9 deletions(-) create mode 100644 src/components/dashboard/budget-editable-card.tsx diff --git a/src/components/dashboard/budget-editable-card.tsx b/src/components/dashboard/budget-editable-card.tsx new file mode 100644 index 0000000..1f76bc9 --- /dev/null +++ b/src/components/dashboard/budget-editable-card.tsx @@ -0,0 +1,262 @@ +"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 ( + <> + + + {open && ( + setOpen(false)} ariaLabel={t("budgetEditTitle")}> +

+ {t("budgetEditTitle")} +

+

+ {t("budgetEditDescription")} +

+
+ {t("budgetOrgScopeWarning")} +
+ +
+ {/* 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} +
+ )} + +
+ + +
+
+
+ )} + + ); +} diff --git a/src/components/dashboard/usage-display.tsx b/src/components/dashboard/usage-display.tsx index 02e54d1..9cbb081 100644 --- a/src/components/dashboard/usage-display.tsx +++ b/src/components/dashboard/usage-display.tsx @@ -2,6 +2,7 @@ import { useTranslations } from "next-intl"; import { useEffect, useState, useCallback } from "react"; +import { BudgetEditableCard } from "@/components/dashboard/budget-editable-card"; interface DailyUsage { date: string; @@ -18,7 +19,17 @@ interface UsageData { totalSpend: number; requestCount: number; }; - budget: { maxBudget: number | null; spend: number; remaining: number | null }; + budget: { + maxBudget: number | null; + spend: number; + remaining: number | null; + /** + * Feature 7: budget reset cadence as stored on LiteLLM. + * Strings: "30d" / "1mo" / "1y" / null (no reset). UI maps these + * to user-friendly labels. + */ + budgetDuration: string | null; + }; rateLimits: { rpm: number | null; tpm: number | null }; dailyUsage: DailyUsage[]; } @@ -29,8 +40,31 @@ function fmt(n: number): string { return n.toString(); } -function usd(n: number): string { - return `$${n.toFixed(4)}`; +/** + * Format a numeric amount as CHF. + * + * Note on currency labelling: LiteLLM stores raw cost numbers it + * receives from upstream (OpenAI/Anthropic), which originate as USD. + * The PieCed pricing config (Slice 5) converts those numbers to + * CHF before LiteLLM persists them, so the values flowing through + * here are already CHF amounts. We label them as such in the UI; + * "USD" or "$" anywhere in the customer-facing experience would + * be misleading. + * + * Precision is adaptive: + * - Amounts ≥ 1 CHF: 2 decimals (typical money formatting). + * - Smaller amounts: 4 decimals — per-request inference costs are + * routinely sub-rappen, and rounding to 2dp + * would render CHF 0.0042 as "CHF 0.00", + * which obscures real costs from customers + * looking at the daily breakdown. + * + * This is a customer-facing display helper; for storage and + * comparisons keep using the raw number. + */ +function chf(n: number): string { + const decimals = Math.abs(n) >= 1 ? 2 : 4; + return `CHF ${n.toFixed(decimals)}`; } function getCurrentMonth(): string { @@ -69,7 +103,7 @@ function UsageChart({ data }: { data: DailyUsage[] }) { const x = i * (barW + 2); return ( - {d.date}: {fmt(d.inputTokens)} in / {fmt(d.outputTokens)} out — {usd(d.spend)} + {d.date}: {fmt(d.inputTokens)} in / {fmt(d.outputTokens)} out — {chf(d.spend)} {i % 7 === 0 && ( @@ -113,10 +147,18 @@ export function UsageDisplay({ tenant, teamId, keyAlias, + canEditBudget = false, }: { tenant?: string | null; teamId?: string | null; keyAlias?: string | null; + /** + * Feature 7: when true, the Budget StatCard becomes clickable and + * opens the budget editor. Off by default — owners and platform + * admins get it on; `user` role customers see the budget read-only. + * Server component decides this via canMutate(user). + */ + canEditBudget?: boolean; }) { const t = useTranslations("usage"); const [month, setMonth] = useState(getCurrentMonth); @@ -185,11 +227,25 @@ export function UsageDisplay({
- - + + {canEditBudget && tenant ? ( + + ) : ( + + )}