ultisuite-client/components/admin/settings/sections/ai-usage-section.tsx
R3D347HR4Y 2a0958b70d
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat: update agenda references to use ULTICAL_APP_NAME and enhance AI usage sections
- Replaced hardcoded "Agenda" labels with dynamic ULTICAL_APP_NAME in various components for consistency.
- Introduced new AiUsageSection and CompteAiUsageSection components to track AI usage and costs.
- Updated settings and metadata to reflect changes in AI cost policies and usage limits.
- Enhanced user interface elements for better accessibility and user experience across admin settings.
2026-06-16 10:46:31 +02:00

352 lines
12 KiB
TypeScript

"use client"
import Link from "next/link"
import { useMemo } from "react"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"
import type { ComponentType } from "react"
import { CalendarDays, Coins, Gauge, TrendingUp } from "lucide-react"
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
import { SettingsCard } from "@/components/settings/settings-kit"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
useAdminAIUsage,
useAdminAIPricing,
} from "@/lib/api/hooks/use-admin-ai-usage"
import { formatAiCostEUR } from "@/lib/api/hooks/use-ai-queries"
import { cn } from "@/lib/utils"
const chartConfig = {
cost: {
label: "Coût org",
color: "hsl(var(--primary))",
},
} satisfies ChartConfig
function formatPricePerMTok(eur: number): string {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(eur)
}
function formatShortDate(isoDate: string): string {
const date = new Date(`${isoDate}T12:00:00`)
return date.toLocaleDateString("fr-FR", { day: "numeric", month: "short" })
}
function StatCard({
label,
value,
hint,
icon: Icon,
}: {
label: string
value: string
hint?: string
icon: ComponentType<{ className?: string }>
}) {
return (
<div className="rounded-xl border border-mail-border bg-mail-surface p-5 shadow-sm dark:bg-mail-surface-elevated dark:shadow-[0_1px_4px_rgba(0,0,0,0.35)]">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-semibold tabular-nums tracking-tight">{value}</p>
{hint ? <p className="mt-1 text-xs text-muted-foreground">{hint}</p> : null}
</div>
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Icon className="size-4" />
</div>
</div>
</div>
)
}
function RankedRow({
rank,
label,
sublabel,
value,
sharePct,
}: {
rank: number
label: string
sublabel?: string
value: string
sharePct: number
}) {
return (
<div className="flex items-start gap-3 py-2.5">
<span className="mt-0.5 w-5 shrink-0 text-xs font-medium tabular-nums text-muted-foreground">
{rank}
</span>
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{label}</p>
{sublabel ? (
<p className="truncate text-xs text-muted-foreground">{sublabel}</p>
) : null}
</div>
<span className="shrink-0 text-sm tabular-nums font-medium">{value}</span>
</div>
<div className="h-1 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary/80 transition-all"
style={{ width: `${Math.max(sharePct, 4)}%` }}
/>
</div>
</div>
</div>
)
}
function EmptyPanel({ message }: { message: string }) {
return (
<p className="py-8 text-center text-sm text-muted-foreground">{message}</p>
)
}
export function AiUsageSection() {
const { data, isFetching, isError, refetch } = useAdminAIUsage("org")
const { data: pricing } = useAdminAIPricing()
const dailySeries = data?.daily_series ?? []
const topUsers = data?.top_users ?? []
const topModels = data?.top_models ?? []
const pricingRows = pricing ?? []
const chartData = useMemo(
() =>
dailySeries.map((day) => ({
date: day.date,
label: formatShortDate(day.date),
cost: day.cost_org_micro_eur / 1_000_000,
requests: day.requests,
})),
[dailySeries]
)
const maxUserCost = useMemo(
() => Math.max(...topUsers.map((u) => u.cost_org_micro_eur), 1),
[topUsers]
)
const maxModelCost = useMemo(
() => Math.max(...topModels.map((m) => m.cost_micro_eur), 1),
[topModels]
)
return (
<>
<SettingsSectionHeader
title="Usage IA"
description="Coûts estimés de la consommation LLM (clés organisation) et tarifs modèles."
/>
<SettingsSyncBanner
isFetching={isFetching}
isError={isError}
onRetry={() => refetch()}
/>
<div className="mb-6 flex flex-wrap items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/admin/settings/quotas">
<Gauge className="mr-2 size-4" />
Modifier les plafonds
</Link>
</Button>
</div>
{data ? (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatCard
label="Aujourd'hui (org)"
value={formatAiCostEUR(data.cost_today_micro_eur)}
icon={CalendarDays}
/>
<StatCard
label="Ce mois (org)"
value={formatAiCostEUR(data.cost_month_micro_eur)}
icon={TrendingUp}
/>
<StatCard
label="Devise"
value={data.currency}
hint="Estimation basée sur les tarifs modèles"
icon={Coins}
/>
</div>
{dailySeries.length > 0 ? (
<SettingsCard
title="30 derniers jours"
description="Coût quotidien estimé pour les clés organisation."
>
<ChartContainer config={chartConfig} className="aspect-3/1 h-48 w-full">
<BarChart data={chartData} margin={{ top: 8, right: 4, left: 0, bottom: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="label"
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
minTickGap={28}
/>
<YAxis
tickLine={false}
axisLine={false}
width={48}
tickFormatter={(v: number) =>
new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
notation: "compact",
maximumFractionDigits: 1,
}).format(v)
}
/>
<ChartTooltip
cursor={{ fill: "hsl(var(--muted))", opacity: 0.35 }}
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const row = payload?.[0]?.payload as { date?: string } | undefined
return row?.date
? new Date(`${row.date}T12:00:00`).toLocaleDateString("fr-FR", {
weekday: "short",
day: "numeric",
month: "long",
})
: ""
}}
formatter={(value, _name, item) => {
const requests = (item.payload as { requests?: number }).requests ?? 0
return (
<div className="flex w-full items-center justify-between gap-4">
<span>{chartConfig.cost.label}</span>
<span className="font-mono tabular-nums">
{formatAiCostEUR(Number(value) * 1_000_000)}
{requests > 0 ? ` · ${requests} req.` : ""}
</span>
</div>
)
}}
/>
}
/>
<Bar dataKey="cost" fill="var(--color-cost)" radius={[3, 3, 0, 0]} />
</BarChart>
</ChartContainer>
</SettingsCard>
) : null}
<div className="grid gap-4 lg:grid-cols-2">
<SettingsCard title="Top utilisateurs (mois)" description="Clés organisation uniquement.">
{topUsers.length === 0 ? (
<EmptyPanel message="Aucune consommation enregistrée" />
) : (
<div className="divide-y divide-mail-border">
{topUsers.map((u, index) => (
<RankedRow
key={u.user_id}
rank={index + 1}
label={u.display_name || u.email}
sublabel={u.display_name ? u.email : undefined}
value={formatAiCostEUR(u.cost_org_micro_eur)}
sharePct={Math.round((u.cost_org_micro_eur / maxUserCost) * 100)}
/>
))}
</div>
)}
</SettingsCard>
<SettingsCard title="Top modèles (mois)" description="Répartition par modèle LLM.">
{topModels.length === 0 ? (
<EmptyPanel message="Aucune consommation enregistrée" />
) : (
<div className="divide-y divide-mail-border">
{topModels.map((m, index) => (
<RankedRow
key={m.model_id}
rank={index + 1}
label={m.model_id}
sublabel={`${m.request_count} requête${m.request_count > 1 ? "s" : ""}`}
value={formatAiCostEUR(m.cost_micro_eur)}
sharePct={Math.round((m.cost_micro_eur / maxModelCost) * 100)}
/>
))}
</div>
)}
</SettingsCard>
</div>
{pricingRows.length > 0 ? (
<SettingsCard
title="Tarifs modèles"
description="Prix de référence utilisés pour estimer les coûts."
>
<div className="overflow-x-auto rounded-lg border border-mail-border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Modèle</TableHead>
<TableHead className="text-right">
Input <span className="font-normal text-muted-foreground">/ 1M</span>
</TableHead>
<TableHead className="text-right">
Output <span className="font-normal text-muted-foreground">/ 1M</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pricingRows.map((p) => (
<TableRow key={p.model_id}>
<TableCell>
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs">{p.model_id}</span>
{p.provider_type ? (
<Badge variant="secondary" className="text-[10px] font-normal">
{p.provider_type}
</Badge>
) : null}
</div>
</TableCell>
<TableCell className="text-right tabular-nums">
{formatPricePerMTok(p.input_eur_per_mtok)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatPricePerMTok(p.output_eur_per_mtok)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</SettingsCard>
) : null}
</div>
) : isFetching ? (
<p className={cn("text-sm text-muted-foreground")}>Chargement de la consommation</p>
) : null}
</>
)
}