"use client"; import { useTranslations } from "next-intl"; import { useEffect, useState, useCallback } from "react"; import { BudgetEditableCard } from "@/components/dashboard/budget-editable-card"; interface DailyUsage { date: string; inputTokens: number; outputTokens: number; spend: number; } interface UsageData { month: string; currentPeriod: { inputTokens: number; outputTokens: number; totalSpend: number; requestCount: number; }; 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[]; } function fmt(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`; return n.toString(); } /** * 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 { const now = new Date(); return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; } function shiftMonth(month: string, delta: number): string { const [y, m] = month.split("-").map(Number); const d = new Date(y, m - 1 + delta, 1); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; } function formatMonth(month: string, locale: string): string { const [y, m] = month.split("-").map(Number); return new Date(y, m - 1).toLocaleDateString(locale, { year: "numeric", month: "long" }); } function UsageChart({ data }: { data: DailyUsage[] }) { if (!data.length) return null; const maxTokens = Math.max(...data.map((d) => d.inputTokens + d.outputTokens), 1); const barW = Math.max(4, Math.floor(600 / data.length) - 2); const h = 120; return (
{data.map((d, i) => { const total = d.inputTokens + d.outputTokens; const totalH = (total / maxTokens) * h; const inputH = (d.inputTokens / maxTokens) * h; const x = i * (barW + 2); return ( {d.date}: {fmt(d.inputTokens)} in / {fmt(d.outputTokens)} out — {chf(d.spend)} {i % 7 === 0 && ( {d.date.slice(8)} )} ); })}
Input Output
); } /** * Usage display widget. * * Pass `tenant=` for the canonical path — works for both * customers and admins, the API resolves team+alias from the tenant * CR's status. The visibility check on the API ensures users can't * query tenants they shouldn't see. * * `teamId`/`keyAlias` remain available as a platform-admin escape * hatch for cross-org debugging, but the tenant-detail and dashboard * paths should always use `tenant`. * * Bug 19 fix: previous version omitted both props for customer * sessions, expecting the API to "figure it out". The API's fallback * was "first visible tenant", which meant siblings in the same org * showed identical numbers regardless of which detail page was open. * Now the page passes the tenant name explicitly; no fallback exists. */ 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); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const isCurrentMonth = month === getCurrentMonth(); const fetchUsage = useCallback(() => { setLoading(true); setError(null); const params = new URLSearchParams({ month }); if (tenant) { params.set("tenant", tenant); } else if (teamId) { // Admin escape hatch — only honoured by the API when the // viewer is platform-role. params.set("teamId", teamId); if (keyAlias) params.set("keyAlias", keyAlias); } fetch(`/api/usage?${params}`) .then((res) => { if (!res.ok) throw new Error(`${res.status}`); return res.json(); }) .then(setData) .catch((e) => setError(e.message)) .finally(() => setLoading(false)); }, [tenant, teamId, keyAlias, month]); useEffect(() => { fetchUsage(); }, [fetchUsage]); return (
{/* Month selector */}
{formatMonth(month, "en")}
{loading ? (
) : error || !data ? (

{error || t("noData")}

) : ( <>
{canEditBudget && tenant ? ( ) : ( )}

{t("dailyBreakdown")}

{data.currentPeriod.requestCount} {t("requests")}
)}
); } function StatCard({ label, value, accent }: { label: string; value: string; accent?: boolean }) { return (
{label}
{value}
); }