Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 666dd64580 | |||
| 188bef2ece | |||
| 57258bca92 | |||
| c7ab4c6b4e |
@@ -199,7 +199,7 @@ export default async function TenantDetailPage({
|
|||||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||||
{t("usage")}
|
{t("usage")}
|
||||||
</h2>
|
</h2>
|
||||||
<UsageDisplay tenant={name} />
|
<UsageDisplay tenant={name} canEditBudget={canEdit} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Packages */}
|
{/* Packages */}
|
||||||
|
|||||||
278
src/components/dashboard/budget-editable-card.tsx
Normal file
278
src/components/dashboard/budget-editable-card.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { BudgetEditableCard } from "@/components/dashboard/budget-editable-card";
|
||||||
|
|
||||||
interface DailyUsage {
|
interface DailyUsage {
|
||||||
date: string;
|
date: string;
|
||||||
@@ -18,7 +19,17 @@ interface UsageData {
|
|||||||
totalSpend: number;
|
totalSpend: number;
|
||||||
requestCount: 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 };
|
rateLimits: { rpm: number | null; tpm: number | null };
|
||||||
dailyUsage: DailyUsage[];
|
dailyUsage: DailyUsage[];
|
||||||
}
|
}
|
||||||
@@ -29,8 +40,31 @@ function fmt(n: number): string {
|
|||||||
return n.toString();
|
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 {
|
function getCurrentMonth(): string {
|
||||||
@@ -69,7 +103,7 @@ function UsageChart({ data }: { data: DailyUsage[] }) {
|
|||||||
const x = i * (barW + 2);
|
const x = i * (barW + 2);
|
||||||
return (
|
return (
|
||||||
<g key={d.date}>
|
<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 - 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} />
|
<rect x={x} y={h - inputH} width={barW} height={inputH} rx={1} fill="var(--color-accent)" opacity={0.7} />
|
||||||
{i % 7 === 0 && (
|
{i % 7 === 0 && (
|
||||||
@@ -113,10 +147,18 @@ export function UsageDisplay({
|
|||||||
tenant,
|
tenant,
|
||||||
teamId,
|
teamId,
|
||||||
keyAlias,
|
keyAlias,
|
||||||
|
canEditBudget = false,
|
||||||
}: {
|
}: {
|
||||||
tenant?: string | null;
|
tenant?: string | null;
|
||||||
teamId?: string | null;
|
teamId?: string | null;
|
||||||
keyAlias?: 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 t = useTranslations("usage");
|
||||||
const [month, setMonth] = useState(getCurrentMonth);
|
const [month, setMonth] = useState(getCurrentMonth);
|
||||||
@@ -185,11 +227,25 @@ export function UsageDisplay({
|
|||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
<StatCard label={t("inputTokens")} value={fmt(data.currentPeriod.inputTokens)} />
|
<StatCard label={t("inputTokens")} value={fmt(data.currentPeriod.inputTokens)} />
|
||||||
<StatCard label={t("outputTokens")} value={fmt(data.currentPeriod.outputTokens)} />
|
<StatCard label={t("outputTokens")} value={fmt(data.currentPeriod.outputTokens)} />
|
||||||
<StatCard label={t("totalSpend")} value={usd(data.currentPeriod.totalSpend)} accent />
|
<StatCard label={t("totalSpend")} value={chf(data.currentPeriod.totalSpend)} accent />
|
||||||
<StatCard
|
{canEditBudget && tenant ? (
|
||||||
label={t("budget")}
|
<BudgetEditableCard
|
||||||
value={data.budget.remaining !== null ? usd(data.budget.remaining) : t("noLimit")}
|
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>
|
||||||
|
|
||||||
<div className="bg-surface-1 border border-border rounded-xl p-5">
|
<div className="bg-surface-1 border border-border rounded-xl p-5">
|
||||||
|
|||||||
@@ -189,7 +189,22 @@
|
|||||||
"last30Days": "Letzte 30 Tage",
|
"last30Days": "Letzte 30 Tage",
|
||||||
"noData": "Keine Nutzungsdaten verfügbar.",
|
"noData": "Keine Nutzungsdaten verfügbar.",
|
||||||
"dailyBreakdown": "Tagesübersicht",
|
"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": {
|
"workspace": {
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
|
|||||||
@@ -189,7 +189,22 @@
|
|||||||
"last30Days": "Last 30 Days",
|
"last30Days": "Last 30 Days",
|
||||||
"noData": "No usage data available.",
|
"noData": "No usage data available.",
|
||||||
"dailyBreakdown": "Daily Breakdown",
|
"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": {
|
"workspace": {
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
|
|||||||
@@ -189,7 +189,22 @@
|
|||||||
"last30Days": "30 derniers jours",
|
"last30Days": "30 derniers jours",
|
||||||
"noData": "Aucune donnée d'utilisation disponible.",
|
"noData": "Aucune donnée d'utilisation disponible.",
|
||||||
"dailyBreakdown": "Détail journalier",
|
"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": {
|
"workspace": {
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
|
|||||||
@@ -189,7 +189,22 @@
|
|||||||
"last30Days": "Ultimi 30 giorni",
|
"last30Days": "Ultimi 30 giorni",
|
||||||
"noData": "Nessun dato di utilizzo disponibile.",
|
"noData": "Nessun dato di utilizzo disponibile.",
|
||||||
"dailyBreakdown": "Dettaglio giornaliero",
|
"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": {
|
"workspace": {
|
||||||
"save": "Salva",
|
"save": "Salva",
|
||||||
|
|||||||
Reference in New Issue
Block a user