feat(cc-050): AI Costs & Usage page
This commit is contained in:
parent
58b8a5f9ba
commit
bafc493961
1 changed files with 149 additions and 0 deletions
149
src/app/dashboard/ai/costs/page.tsx
Normal file
149
src/app/dashboard/ai/costs/page.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../../../lib/api';
|
||||
import { useSession } from '../../../../components/session-provider';
|
||||
|
||||
interface AiCosts {
|
||||
totals: { totalCostUsd: string | null; totalRequests: number };
|
||||
byModel: { model: string; requests: number; costUsd: string | null }[];
|
||||
byPurpose: { purpose: string; requests: number; costUsd: string | null }[];
|
||||
byStatus: { resultStatus: string; requests: number }[];
|
||||
}
|
||||
|
||||
export default function AiCostsPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['ai-costs', tenantId],
|
||||
queryFn: () => apiFetch<AiCosts>('/v1/ai-requests/costs', { tenantId }),
|
||||
enabled: Boolean(tenantId),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const totalCost = Number(data?.totals?.totalCostUsd ?? 0);
|
||||
const totalReqs = data?.totals?.totalRequests ?? 0;
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-6">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Costuri & Utilizare AI</h1>
|
||||
<p className="mt-1 text-sm text-ink-faint">
|
||||
Statistici agregate pentru toate cererile AI din workspace.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-ink-faint">Se încarcă…</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div className="card p-5">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-ink-faint">Total cereri</p>
|
||||
<p className="mt-1 font-display text-3xl font-semibold text-ink">{totalReqs}</p>
|
||||
</div>
|
||||
<div className="card p-5">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-ink-faint">Cost total</p>
|
||||
<p className="mt-1 font-mono text-2xl font-semibold text-ink">${totalCost.toFixed(4)}</p>
|
||||
</div>
|
||||
<div className="card p-5">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-ink-faint">Cost mediu/cerere</p>
|
||||
<p className="mt-1 font-mono text-2xl font-semibold text-ink">
|
||||
{totalReqs > 0 ? `$${(totalCost / totalReqs).toFixed(4)}` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="card p-5">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-ink-faint">Modele utilizate</p>
|
||||
<p className="mt-1 font-display text-3xl font-semibold text-ink">
|
||||
{data?.byModel?.length ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalReqs === 0 ? (
|
||||
<div className="card p-10 text-center">
|
||||
<p className="text-sm font-medium text-ink">Nicio cerere AI înregistrată</p>
|
||||
<p className="mt-1 text-xs text-ink-faint">
|
||||
Costurile sunt înregistrate automat la utilizarea funcționalităților AI.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<section className="card p-5">
|
||||
<h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-ink-faint">
|
||||
Cost per model
|
||||
</h2>
|
||||
<ul className="space-y-3">
|
||||
{(data?.byModel ?? [])
|
||||
.sort((a, b) => Number(b.costUsd ?? 0) - Number(a.costUsd ?? 0))
|
||||
.map((m) => {
|
||||
const pct = totalCost > 0 ? (Number(m.costUsd ?? 0) / totalCost) * 100 : 0;
|
||||
return (
|
||||
<li key={m.model}>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="min-w-0 truncate font-mono text-xs text-ink">{m.model}</span>
|
||||
<span className="shrink-0 font-mono text-xs text-ink-faint">
|
||||
${Number(m.costUsd ?? 0).toFixed(4)} · {m.requests}x
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-paper-sunken">
|
||||
<div className="h-full rounded-full bg-bronze-deep" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="card p-5">
|
||||
<h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-ink-faint">
|
||||
Cost per scop
|
||||
</h2>
|
||||
<ul className="space-y-3">
|
||||
{(data?.byPurpose ?? [])
|
||||
.sort((a, b) => Number(b.costUsd ?? 0) - Number(a.costUsd ?? 0))
|
||||
.slice(0, 8)
|
||||
.map((p) => {
|
||||
const pct = totalCost > 0 ? (Number(p.costUsd ?? 0) / totalCost) * 100 : 0;
|
||||
return (
|
||||
<li key={p.purpose}>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-ink capitalize">
|
||||
{p.purpose.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-xs text-ink-faint">
|
||||
${Number(p.costUsd ?? 0).toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-paper-sunken">
|
||||
<div className="h-full rounded-full bg-ink-soft" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="card p-5 lg:col-span-2">
|
||||
<h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-ink-faint">
|
||||
Distribuție status
|
||||
</h2>
|
||||
<ul className="flex flex-wrap gap-4">
|
||||
{(data?.byStatus ?? []).map((s) => (
|
||||
<li key={s.resultStatus} className="flex items-center gap-2">
|
||||
<span className="rounded-full bg-paper-sunken px-3 py-1 text-xs font-medium capitalize text-ink">
|
||||
{s.resultStatus.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<span className="font-mono text-sm font-semibold text-ink">{s.requests}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue