"use client"; import { useTranslations, useLocale } 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[] }) { const t = useTranslations("usage"); const locale = useLocale(); // Which day's detail is shown in the readout. Defaults to the most // recent day; hover (mouse), tap (touch) or focus (keyboard) all // update it. The previous version put per-day numbers only in SVG // hover tooltips, which are unreachable on touch devices and // invisible to keyboard users — this readout fixes both. const [selected, setSelected] = useState<number | null>(null); 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; const activeIndex = selected ?? data.length - 1; const active = data[activeIndex]; const dayLabel = (iso: string) => { const [y, m, dd] = iso.split("-").map(Number); return new Date(y, m - 1, dd).toLocaleDateString(locale, { month: "short", day: "numeric", }); }; const barAria = (d: DailyUsage) => `${dayLabel(d.date)}: ${fmt(d.inputTokens)} ${t("inputTokens")}, ${fmt( d.outputTokens )} ${t("outputTokens")}, ${chf(d.spend)}`; return ( <div> {/* Readout — the touch/keyboard-accessible equivalent of the old hover-only tooltip. Always reflects the active day. */} <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 mb-2 text-xs"> <span className="font-medium text-text-primary"> {dayLabel(active.date)} </span> <span className="text-text-secondary tabular-nums"> {fmt(active.inputTokens)} {t("inputTokens")} </span> <span className="text-text-secondary tabular-nums"> {fmt(active.outputTokens)} {t("outputTokens")} </span> <span className="text-accent tabular-nums">{chf(active.spend)}</span> </div> <div className="overflow-x-auto"> <svg viewBox={`0 0 ${Math.max(data.length * (barW + 2), 600)} ${h + 24}`} className="w-full h-36" preserveAspectRatio="xMinYMid meet" role="group" aria-label={t("dailyBreakdown")} > {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); const isActive = i === activeIndex; return ( <g key={d.date} role="button" tabIndex={0} aria-label={barAria(d)} aria-pressed={isActive} className="cursor-pointer focus:outline-none" onClick={() => setSelected(i)} onMouseEnter={() => setSelected(i)} onFocus={() => setSelected(i)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setSelected(i); } }} > <title>{barAria(d)} {/* Full-height transparent hit area so thin bars stay easy to tap on touch screens. */} {isActive && ( )} {i % 7 === 0 && ( {d.date.slice(8)} )} ); })}
{" "} {t("legendInput")} {" "} {t("legendOutput")} {t("chartHint")}
); } /** * 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 locale = useLocale(); 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, locale)}
{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}
); }