feat: update agenda references to use ULTICAL_APP_NAME and enhance AI usage sections
Some checks are pending
E2E / Playwright e2e (push) Waiting to run

- 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.
This commit is contained in:
R3D347HR4Y 2026-06-16 10:46:31 +02:00
parent 770669424e
commit 2a0958b70d
40 changed files with 863 additions and 70 deletions

View File

@ -1,14 +1,14 @@
import { DemoAgendaShell } from "@/components/demo/demo-agenda-shell" import { DemoAgendaShell } from "@/components/demo/demo-agenda-shell"
import type { Metadata } from "next" import type { Metadata } from "next"
import { suitePageMetadata } from "@/lib/suite/page-metadata" import { ULTICAL_APP_NAME, suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata: Metadata = { export const metadata: Metadata = {
...suitePageMetadata({ ...suitePageMetadata({
app: "agenda", app: "agenda",
title: "Démo UltiAgenda", title: `Démo ${ULTICAL_APP_NAME}`,
absoluteTitle: true, absoluteTitle: true,
description: description:
"Essayez l'agenda UltiAgenda sans compte — démo interactive, zéro rétention.", `Essayez ${ULTICAL_APP_NAME} sans compte — démo interactive, zéro rétention.`,
}), }),
robots: { index: false }, robots: { index: false },
} }

View File

@ -18,12 +18,12 @@ import {
useStartMigrationOAuth, useStartMigrationOAuth,
} from "@/lib/api/hooks/use-hosted-mail" } from "@/lib/api/hooks/use-hosted-mail"
import { useAuthReady } from "@/lib/api/use-auth-ready" import { useAuthReady } from "@/lib/api/use-auth-ready"
import { buildOidcLoginUrl } from "@/lib/auth/login-url" import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
const SERVICE_LABELS: Record<string, string> = { const SERVICE_LABELS: Record<string, string> = {
mail: "Mail", mail: "Mail",
contacts: "Contacts", contacts: "Contacts",
calendar: "Agenda", calendar: ULTICAL_APP_NAME,
drive: "Drive", drive: "Drive",
} }

View File

@ -85,6 +85,11 @@ const SECTIONS: Record<AdminSettingsSectionId, ComponentType> = {
default: m.AiAssistantSection, default: m.AiAssistantSection,
})) }))
), ),
"ai-usage": loadSection(() =>
import("@/components/admin/settings/sections/ai-usage-section").then((m) => ({
default: m.AiUsageSection,
}))
),
audit: loadSection(() => audit: loadSection(() =>
import("@/components/admin/settings/sections/audit-section").then((m) => ({ import("@/components/admin/settings/sections/audit-section").then((m) => ({
default: m.AuditSection, default: m.AuditSection,

View File

@ -23,7 +23,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import type { MailThemeMode } from "@/lib/mail-settings/types" import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
const THEME_OPTIONS: { id: MailThemeMode; label: string }[] = [ const THEME_OPTIONS: { id: MailThemeMode; label: string }[] = [
{ id: "light", label: "Clair" }, { id: "light", label: "Clair" },
@ -52,7 +52,7 @@ export function AgendaSection() {
return ( return (
<OrgSettingsSection <OrgSettingsSection
title="Agenda" title={ULTICAL_APP_NAME}
description="Thème et visioconférence par défaut pour toute l'organisation." description="Thème et visioconférence par défaut pour toute l'organisation."
policySection="agenda" policySection="agenda"
beforeSave={() => setAgenda(draft)} beforeSave={() => setAgenda(draft)}

View File

@ -0,0 +1,351 @@
"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}
</>
)
}

View File

@ -46,12 +46,12 @@ import {
useRetryMigrationFailedJobs, useRetryMigrationFailedJobs,
useRetryMigrationJob, useRetryMigrationJob,
} from "@/lib/api/hooks/use-hosted-mail" } from "@/lib/api/hooks/use-hosted-mail"
import { ApiRequestError } from "@/lib/api/client" import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
const SERVICE_LABELS: Record<string, string> = { const SERVICE_LABELS: Record<string, string> = {
mail: "Mail", mail: "Mail",
contacts: "Contacts", contacts: "Contacts",
calendar: "Agenda", calendar: ULTICAL_APP_NAME,
drive: "Drive", drive: "Drive",
} }

View File

@ -31,6 +31,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const SIMPLE_PLUGIN_IDS = new Set(["mail-automation", "contact-discovery", "public-share"]) const SIMPLE_PLUGIN_IDS = new Set(["mail-automation", "contact-discovery", "public-share"])
@ -201,7 +202,7 @@ function NextcloudPluginCard() {
return ( return (
<PluginToggleCard <PluginToggleCard
name="Nextcloud Suite" name="Nextcloud Suite"
description="Plateforme drive, agenda, contacts et Talk. Requis pour UltiDrive et les modules associés." description="Plateforme drive, UltiCal, contacts et Talk. Requis pour UltiDrive et les modules associés."
enabled={enabled} enabled={enabled}
locked={enabledLocked} locked={enabledLocked}
lockSection="nextcloud" lockSection="nextcloud"
@ -262,7 +263,7 @@ function NextcloudPluginCard() {
onChange={(drive_enabled) => setNextcloud({ drive_enabled })} onChange={(drive_enabled) => setNextcloud({ drive_enabled })}
/> />
<ServiceToggle <ServiceToggle
label="Agenda" label={ULTICAL_APP_NAME}
checked={nextcloud.calendar_enabled} checked={nextcloud.calendar_enabled}
onChange={(calendar_enabled) => setNextcloud({ calendar_enabled })} onChange={(calendar_enabled) => setNextcloud({ calendar_enabled })}
/> />

View File

@ -71,22 +71,37 @@ export function QuotasSection() {
<SettingsCard <SettingsCard
title="Intelligence artificielle" title="Intelligence artificielle"
description="Requêtes LLM et tokens consommés par mois." description="Plafonds par utilisateur (clés org) : usage quotidien et mensuel raisonnable pour une PME."
> >
<SettingsGrid columns={2}> <SettingsGrid columns={2}>
<SettingsNumberField <SettingsNumberField
label="Requêtes LLM" label="Plafond journalier"
unit="/ jour" unit="€"
value={usageQuotas.llm_requests_per_day} step={0.5}
onChange={(v) => setUsageQuotas({ llm_requests_per_day: v })} value={usageQuotas.llm_daily_cost_limit_eur}
onChange={(v) => setUsageQuotas({ llm_daily_cost_limit_eur: v })}
/> />
<SettingsNumberField <SettingsNumberField
label="Tokens LLM" label="Plafond mensuel"
unit="/ mois" unit="€"
value={usageQuotas.llm_tokens_per_month} step={1}
onChange={(v) => setUsageQuotas({ llm_tokens_per_month: v })} value={usageQuotas.llm_monthly_cost_limit_eur}
onChange={(v) => setUsageQuotas({ llm_monthly_cost_limit_eur: v })}
/> />
</SettingsGrid> </SettingsGrid>
<SettingsNumberField
label="Seuil d'alerte"
unit="%"
min={50}
max={100}
fallback={80}
className="max-w-xs"
value={usageQuotas.llm_cost_warn_threshold_pct}
onChange={(v) => setUsageQuotas({ llm_cost_warn_threshold_pct: v })}
/>
<Button asChild variant="outline" size="sm">
<Link href="/admin/settings/ai-usage">Voir la consommation IA</Link>
</Button>
</SettingsCard> </SettingsCard>
<SettingsCard <SettingsCard

View File

@ -17,6 +17,7 @@ import {
useSetAdminUserRole, useSetAdminUserRole,
useUpdateAdminUser, useUpdateAdminUser,
} from "@/lib/api/hooks/use-admin-mutations" } from "@/lib/api/hooks/use-admin-mutations"
import { useUpdateUserAICostPolicy } from "@/lib/api/hooks/use-admin-ai-usage"
import type { AdminUser, AdminUserRole } from "@/lib/api/admin-types" import type { AdminUser, AdminUserRole } from "@/lib/api/admin-types"
import { bytesToGib, formatBytes, gibToBytes } from "@/lib/admin/format-bytes" import { bytesToGib, formatBytes, gibToBytes } from "@/lib/admin/format-bytes"
import { import {
@ -514,12 +515,15 @@ function UserDetailSheet({
const updateUser = useUpdateAdminUser(userId ?? "") const updateUser = useUpdateAdminUser(userId ?? "")
const setRole = useSetAdminUserRole(userId ?? "") const setRole = useSetAdminUserRole(userId ?? "")
const setQuota = useSetAdminUserQuota(userId ?? "") const setQuota = useSetAdminUserQuota(userId ?? "")
const setAiPolicy = useUpdateUserAICostPolicy(userId ?? "")
const [name, setName] = useState("") const [name, setName] = useState("")
const [email, setEmail] = useState("") const [email, setEmail] = useState("")
const [selectedRole, setSelectedRole] = useState<AdminUserRole>("user") const [selectedRole, setSelectedRole] = useState<AdminUserRole>("user")
const [mailGib, setMailGib] = useState("5") const [mailGib, setMailGib] = useState("5")
const [driveGib, setDriveGib] = useState("5") const [driveGib, setDriveGib] = useState("5")
const [photosGib, setPhotosGib] = useState("5") const [photosGib, setPhotosGib] = useState("5")
const [aiDailyEur, setAiDailyEur] = useState("")
const [aiMonthlyEur, setAiMonthlyEur] = useState("")
const open = Boolean(userId) const open = Boolean(userId)
@ -554,6 +558,14 @@ function UserDetailSheet({
}) })
} }
async function saveAiPolicy() {
if (!userId) return
await setAiPolicy.mutateAsync({
daily_limit_eur: aiDailyEur.trim() === "" ? null : Number(aiDailyEur),
monthly_limit_eur: aiMonthlyEur.trim() === "" ? null : Number(aiMonthlyEur),
})
}
return ( return (
<Sheet open={open} onOpenChange={(v) => !v && onClose()}> <Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent className="flex flex-col gap-0 overflow-y-auto p-0 sm:max-w-lg"> <SheetContent className="flex flex-col gap-0 overflow-y-auto p-0 sm:max-w-lg">
@ -646,6 +658,40 @@ function UserDetailSheet({
</div> </div>
) : null} ) : null}
<div className="space-y-4 rounded-lg border bg-muted/20 p-5">
<h3 className="text-sm font-medium">Plafond IA (clé org)</h3>
<p className="text-xs text-muted-foreground">
Override utilisateur. Laissez vide pour hériter des plafonds org/groupe.
</p>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label>Journalier ()</Label>
<Input
type="number"
min={0}
step={0.5}
placeholder="Hérité"
value={aiDailyEur}
onChange={(e) => setAiDailyEur(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Mensuel ()</Label>
<Input
type="number"
min={0}
step={1}
placeholder="Hérité"
value={aiMonthlyEur}
onChange={(e) => setAiMonthlyEur(e.target.value)}
/>
</div>
</div>
<Button size="sm" variant="secondary" onClick={() => void saveAiPolicy()} disabled={setAiPolicy.isPending}>
Enregistrer le plafond IA
</Button>
</div>
<p className="font-mono text-xs text-muted-foreground">ID : {user.id}</p> <p className="font-mono text-xs text-muted-foreground">ID : {user.id}</p>
</div> </div>
)} )}

View File

@ -22,6 +22,7 @@ import {
} from "@/lib/agenda/agenda-url" } from "@/lib/agenda/agenda-url"
import { useAgendaSettingsStore, useAgendaUIStore } from "@/lib/agenda/agenda-store" import { useAgendaSettingsStore, useAgendaUIStore } from "@/lib/agenda/agenda-store"
import { useIsMobile } from "@/hooks/use-mobile" import { useIsMobile } from "@/hooks/use-mobile"
import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
import { import {
SUITE_APP_LOGO_LOCKUP_CLASS, SUITE_APP_LOGO_LOCKUP_CLASS,
SUITE_APP_LOGO_MARK_CLASS, SUITE_APP_LOGO_MARK_CLASS,
@ -74,7 +75,7 @@ export function AgendaHeader({
className={cn("mr-1 hidden sm:flex", SUITE_APP_LOGO_LOCKUP_CLASS)} className={cn("mr-1 hidden sm:flex", SUITE_APP_LOGO_LOCKUP_CLASS)}
> >
<AgendaMark className={SUITE_APP_LOGO_MARK_CLASS} /> <AgendaMark className={SUITE_APP_LOGO_MARK_CLASS} />
<span className={cn("hidden md:block", SUITE_APP_LOGO_TEXT_CLASS)}>Agenda</span> <span className={cn("hidden md:block", SUITE_APP_LOGO_TEXT_CLASS)}>{ULTICAL_APP_NAME}</span>
</Link> </Link>
<Button <Button

View File

@ -4,6 +4,7 @@ import { X, Sparkles } from "lucide-react"
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet" import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { AiChatIframe } from "@/components/ai/ai-chat-iframe" import { AiChatIframe } from "@/components/ai/ai-chat-iframe"
import { AiSpendBar } from "@/components/ai/ai-spend-bar"
import { useAiPanelStore } from "@/lib/ai/use-ai-panel" import { useAiPanelStore } from "@/lib/ai/use-ai-panel"
import { useAiConfig, useAiQuota } from "@/lib/api/hooks/use-ai-queries" import { useAiConfig, useAiQuota } from "@/lib/api/hooks/use-ai-queries"
@ -24,11 +25,9 @@ export function AiChatPanel() {
<Sparkles className="h-4 w-4 text-[#1a73e8]" /> <Sparkles className="h-4 w-4 text-[#1a73e8]" />
UltiAI UltiAI
</SheetTitle> </SheetTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
{quota ? ( {quota ? (
<span className="text-xs text-muted-foreground"> <AiSpendBar quota={quota} compact className="hidden max-w-[180px] sm:block" />
{quota.requests_remaining} req. restantes
</span>
) : null} ) : null}
<Button variant="ghost" size="icon" onClick={closePanel} aria-label="Fermer"> <Button variant="ghost" size="icon" onClick={closePanel} aria-label="Fermer">
<X className="h-4 w-4" /> <X className="h-4 w-4" />

View File

@ -0,0 +1,84 @@
"use client"
import Link from "next/link"
import {
aiQuotaUsedMonthPct,
formatAiCostEUR,
type AiQuota,
} from "@/lib/api/hooks/use-ai-queries"
import { cn } from "@/lib/utils"
export function AiSpendBar({
quota,
compact = false,
className,
}: {
quota: AiQuota
compact?: boolean
className?: string
}) {
const pct = aiQuotaUsedMonthPct(quota)
const warnPct = quota.warn_threshold_pct || 80
const nearLimit = pct != null && pct >= warnPct
const isBYOK = !quota.billing_scope_org
if (isBYOK) {
const byokTotal = (quota.by_provider_keys ?? []).reduce(
(sum, k) => sum + k.cost_month_micro_eur,
quota.cost_used_month_micro_eur
)
return (
<div className={cn("space-y-1", className)}>
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>Clé personnelle</span>
<span className="tabular-nums">{formatAiCostEUR(byokTotal)} / mois</span>
</div>
{!compact && (quota.by_provider_keys?.length ?? 0) > 0 ? (
<div className="space-y-0.5 pl-2 text-[10px] text-muted-foreground">
{quota.by_provider_keys!.slice(0, 3).map((k) => (
<div key={k.fingerprint} className="flex justify-between gap-2">
<span className="truncate">{k.label}</span>
<span className="tabular-nums shrink-0">{formatAiCostEUR(k.cost_month_micro_eur)}</span>
</div>
))}
</div>
) : null}
</div>
)
}
return (
<div className={cn("space-y-1", className)}>
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span className={cn(nearLimit && "text-amber-600 dark:text-amber-400")}>
IA {nearLimit ? "· proche du plafond" : ""}
</span>
<span className="tabular-nums">
{formatAiCostEUR(quota.cost_used_month_micro_eur)}
{quota.cost_limit_month_micro_eur
? ` / ${formatAiCostEUR(quota.cost_limit_month_micro_eur)}`
: ""}
</span>
</div>
{pct != null ? (
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className={cn(
"h-full rounded-full transition-all",
nearLimit ? "bg-amber-500" : "bg-primary"
)}
style={{ width: `${pct}%` }}
/>
</div>
) : null}
{!compact ? (
<Link
href="/compte/usage-ia"
className="text-[10px] text-muted-foreground underline-offset-2 hover:underline"
>
Détail de consommation
</Link>
) : null}
</div>
)
}

View File

@ -17,6 +17,7 @@ import {
} from "@/lib/ai/docs-apply" } from "@/lib/ai/docs-apply"
import type { AiPostMessage } from "@/lib/ai/chat-context" import type { AiPostMessage } from "@/lib/ai/chat-context"
import { useAiConfig, useAiQuota } from "@/lib/api/hooks/use-ai-queries" import { useAiConfig, useAiQuota } from "@/lib/api/hooks/use-ai-queries"
import { AiSpendBar } from "@/components/ai/ai-spend-bar"
import { import {
DOCS_AI_PANEL_MIN_WIDTH_PX, DOCS_AI_PANEL_MIN_WIDTH_PX,
useDocsAiPanelStore, useDocsAiPanelStore,
@ -153,11 +154,7 @@ export function DocsAiPanel({
<span className="truncate">UltiAI</span> <span className="truncate">UltiAI</span>
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
{quota ? ( {quota ? <AiSpendBar quota={quota} compact className="max-w-[140px]" /> : null}
<span className="text-[10px] text-muted-foreground">
{quota.requests_remaining} req.
</span>
) : null}
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"

View File

@ -7,11 +7,13 @@ import {
import { CompteHomeSection } from "@/components/compte/sections/compte-home-section" import { CompteHomeSection } from "@/components/compte/sections/compte-home-section"
import { ComptePersonalInfoSection } from "@/components/compte/sections/compte-personal-info-section" import { ComptePersonalInfoSection } from "@/components/compte/sections/compte-personal-info-section"
import { CompteSecuritySection } from "@/components/compte/sections/compte-security-section" import { CompteSecuritySection } from "@/components/compte/sections/compte-security-section"
import { CompteAiUsageSection } from "@/components/compte/sections/compte-ai-usage-section"
const SECTIONS: Record<CompteSettingsSectionId, React.ComponentType> = { const SECTIONS: Record<CompteSettingsSectionId, React.ComponentType> = {
home: CompteHomeSection, home: CompteHomeSection,
"personal-info": ComptePersonalInfoSection, "personal-info": ComptePersonalInfoSection,
security: CompteSecuritySection, security: CompteSecuritySection,
"usage-ia": CompteAiUsageSection,
} }
export function CompteSettingsSectionView({ export function CompteSettingsSectionView({

View File

@ -0,0 +1,101 @@
"use client"
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { AiSpendBar } from "@/components/ai/ai-spend-bar"
import { useAiConfig, useAiQuota, formatAiCostEUR } from "@/lib/api/hooks/use-ai-queries"
export function CompteAiUsageSection() {
const { data: config } = useAiConfig()
const { data: quota, isLoading } = useAiQuota(config?.enabled ?? false)
if (!config?.enabled) {
return (
<>
<SettingsSectionHeader
title="Usage IA"
description="UltiAI n'est pas activé sur cette instance."
/>
</>
)
}
return (
<>
<SettingsSectionHeader
title="Usage IA"
description="Consommation estimée de vos requêtes LLM ce mois-ci."
/>
{isLoading ? (
<p className="text-sm text-muted-foreground">Chargement</p>
) : quota ? (
<div className="space-y-6 max-w-xl">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Budget mensuel</CardTitle>
</CardHeader>
<CardContent>
<AiSpendBar quota={quota} />
<div className="mt-4 grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Aujourd'hui</p>
<p className="font-medium tabular-nums">
{formatAiCostEUR(quota.cost_used_today_micro_eur)}
</p>
</div>
<div>
<p className="text-muted-foreground">Ce mois</p>
<p className="font-medium tabular-nums">
{formatAiCostEUR(quota.cost_used_month_micro_eur)}
</p>
</div>
</div>
</CardContent>
</Card>
{(quota.by_provider_keys?.length ?? 0) > 0 ? (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Par clé API</CardTitle>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Clé / fournisseur</TableHead>
<TableHead className="text-right">Ce mois</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{quota.by_provider_keys!.map((k) => (
<TableRow key={k.fingerprint}>
<TableCell>
<div>{k.label}</div>
<div className="text-xs text-muted-foreground">
{k.billing_scope === "user" ? "Clé personnelle" : "Organisation"}
</div>
</TableCell>
<TableCell className="text-right tabular-nums">
{formatAiCostEUR(k.cost_month_micro_eur)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
) : null}
</div>
) : null}
</>
)
}

View File

@ -98,7 +98,7 @@ export function createInitialDemoAgendaEvents(): AgendaApiEvent[] {
{ {
uid: "demo-client-call", uid: "demo-client-call",
summary: "Appel client — Atelier Nord", summary: "Appel client — Atelier Nord",
description: "Présentation Ultimail Agenda + intégration UltiMeet pour leur équipe.", description: "Présentation UltiCal + intégration UltiMeet pour leur équipe.",
location: "UltiMeet", location: "UltiMeet",
start: formatICSDateTimeUTC(clientCallStart), start: formatICSDateTimeUTC(clientCallStart),
end: formatICSDateTimeUTC(addHours(clientCallStart, 1)), end: formatICSDateTimeUTC(addHours(clientCallStart, 1)),

View File

@ -137,7 +137,9 @@ export function RuleSimulatorPanel({ state, ruleId }: RuleSimulatorPanelProps) {
{domains.includes('agenda') ? ( {domains.includes('agenda') ? (
<div className="space-y-2 rounded-md border border-border/50 p-2"> <div className="space-y-2 rounded-md border border-border/50 p-2">
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">Agenda</p> <p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{AUTOMATION_DOMAIN_LABELS.agenda}
</p>
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
<div> <div>
<Label className="text-[10px]">Titre</Label> <Label className="text-[10px]">Titre</Label>
@ -156,7 +158,7 @@ export function RuleSimulatorPanel({ state, ruleId }: RuleSimulatorPanelProps) {
<Input className="h-8 text-xs" value={calendarEvent.attendee} onChange={(e) => setCalendarEvent({ ...calendarEvent, attendee: e.target.value })} /> <Input className="h-8 text-xs" value={calendarEvent.attendee} onChange={(e) => setCalendarEvent({ ...calendarEvent, attendee: e.target.value })} />
</div> </div>
<div> <div>
<Label className="text-[10px]">Agenda</Label> <Label className="text-[10px]">Calendrier</Label>
<Input className="h-8 text-xs" value={calendarEvent.calendar_id} onChange={(e) => setCalendarEvent({ ...calendarEvent, calendar_id: e.target.value })} /> <Input className="h-8 text-xs" value={calendarEvent.calendar_id} onChange={(e) => setCalendarEvent({ ...calendarEvent, calendar_id: e.target.value })} />
</div> </div>
</div> </div>

View File

@ -1,13 +1,13 @@
"use client" "use client"
import { AgendaSettingsFields } from "@/components/agenda/agenda-settings-fields" import { AgendaSettingsFields } from "@/components/agenda/agenda-settings-fields"
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header" import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
export function AgendaSettingsSection() { export function AgendaSettingsSection() {
return ( return (
<> <>
<SettingsSectionHeader <SettingsSectionHeader
title="Réglages Agenda" title={`Réglages ${ULTICAL_APP_NAME}`}
description="Affichage, visioconférence, invitations, agendas par compte, calendriers iCal et vues. Le thème suit les réglages d'affichage." description="Affichage, visioconférence, invitations, agendas par compte, calendriers iCal et vues. Le thème suit les réglages d'affichage."
/> />
<AgendaSettingsFields variant="page" /> <AgendaSettingsFields variant="page" />

View File

@ -14,7 +14,7 @@ export function AutomationSettingsSection() {
<> <>
<SettingsSectionHeader <SettingsSectionHeader
title="Automatisations" title="Automatisations"
description="Règles et webhooks pour les événements mail, Drive, contacts et agenda — conditions et actions adaptées au déclencheur." description="Règles et webhooks pour les événements mail, Drive, contacts et UltiCal — conditions et actions adaptées au déclencheur."
/> />
<Tabs defaultValue="rules"> <Tabs defaultValue="rules">
<TabsList className={MAIL_SETTINGS_TABS_LIST_CLASS}> <TabsList className={MAIL_SETTINGS_TABS_LIST_CLASS}>

View File

@ -1,3 +1,4 @@
import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
import { suitePublicAsset } from "@/lib/suite/suite-public-asset" import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
export type LandingApp = { export type LandingApp = {
@ -59,10 +60,10 @@ export const LANDING_APPS: LandingApp[] = [
accent: "#5a6172", accent: "#5a6172",
}, },
{ {
name: "Agenda", name: ULTICAL_APP_NAME,
tagline: "Calendrier", tagline: "Calendrier",
description: description:
"Agenda partagé, invitations et disponibilités, connecté au mail et aux contacts.", "Calendrier partagé, invitations et disponibilités, connecté au mail et aux contacts.",
icon: suitePublicAsset("/agenda-mark.svg"), icon: suitePublicAsset("/agenda-mark.svg"),
iconDark: suitePublicAsset("/agenda-mark-dark.svg"), iconDark: suitePublicAsset("/agenda-mark-dark.svg"),
href: "/agenda", href: "/agenda",
@ -72,7 +73,7 @@ export const LANDING_APPS: LandingApp[] = [
name: "UltiMeet", name: "UltiMeet",
tagline: "Visio", tagline: "Visio",
description: description:
"Réunions vidéo chiffrées dans le navigateur, auto-hébergées via Jitsi et liées à l'Agenda.", "Réunions vidéo chiffrées dans le navigateur, auto-hébergées via Jitsi et liées à UltiCal.",
icon: suitePublicAsset("/ultimeet-mark.svg"), icon: suitePublicAsset("/ultimeet-mark.svg"),
href: "/meet", href: "/meet",
accent: "#34A853", accent: "#34A853",

View File

@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { Icon } from "@iconify/react" import { Icon } from "@iconify/react"
import { LandingReveal } from "@/components/landing/landing-reveal" import { LandingReveal } from "@/components/landing/landing-reveal"
import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
type DemoTab = { type DemoTab = {
@ -33,7 +34,7 @@ const DEMO_TABS: DemoTab[] = [
}, },
{ {
id: "agenda", id: "agenda",
label: "UltiAgenda", label: ULTICAL_APP_NAME,
icon: "mdi:calendar-outline", icon: "mdi:calendar-outline",
src: "/demo/agenda", src: "/demo/agenda",
fakeUrl: "suite.votre-domaine.fr/agenda", fakeUrl: "suite.votre-domaine.fr/agenda",

View File

@ -39,7 +39,7 @@ export const DEFAULT_ORG_PLUGINS: PluginEntry[] = [
{ {
id: "ai-assistant", id: "ai-assistant",
name: "UltiAI", name: "UltiAI",
description: "Assistant IA intégré avec tools mail, drive, contacts, agenda et recherche web.", description: "Assistant IA intégré avec tools mail, drive, contacts, UltiCal et recherche web.",
enabled: false, enabled: false,
version: "1.0.0", version: "1.0.0",
}, },

View File

@ -154,7 +154,17 @@ export function apiOrgPolicyToStore(policy: ApiOrgPolicy): Partial<OrgSettingsSt
remember_device_days: policy.two_factor.remember_device_days, remember_device_days: policy.two_factor.remember_device_days,
}, },
storageQuotas: { ...policy.storage_quotas }, storageQuotas: { ...policy.storage_quotas },
usageQuotas: { ...policy.usage_quotas }, usageQuotas: {
llm_daily_cost_limit_eur: 2,
llm_monthly_cost_limit_eur: 35,
llm_cost_warn_threshold_pct: 80,
llm_requests_per_day: 75,
llm_tokens_per_month: 2_000_000,
search_requests_per_day: 20,
max_api_tokens_per_user: 5,
max_webhooks_per_user: 5,
...policy.usage_quotas,
},
filePolicies: mergeFilePolicies(policy.file_policies), filePolicies: mergeFilePolicies(policy.file_policies),
llm: { llm: {
...policy.llm, ...policy.llm,

View File

@ -58,11 +58,14 @@ const DEFAULT_STORAGE_QUOTAS: OrgStorageQuotas = {
} }
const DEFAULT_USAGE_QUOTAS: UsageQuotaDefaults = { const DEFAULT_USAGE_QUOTAS: UsageQuotaDefaults = {
llm_requests_per_day: 100, llm_daily_cost_limit_eur: 2,
llm_tokens_per_month: 500_000, llm_monthly_cost_limit_eur: 35,
search_requests_per_day: 50, llm_cost_warn_threshold_pct: 80,
max_api_tokens_per_user: 10, llm_requests_per_day: 75,
max_webhooks_per_user: 20, llm_tokens_per_month: 2_000_000,
search_requests_per_day: 20,
max_api_tokens_per_user: 5,
max_webhooks_per_user: 5,
} }
const DEFAULT_FILE_POLICIES: FilePolicySettings = { const DEFAULT_FILE_POLICIES: FilePolicySettings = {
@ -169,7 +172,7 @@ const DEFAULT_INTEGRATIONS: IntegrationEntry[] = [
{ {
id: "nextcloud", id: "nextcloud",
name: "Nextcloud", name: "Nextcloud",
description: "Drive, agenda, contacts et Talk.", description: "Drive, UltiCal, contacts et Talk.",
enabled: false, enabled: false,
configured: false, configured: false,
href: "/admin/settings/plugins", href: "/admin/settings/plugins",

View File

@ -91,6 +91,10 @@ export type OrgStorageQuotas = {
} }
export type UsageQuotaDefaults = { export type UsageQuotaDefaults = {
llm_daily_cost_limit_eur: number
llm_monthly_cost_limit_eur: number
llm_cost_warn_threshold_pct: number
/** @deprecated legacy */
llm_requests_per_day: number llm_requests_per_day: number
llm_tokens_per_month: number llm_tokens_per_month: number
search_requests_per_day: number search_requests_per_day: number

View File

@ -1,4 +1,5 @@
import type { LucideIcon } from "lucide-react" import type { LucideIcon } from "lucide-react"
import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
import { import {
Bot, Bot,
Calendar, Calendar,
@ -29,6 +30,7 @@ export type AdminSettingsSectionId =
| "plugins" | "plugins"
| "mail-domains" | "mail-domains"
| "ai-assistant" | "ai-assistant"
| "ai-usage"
| "agenda" | "agenda"
| "ultimeet" | "ultimeet"
| "audit" | "audit"
@ -107,7 +109,7 @@ export const ADMIN_SETTINGS_NAV: AdminSettingsNavItem[] = [
}, },
{ {
id: "agenda", id: "agenda",
label: "Agenda", label: ULTICAL_APP_NAME,
description: "Thème et visioconférence par défaut", description: "Thème et visioconférence par défaut",
href: "/admin/settings/agenda", href: "/admin/settings/agenda",
icon: Calendar, icon: Calendar,
@ -126,6 +128,13 @@ export const ADMIN_SETTINGS_NAV: AdminSettingsNavItem[] = [
href: "/admin/settings/mail-domains", href: "/admin/settings/mail-domains",
icon: Mail, icon: Mail,
}, },
{
id: "ai-usage",
label: "Usage IA",
description: "Coûts, consommation et tarifs modèles",
href: "/admin/settings/ai-usage",
icon: Gauge,
},
{ {
id: "ai-assistant", id: "ai-assistant",
label: "UltiAI", label: "UltiAI",
@ -162,6 +171,7 @@ export function resolveAdminSettingsSection(
if (slug === "nextcloud" || slug === "onlyoffice" || slug === "richtext") return "plugins" if (slug === "nextcloud" || slug === "onlyoffice" || slug === "richtext") return "plugins"
if (slug === "mailing") return "mail-domains" if (slug === "mailing") return "mail-domains"
if (slug === "storage-quotas" || slug === "usage-quotas") return "quotas" if (slug === "storage-quotas" || slug === "usage-quotas") return "quotas"
if (slug === "ai-usage") return "ai-usage"
const match = ADMIN_SETTINGS_NAV.find((item) => { const match = ADMIN_SETTINGS_NAV.find((item) => {
if (item.id === "overview") return !slug || slug === "overview" if (item.id === "overview") return !slug || slug === "overview"
return item.href.endsWith(`/${slug}`) return item.href.endsWith(`/${slug}`)
@ -173,6 +183,7 @@ const ADMIN_WIDE_SECTIONS: AdminSettingsSectionId[] = [
"overview", "overview",
"audit", "audit",
"ai-assistant", "ai-assistant",
"ai-usage",
"plugins", "plugins",
"quotas", "quotas",
"search", "search",

View File

@ -64,7 +64,7 @@ export function buildEmbedSearchParams(
export function systemPromptFromContext(context: AiChatContext): string { export function systemPromptFromContext(context: AiChatContext): string {
const lines = [ const lines = [
"Tu es UltiAI, l'assistant intégré à la suite Ultimail (mail, drive, contacts, agenda).", "Tu es UltiAI, l'assistant intégré à la suite Ultimail (mail, drive, contacts, UltiCal).",
"Réponds en français sauf demande contraire. Utilise les tools disponibles pour agir sur les données utilisateur.", "Réponds en français sauf demande contraire. Utilise les tools disponibles pour agir sur les données utilisateur.",
"Recherche suite (index local) via suite_search ; recherche web publique via web_search si configurée.", "Recherche suite (index local) via suite_search ; recherche web publique via web_search si configurée.",
"Après chaque appel d'outil, réponds toujours en langage naturel : résume le résultat, cite les sources (sujet, chemin, nom), propose la suite.", "Après chaque appel d'outil, réponds toujours en langage naturel : résume le résultat, cite les sources (sujet, chemin, nom), propose la suite.",

View File

@ -1,3 +1,5 @@
import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
export type UltiAiToolGroup = { export type UltiAiToolGroup = {
id: string id: string
label: string label: string
@ -23,7 +25,7 @@ export const ULTIAI_TOOL_GROUPS: UltiAiToolGroup[] = [
}, },
{ {
id: "agenda", id: "agenda",
label: "Agenda", label: ULTICAL_APP_NAME,
description: "Calendriers, événements, invitations et visioconférence.", description: "Calendriers, événements, invitations et visioconférence.",
}, },
{ {

View File

@ -80,6 +80,9 @@ export type ApiOrgStorageQuotas = {
} }
export type ApiOrgUsageQuotas = { export type ApiOrgUsageQuotas = {
llm_daily_cost_limit_eur: number
llm_monthly_cost_limit_eur: number
llm_cost_warn_threshold_pct: number
llm_requests_per_day: number llm_requests_per_day: number
llm_tokens_per_month: number llm_tokens_per_month: number
search_requests_per_day: number search_requests_per_day: number

View File

@ -0,0 +1,105 @@
"use client"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { apiClient } from "@/lib/api/client"
export type AdminAIUsageSummary = {
cost_today_micro_eur: number
cost_month_micro_eur: number
cost_today_eur: number
cost_month_eur: number
currency: string
daily_series: {
date: string
cost_org_micro_eur: number
cost_user_micro_eur: number
cost_org_eur: number
requests: number
}[]
top_users: {
user_id: string
email: string
display_name: string
cost_org_micro_eur: number
cost_org_eur: number
}[]
top_models: {
model_id: string
cost_micro_eur: number
cost_eur: number
request_count: number
}[]
}
export type AdminAIPricingEntry = {
model_id: string
provider_type: string
input_micro_eur_per_mtok: number
cached_input_micro_eur_per_mtok?: number
output_micro_eur_per_mtok: number
input_eur_per_mtok: number
output_eur_per_mtok: number
}
export function useAdminAIUsage(scope: "org" | "user" = "org") {
return useQuery({
queryKey: ["admin", "ai", "usage", scope],
queryFn: () =>
apiClient.get<AdminAIUsageSummary>(`/admin/ai/usage?scope=${scope}`),
staleTime: 30_000,
})
}
export function useAdminAIPricing() {
return useQuery({
queryKey: ["admin", "ai", "pricing"],
queryFn: async () => {
const res = await apiClient.get<{ prices: AdminAIPricingEntry[] }>(
"/admin/ai/pricing"
)
return res.prices ?? []
},
staleTime: 60_000,
})
}
export function useUpdateAdminAIPricing() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (prices: AdminAIPricingEntry[]) =>
apiClient.put<{ prices: AdminAIPricingEntry[] }>("/admin/ai/pricing", {
prices,
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["admin", "ai", "pricing"] })
},
})
}
export function useUpdateUserAICostPolicy(userId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (body: {
daily_limit_eur?: number | null
monthly_limit_eur?: number | null
warn_threshold_pct?: number
}) => apiClient.put(`/admin/users/${userId}/ai-policy`, body),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["admin", "ai"] })
},
})
}
export function useUpdateGroupAICostPolicy(groupId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (body: {
daily_limit_eur?: number | null
monthly_limit_eur?: number | null
warn_threshold_pct?: number
}) => apiClient.put(`/admin/user-groups/${groupId}/ai-policy`, body),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["admin", "ai"] })
},
})
}

View File

@ -17,12 +17,45 @@ export type AiConfig = {
} }
export type AiQuota = { export type AiQuota = {
requests_used_today: number cost_used_today_micro_eur: number
requests_limit: number cost_limit_today_micro_eur?: number | null
tokens_used_month: number cost_used_month_micro_eur: number
tokens_limit: number cost_limit_month_micro_eur?: number | null
requests_remaining: number cost_remaining_today_micro_eur?: number | null
tokens_remaining: number cost_remaining_month_micro_eur?: number | null
warn_threshold_pct: number
currency: string
billing_scope_org: boolean
by_provider_keys?: {
fingerprint: string
label: string
cost_month_micro_eur: number
cost_month_eur: number
billing_scope: string
}[]
/** @deprecated */
requests_used_today?: number
requests_limit?: number
tokens_used_month?: number
tokens_limit?: number
requests_remaining?: number
tokens_remaining?: number
}
export function formatAiCostEUR(microEur: number | null | undefined): string {
const value = typeof microEur === "number" && Number.isFinite(microEur) ? microEur : 0
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
maximumFractionDigits: 4,
}).format(value / 1_000_000)
}
export function aiQuotaUsedMonthPct(quota: AiQuota): number | null {
const limit = quota.cost_limit_month_micro_eur
if (!limit || limit <= 0) return null
return Math.min(100, Math.round((quota.cost_used_month_micro_eur / limit) * 100))
} }
export type AiSessionResponse = { export type AiSessionResponse = {

View File

@ -1,7 +1,7 @@
import type { LucideIcon } from "lucide-react" import type { LucideIcon } from "lucide-react"
import { Home, ShieldCheck, UserRound } from "lucide-react" import { Home, ShieldCheck, Sparkles, UserRound } from "lucide-react"
export type CompteSettingsSectionId = "home" | "personal-info" | "security" export type CompteSettingsSectionId = "home" | "personal-info" | "security" | "usage-ia"
export type CompteSettingsNavItem = { export type CompteSettingsNavItem = {
id: CompteSettingsSectionId id: CompteSettingsSectionId
@ -26,6 +26,13 @@ export const COMPTE_SETTINGS_NAV: CompteSettingsNavItem[] = [
href: "/compte/informations", href: "/compte/informations",
icon: UserRound, icon: UserRound,
}, },
{
id: "usage-ia",
label: "Usage IA",
description: "Consommation LLM et clés API personnelles",
href: "/compte/usage-ia",
icon: Sparkles,
},
{ {
id: "security", id: "security",
label: "Sécurité", label: "Sécurité",

View File

@ -29,7 +29,7 @@ const DEMO_TEXT_BY_PATH: Record<string, string> = {
"/Release notes v2.3.txt": `# Ultimail v2.3 "/Release notes v2.3.txt": `# Ultimail v2.3
## Nouveautés ## Nouveautés
- UltiAgenda : visio UltiMeet intégrée aux événements - UltiCal : visio UltiMeet intégrée aux événements
- UltiDrive : favoris et corbeille unifiés - UltiDrive : favoris et corbeille unifiés
- Contacts : fusion et labels personnalisés - Contacts : fusion et labels personnalisés

View File

@ -1,3 +1,5 @@
import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
export type ApiTokenPermissionGroup = "mail" | "drive" | "contacts" | "agenda" | "automation" export type ApiTokenPermissionGroup = "mail" | "drive" | "contacts" | "agenda" | "automation"
export type ApiTokenAccess = "read" | "write" export type ApiTokenAccess = "read" | "write"
@ -54,7 +56,7 @@ export const API_TOKEN_PERMISSION_GROUPS: {
}, },
{ {
id: "agenda", id: "agenda",
label: "Agenda", label: ULTICAL_APP_NAME,
description: "Calendriers, événements, disponibilités et réponses aux invitations.", description: "Calendriers, événements, disponibilités et réponses aux invitations.",
}, },
{ {

View File

@ -5,6 +5,7 @@ import type {
TriggerOrGroup, TriggerOrGroup,
TriggerType, TriggerType,
} from './types' } from './types'
import { ULTICAL_APP_NAME } from '@/lib/suite/page-metadata'
export type AutomationDomain = 'mail' | 'drive' | 'contacts' | 'agenda' export type AutomationDomain = 'mail' | 'drive' | 'contacts' | 'agenda'
@ -12,7 +13,7 @@ export const AUTOMATION_DOMAIN_LABELS: Record<AutomationDomain, string> = {
mail: 'Mail', mail: 'Mail',
drive: 'Drive', drive: 'Drive',
contacts: 'Contacts', contacts: 'Contacts',
agenda: 'Agenda', agenda: ULTICAL_APP_NAME,
} }
export const DOMAIN_TRIGGER_TYPES: Record<AutomationDomain, TriggerType[]> = { export const DOMAIN_TRIGGER_TYPES: Record<AutomationDomain, TriggerType[]> = {

View File

@ -1,4 +1,5 @@
import type { LucideIcon } from "lucide-react" import type { LucideIcon } from "lucide-react"
import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
import { import {
Bell, Bell,
Bot, Bot,
@ -62,7 +63,7 @@ export const MAIL_SETTINGS_NAV: MailSettingsNavItem[] = [
}, },
{ {
id: "agenda", id: "agenda",
label: "Agenda", label: ULTICAL_APP_NAME,
description: "Affichage, visio, invitations, agendas et vues", description: "Affichage, visio, invitations, agendas et vues",
href: "/mail/settings/agenda", href: "/mail/settings/agenda",
icon: CalendarDays, icon: CalendarDays,

View File

@ -1,3 +1,4 @@
import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
import { suitePublicAsset } from "@/lib/suite/suite-public-asset" import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
export type FavoriteApp = { export type FavoriteApp = {
@ -19,7 +20,7 @@ export const SUITE_FAVORITE_APPS: FavoriteApp[] = [
href: "/compte", href: "/compte",
}, },
{ {
name: "Agenda", name: ULTICAL_APP_NAME,
icon: suitePublicAsset("/agenda-mark.svg"), icon: suitePublicAsset("/agenda-mark.svg"),
iconDark: suitePublicAsset("/agenda-mark-dark.svg"), iconDark: suitePublicAsset("/agenda-mark-dark.svg"),
href: "/agenda", href: "/agenda",

View File

@ -7,13 +7,16 @@ export type SuiteApp = "mail" | "drive" | "contacts" | "agenda" | "meet" | "admi
/** Separator between page segment and product name in document titles. */ /** Separator between page segment and product name in document titles. */
export const SUITE_TITLE_SEP = " - " export const SUITE_TITLE_SEP = " - "
/** Display name of the calendar app (`/agenda`). */
export const ULTICAL_APP_NAME = "UltiCal"
export const MAIL_INBOX_DOCUMENT_TITLE = `Boîte mail${SUITE_TITLE_SEP}Ultimail` export const MAIL_INBOX_DOCUMENT_TITLE = `Boîte mail${SUITE_TITLE_SEP}Ultimail`
const DESCRIPTIONS: Record<SuiteApp, string> = { const DESCRIPTIONS: Record<SuiteApp, string> = {
mail: "Client mail Ultimail — suite souveraine", mail: "Client mail Ultimail — suite souveraine",
drive: "Stockage de fichiers UltiDrive — suite UltiSuite", drive: "Stockage de fichiers UltiDrive — suite UltiSuite",
contacts: "Carnet d'adresses — UltiSuite", contacts: "Carnet d'adresses — UltiSuite",
agenda: "Agenda partagé, invitations et disponibilités — UltiSuite", agenda: "Calendrier partagé, invitations et disponibilités — UltiSuite",
meet: "Visioconférence chiffrée intégrée à la suite — UltiMeet", meet: "Visioconférence chiffrée intégrée à la suite — UltiMeet",
admin: "Console d'administration — UltiSuite", admin: "Console d'administration — UltiSuite",
compte: "Réglages du compte — UltiSuite", compte: "Réglages du compte — UltiSuite",
@ -24,7 +27,7 @@ const APP_LABELS: Record<SuiteApp, string> = {
mail: "Ultimail", mail: "Ultimail",
drive: "UltiDrive", drive: "UltiDrive",
contacts: "UltiSuite", contacts: "UltiSuite",
agenda: "Agenda", agenda: ULTICAL_APP_NAME,
meet: "UltiMeet", meet: "UltiMeet",
admin: "Administration", admin: "Administration",
compte: "Compte Ulti", compte: "Compte Ulti",

View File

@ -1,4 +1,5 @@
import { isDriveAppPath } from "@/lib/suite/drive-route" import { isDriveAppPath } from "@/lib/suite/drive-route"
import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
export type SuiteSplashApp = "mail" | "drive" | "agenda" | "contacts" export type SuiteSplashApp = "mail" | "drive" | "agenda" | "contacts"
@ -8,7 +9,7 @@ const LEGACY_MAIL_SPLASH_KEY = "ultimail-splash-seen-v1"
export type SuiteSplashConfig = { export type SuiteSplashConfig = {
pill: string pill: string
mark: string mark: string
/** Agenda : variante dark mode. */ /** UltiCal : variante dark mode. */
markDark?: string markDark?: string
subtitle: string subtitle: string
ariaLabel: string ariaLabel: string
@ -30,11 +31,11 @@ export const SUITE_SPLASH_CONFIG: Record<SuiteSplashApp, SuiteSplashConfig> = {
spinMark: true, spinMark: true,
}, },
agenda: { agenda: {
pill: "ULTIAGENDA", pill: "ULTICAL",
mark: "/agenda-mark.svg", mark: "/agenda-mark.svg",
markDark: "/agenda-mark-dark.svg", markDark: "/agenda-mark-dark.svg",
subtitle: "Chargement de votre agenda...", subtitle: "Chargement de votre agenda...",
ariaLabel: "Chargement de l'agenda", ariaLabel: `Chargement d'${ULTICAL_APP_NAME}`,
spinMark: true, spinMark: true,
}, },
contacts: { contacts: {

File diff suppressed because one or more lines are too long