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 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 = {
...suitePageMetadata({
app: "agenda",
title: "Démo UltiAgenda",
title: `Démo ${ULTICAL_APP_NAME}`,
absoluteTitle: true,
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 },
}

View File

@ -18,12 +18,12 @@ import {
useStartMigrationOAuth,
} from "@/lib/api/hooks/use-hosted-mail"
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> = {
mail: "Mail",
contacts: "Contacts",
calendar: "Agenda",
calendar: ULTICAL_APP_NAME,
drive: "Drive",
}

View File

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

View File

@ -23,7 +23,7 @@ import {
SelectTrigger,
SelectValue,
} 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 }[] = [
{ id: "light", label: "Clair" },
@ -52,7 +52,7 @@ export function AgendaSection() {
return (
<OrgSettingsSection
title="Agenda"
title={ULTICAL_APP_NAME}
description="Thème et visioconférence par défaut pour toute l'organisation."
policySection="agenda"
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,
useRetryMigrationJob,
} 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> = {
mail: "Mail",
contacts: "Contacts",
calendar: "Agenda",
calendar: ULTICAL_APP_NAME,
drive: "Drive",
}

View File

@ -31,6 +31,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
import { cn } from "@/lib/utils"
const SIMPLE_PLUGIN_IDS = new Set(["mail-automation", "contact-discovery", "public-share"])
@ -201,7 +202,7 @@ function NextcloudPluginCard() {
return (
<PluginToggleCard
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}
locked={enabledLocked}
lockSection="nextcloud"
@ -262,7 +263,7 @@ function NextcloudPluginCard() {
onChange={(drive_enabled) => setNextcloud({ drive_enabled })}
/>
<ServiceToggle
label="Agenda"
label={ULTICAL_APP_NAME}
checked={nextcloud.calendar_enabled}
onChange={(calendar_enabled) => setNextcloud({ calendar_enabled })}
/>

View File

@ -71,22 +71,37 @@ export function QuotasSection() {
<SettingsCard
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}>
<SettingsNumberField
label="Requêtes LLM"
unit="/ jour"
value={usageQuotas.llm_requests_per_day}
onChange={(v) => setUsageQuotas({ llm_requests_per_day: v })}
label="Plafond journalier"
unit="€"
step={0.5}
value={usageQuotas.llm_daily_cost_limit_eur}
onChange={(v) => setUsageQuotas({ llm_daily_cost_limit_eur: v })}
/>
<SettingsNumberField
label="Tokens LLM"
unit="/ mois"
value={usageQuotas.llm_tokens_per_month}
onChange={(v) => setUsageQuotas({ llm_tokens_per_month: v })}
label="Plafond mensuel"
unit="€"
step={1}
value={usageQuotas.llm_monthly_cost_limit_eur}
onChange={(v) => setUsageQuotas({ llm_monthly_cost_limit_eur: v })}
/>
</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

View File

@ -17,6 +17,7 @@ import {
useSetAdminUserRole,
useUpdateAdminUser,
} 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 { bytesToGib, formatBytes, gibToBytes } from "@/lib/admin/format-bytes"
import {
@ -514,12 +515,15 @@ function UserDetailSheet({
const updateUser = useUpdateAdminUser(userId ?? "")
const setRole = useSetAdminUserRole(userId ?? "")
const setQuota = useSetAdminUserQuota(userId ?? "")
const setAiPolicy = useUpdateUserAICostPolicy(userId ?? "")
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [selectedRole, setSelectedRole] = useState<AdminUserRole>("user")
const [mailGib, setMailGib] = useState("5")
const [driveGib, setDriveGib] = useState("5")
const [photosGib, setPhotosGib] = useState("5")
const [aiDailyEur, setAiDailyEur] = useState("")
const [aiMonthlyEur, setAiMonthlyEur] = useState("")
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 (
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent className="flex flex-col gap-0 overflow-y-auto p-0 sm:max-w-lg">
@ -646,6 +658,40 @@ function UserDetailSheet({
</div>
) : 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>
</div>
)}

View File

@ -22,6 +22,7 @@ import {
} from "@/lib/agenda/agenda-url"
import { useAgendaSettingsStore, useAgendaUIStore } from "@/lib/agenda/agenda-store"
import { useIsMobile } from "@/hooks/use-mobile"
import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
import {
SUITE_APP_LOGO_LOCKUP_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)}
>
<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>
<Button

View File

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

View File

@ -7,11 +7,13 @@ import {
import { CompteHomeSection } from "@/components/compte/sections/compte-home-section"
import { ComptePersonalInfoSection } from "@/components/compte/sections/compte-personal-info-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> = {
home: CompteHomeSection,
"personal-info": ComptePersonalInfoSection,
security: CompteSecuritySection,
"usage-ia": CompteAiUsageSection,
}
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",
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",
start: formatICSDateTimeUTC(clientCallStart),
end: formatICSDateTimeUTC(addHours(clientCallStart, 1)),

View File

@ -137,7 +137,9 @@ export function RuleSimulatorPanel({ state, ruleId }: RuleSimulatorPanelProps) {
{domains.includes('agenda') ? (
<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>
<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 })} />
</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 })} />
</div>
</div>

View File

@ -1,13 +1,13 @@
"use client"
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() {
return (
<>
<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."
/>
<AgendaSettingsFields variant="page" />

View File

@ -14,7 +14,7 @@ export function AutomationSettingsSection() {
<>
<SettingsSectionHeader
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">
<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"
export type LandingApp = {
@ -59,10 +60,10 @@ export const LANDING_APPS: LandingApp[] = [
accent: "#5a6172",
},
{
name: "Agenda",
name: ULTICAL_APP_NAME,
tagline: "Calendrier",
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"),
iconDark: suitePublicAsset("/agenda-mark-dark.svg"),
href: "/agenda",
@ -72,7 +73,7 @@ export const LANDING_APPS: LandingApp[] = [
name: "UltiMeet",
tagline: "Visio",
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"),
href: "/meet",
accent: "#34A853",

View File

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

View File

@ -39,7 +39,7 @@ export const DEFAULT_ORG_PLUGINS: PluginEntry[] = [
{
id: "ai-assistant",
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,
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,
},
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),
llm: {
...policy.llm,

View File

@ -58,11 +58,14 @@ const DEFAULT_STORAGE_QUOTAS: OrgStorageQuotas = {
}
const DEFAULT_USAGE_QUOTAS: UsageQuotaDefaults = {
llm_requests_per_day: 100,
llm_tokens_per_month: 500_000,
search_requests_per_day: 50,
max_api_tokens_per_user: 10,
max_webhooks_per_user: 20,
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,
}
const DEFAULT_FILE_POLICIES: FilePolicySettings = {
@ -169,7 +172,7 @@ const DEFAULT_INTEGRATIONS: IntegrationEntry[] = [
{
id: "nextcloud",
name: "Nextcloud",
description: "Drive, agenda, contacts et Talk.",
description: "Drive, UltiCal, contacts et Talk.",
enabled: false,
configured: false,
href: "/admin/settings/plugins",

View File

@ -91,6 +91,10 @@ export type OrgStorageQuotas = {
}
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_tokens_per_month: number
search_requests_per_day: number

View File

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

View File

@ -64,7 +64,7 @@ export function buildEmbedSearchParams(
export function systemPromptFromContext(context: AiChatContext): string {
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.",
"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.",

View File

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

View File

@ -80,6 +80,9 @@ export type ApiOrgStorageQuotas = {
}
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_tokens_per_month: 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 = {
requests_used_today: number
requests_limit: number
tokens_used_month: number
tokens_limit: number
requests_remaining: number
tokens_remaining: number
cost_used_today_micro_eur: number
cost_limit_today_micro_eur?: number | null
cost_used_month_micro_eur: number
cost_limit_month_micro_eur?: number | null
cost_remaining_today_micro_eur?: number | null
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 = {

View File

@ -1,7 +1,7 @@
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 = {
id: CompteSettingsSectionId
@ -26,6 +26,13 @@ export const COMPTE_SETTINGS_NAV: CompteSettingsNavItem[] = [
href: "/compte/informations",
icon: UserRound,
},
{
id: "usage-ia",
label: "Usage IA",
description: "Consommation LLM et clés API personnelles",
href: "/compte/usage-ia",
icon: Sparkles,
},
{
id: "security",
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
## 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
- 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 ApiTokenAccess = "read" | "write"
@ -54,7 +56,7 @@ export const API_TOKEN_PERMISSION_GROUPS: {
},
{
id: "agenda",
label: "Agenda",
label: ULTICAL_APP_NAME,
description: "Calendriers, événements, disponibilités et réponses aux invitations.",
},
{

View File

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

View File

@ -1,4 +1,5 @@
import type { LucideIcon } from "lucide-react"
import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
import {
Bell,
Bot,
@ -62,7 +63,7 @@ export const MAIL_SETTINGS_NAV: MailSettingsNavItem[] = [
},
{
id: "agenda",
label: "Agenda",
label: ULTICAL_APP_NAME,
description: "Affichage, visio, invitations, agendas et vues",
href: "/mail/settings/agenda",
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"
export type FavoriteApp = {
@ -19,7 +20,7 @@ export const SUITE_FAVORITE_APPS: FavoriteApp[] = [
href: "/compte",
},
{
name: "Agenda",
name: ULTICAL_APP_NAME,
icon: suitePublicAsset("/agenda-mark.svg"),
iconDark: suitePublicAsset("/agenda-mark-dark.svg"),
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. */
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`
const DESCRIPTIONS: Record<SuiteApp, string> = {
mail: "Client mail Ultimail — suite souveraine",
drive: "Stockage de fichiers UltiDrive — suite 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",
admin: "Console d'administration — UltiSuite",
compte: "Réglages du compte — UltiSuite",
@ -24,7 +27,7 @@ const APP_LABELS: Record<SuiteApp, string> = {
mail: "Ultimail",
drive: "UltiDrive",
contacts: "UltiSuite",
agenda: "Agenda",
agenda: ULTICAL_APP_NAME,
meet: "UltiMeet",
admin: "Administration",
compte: "Compte Ulti",

View File

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

File diff suppressed because one or more lines are too long