279 lines
10 KiB
TypeScript
279 lines
10 KiB
TypeScript
"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>
|
|
</>
|
|
);
|
|
}
|