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
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:
parent
770669424e
commit
2a0958b70d
@ -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 },
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
351
components/admin/settings/sections/ai-usage-section.tsx
Normal file
351
components/admin/settings/sections/ai-usage-section.tsx
Normal 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}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 })}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
84
components/ai/ai-spend-bar.tsx
Normal file
84
components/ai/ai-spend-bar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
101
components/compte/sections/compte-ai-usage-section.tsx
Normal file
101
components/compte/sections/compte-ai-usage-section.tsx
Normal 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}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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)),
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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.",
|
||||||
|
|||||||
@ -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.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
105
lib/api/hooks/use-admin-ai-usage.ts
Normal file
105
lib/api/hooks/use-admin-ai-usage.ts
Normal 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"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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 = {
|
||||||
|
|||||||
@ -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é",
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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[]> = {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
Loading…
Reference in New Issue
Block a user