Compare commits

..

4 Commits

Author SHA1 Message Date
666dd64580 Budget setting and all dollar to chf
All checks were successful
Build and Push / build (push) Successful in 1m33s
2026-05-02 23:25:24 +02:00
188bef2ece Budget setting and all dollar to chf
All checks were successful
Build and Push / build (push) Successful in 1m28s
2026-05-02 23:16:14 +02:00
57258bca92 Budget setting and all dollar to chf
All checks were successful
Build and Push / build (push) Successful in 1m31s
2026-05-02 22:59:51 +02:00
c7ab4c6b4e Budget setting and all dollar to chf
All checks were successful
Build and Push / build (push) Successful in 1m28s
2026-05-02 22:33:35 +02:00
7 changed files with 408 additions and 14 deletions

View File

@@ -199,7 +199,7 @@ export default async function TenantDetailPage({
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("usage")}
</h2>
<UsageDisplay tenant={name} />
<UsageDisplay tenant={name} canEditBudget={canEdit} />
</section>
{/* Packages */}

View File

@@ -0,0 +1,278 @@
"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<string>(
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 (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="bg-surface-1 border border-accent/40 rounded-xl p-4 text-left hover:border-accent transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/40 group block w-full"
>
<div className="text-xs text-text-muted mb-1 flex items-center justify-between">
<span>{t("budget")}</span>
<span className="text-[10px] text-accent inline-flex items-center gap-1">
{/* Pencil icon — unambiguous "this is editable" affordance.
Visible at all times (was hover-only before, which on
touch devices and at-a-glance scanning gave no
indication the card was clickable). */}
<svg
xmlns="http://www.w3.org/2000/svg"
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</svg>
{t("budgetEdit")}
</span>
</div>
<div className="text-lg font-semibold text-text-primary tabular-nums">
{remaining !== null ? formatRemaining(remaining) : t("noLimit")}
</div>
</button>
<Modal open={open} onClose={() => setOpen(false)} ariaLabel={t("budgetEditTitle")}>
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("budgetEditTitle")}
</h3>
<p className="text-sm text-text-secondary mb-4">
{t("budgetEditDescription")}
</p>
<div className="text-xs text-amber-400 bg-amber-400/10 border border-amber-400/20 rounded-lg px-3 py-2 mb-5">
{t("budgetOrgScopeWarning")}
</div>
<form onSubmit={onSubmit} className="space-y-4">
{/* 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). */}
<div className="space-y-2">
<label className="flex items-start gap-2 text-sm text-text-primary cursor-pointer">
<input
type="radio"
name="budget-mode"
checked={mode === "unlimited"}
onChange={() => setMode("unlimited")}
className="mt-1"
/>
<span>
<span className="font-medium">{t("budgetModeUnlimited")}</span>
<span className="block text-xs text-text-muted">
{t("budgetModeUnlimitedDescription")}
</span>
</span>
</label>
<label className="flex items-start gap-2 text-sm text-text-primary cursor-pointer">
<input
type="radio"
name="budget-mode"
checked={mode === "capped"}
onChange={() => setMode("capped")}
className="mt-1"
/>
<span>
<span className="font-medium">{t("budgetModeCapped")}</span>
<span className="block text-xs text-text-muted">
{t("budgetModeCappedDescription")}
</span>
</span>
</label>
</div>
{mode === "capped" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2">
<div>
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{t("budgetAmount")} <span className="text-red-400">*</span>
</label>
<div className="relative">
<span className="absolute left-3 top-2 text-sm text-text-muted font-medium">
CHF
</span>
<input
type="number"
min="0.01"
max="1000000"
step="0.01"
required
value={budgetInput}
onChange={(e) => 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"
/>
</div>
</div>
<div>
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{t("budgetResetCadence")}
</label>
<select
value={duration}
onChange={(e) =>
setDuration(e.target.value as "30d" | "1mo" | "1y")
}
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
>
<option value="30d">{t("budgetCadence_30d")}</option>
<option value="1mo">{t("budgetCadence_1mo")}</option>
<option value="1y">{t("budgetCadence_1y")}</option>
</select>
</div>
</div>
)}
{error && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
{error}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={() => setOpen(false)}
disabled={saving}
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
>
{tCommon("cancel")}
</button>
<button
type="submit"
disabled={saving}
className="text-sm px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
>
{saving ? tCommon("loading") : tCommon("save")}
</button>
</div>
</form>
</Modal>
</>
);
}

View File

@@ -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 (
<g key={d.date}>
<title>{d.date}: {fmt(d.inputTokens)} in / {fmt(d.outputTokens)} out {usd(d.spend)}</title>
<title>{d.date}: {fmt(d.inputTokens)} in / {fmt(d.outputTokens)} out {chf(d.spend)}</title>
<rect x={x} y={h - totalH} width={barW} height={totalH - inputH} rx={1} fill="var(--color-accent)" opacity={0.3} />
<rect x={x} y={h - inputH} width={barW} height={inputH} rx={1} fill="var(--color-accent)" opacity={0.7} />
{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({
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label={t("inputTokens")} value={fmt(data.currentPeriod.inputTokens)} />
<StatCard label={t("outputTokens")} value={fmt(data.currentPeriod.outputTokens)} />
<StatCard label={t("totalSpend")} value={usd(data.currentPeriod.totalSpend)} accent />
<StatCard
label={t("budget")}
value={data.budget.remaining !== null ? usd(data.budget.remaining) : t("noLimit")}
/>
<StatCard label={t("totalSpend")} value={chf(data.currentPeriod.totalSpend)} accent />
{canEditBudget && tenant ? (
<BudgetEditableCard
tenantName={tenant}
maxBudget={data.budget.maxBudget}
remaining={data.budget.remaining}
budgetDuration={data.budget.budgetDuration}
onSaved={fetchUsage}
/>
) : (
<StatCard
label={t("budget")}
value={
data.budget.remaining !== null
? chf(data.budget.remaining)
: t("noLimit")
}
/>
)}
</div>
<div className="bg-surface-1 border border-border rounded-xl p-5">

View File

@@ -189,7 +189,22 @@
"last30Days": "Letzte 30 Tage",
"noData": "Keine Nutzungsdaten verfügbar.",
"dailyBreakdown": "Tagesübersicht",
"requests": "Anfragen"
"requests": "Anfragen",
"budgetEdit": "Bearbeiten",
"budgetEditTitle": "Budget festlegen",
"budgetEditDescription": "Begrenzen Sie, wie viel Ihre Assistenten ausgeben können, bevor Anfragen abgelehnt werden.",
"budgetOrgScopeWarning": "Dieses Budget gilt für alle Tenants Ihrer Organisation, nicht nur für diesen. Bei mehreren Tenants teilen sich diese das Limit.",
"budgetModeUnlimited": "Kein Limit",
"budgetModeUnlimitedDescription": "Beliebige Ausgaben, kein Limit.",
"budgetModeCapped": "Limit festlegen",
"budgetModeCappedDescription": "Anfragen ablehnen, sobald die Ausgaben diesen Betrag erreichen.",
"budgetAmount": "Betrag",
"budgetResetCadence": "Zurücksetzen",
"budgetCadence_30d": "Alle 30 Tage",
"budgetCadence_1mo": "Monatlich",
"budgetCadence_1y": "Jährlich",
"budgetInvalid": "Bitte einen positiven Betrag eingeben.",
"budgetSaveFailed": "Budget konnte nicht gespeichert werden. Bitte erneut versuchen."
},
"workspace": {
"save": "Speichern",

View File

@@ -189,7 +189,22 @@
"last30Days": "Last 30 Days",
"noData": "No usage data available.",
"dailyBreakdown": "Daily Breakdown",
"requests": "requests"
"requests": "requests",
"budgetEdit": "Edit",
"budgetEditTitle": "Set spending budget",
"budgetEditDescription": "Cap how much your assistants can spend before requests start being declined.",
"budgetOrgScopeWarning": "This budget applies to all tenants in your organization, not just this one. If you have multiple tenants, they share the same cap.",
"budgetModeUnlimited": "No limit",
"budgetModeUnlimitedDescription": "Spend as much as needed; no cap.",
"budgetModeCapped": "Set a cap",
"budgetModeCappedDescription": "Stop accepting requests once spend reaches this amount.",
"budgetAmount": "Amount",
"budgetResetCadence": "Reset",
"budgetCadence_30d": "Every 30 days",
"budgetCadence_1mo": "Monthly",
"budgetCadence_1y": "Yearly",
"budgetInvalid": "Please enter a positive amount.",
"budgetSaveFailed": "Could not save budget. Please try again."
},
"workspace": {
"save": "Save",

View File

@@ -189,7 +189,22 @@
"last30Days": "30 derniers jours",
"noData": "Aucune donnée d'utilisation disponible.",
"dailyBreakdown": "Détail journalier",
"requests": "requêtes"
"requests": "requêtes",
"budgetEdit": "Modifier",
"budgetEditTitle": "Définir un budget",
"budgetEditDescription": "Limitez la dépense de vos assistants avant que les requêtes ne soient refusées.",
"budgetOrgScopeWarning": "Ce budget s'applique à tous les locataires de votre organisation, pas seulement à celui-ci. Si vous avez plusieurs locataires, ils partagent le même plafond.",
"budgetModeUnlimited": "Aucune limite",
"budgetModeUnlimitedDescription": "Dépense libre, sans plafond.",
"budgetModeCapped": "Définir un plafond",
"budgetModeCappedDescription": "Refuser les requêtes une fois ce montant atteint.",
"budgetAmount": "Montant",
"budgetResetCadence": "Réinitialisation",
"budgetCadence_30d": "Tous les 30 jours",
"budgetCadence_1mo": "Mensuelle",
"budgetCadence_1y": "Annuelle",
"budgetInvalid": "Veuillez saisir un montant positif.",
"budgetSaveFailed": "Impossible d'enregistrer le budget. Veuillez réessayer."
},
"workspace": {
"save": "Enregistrer",

View File

@@ -189,7 +189,22 @@
"last30Days": "Ultimi 30 giorni",
"noData": "Nessun dato di utilizzo disponibile.",
"dailyBreakdown": "Dettaglio giornaliero",
"requests": "richieste"
"requests": "richieste",
"budgetEdit": "Modifica",
"budgetEditTitle": "Imposta budget",
"budgetEditDescription": "Limita quanto i tuoi assistenti possono spendere prima che le richieste vengano rifiutate.",
"budgetOrgScopeWarning": "Questo budget si applica a tutti i tenant della tua organizzazione, non solo a questo. Se hai più tenant, condividono lo stesso limite.",
"budgetModeUnlimited": "Nessun limite",
"budgetModeUnlimitedDescription": "Spesa libera, nessun tetto.",
"budgetModeCapped": "Imposta un tetto",
"budgetModeCappedDescription": "Rifiuta le richieste una volta raggiunto questo importo.",
"budgetAmount": "Importo",
"budgetResetCadence": "Ripristino",
"budgetCadence_30d": "Ogni 30 giorni",
"budgetCadence_1mo": "Mensile",
"budgetCadence_1y": "Annuale",
"budgetInvalid": "Inserisci un importo positivo.",
"budgetSaveFailed": "Impossibile salvare il budget. Riprova."
},
"workspace": {
"save": "Salva",