Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Introduced new components for managing admin settings, including AdminListControls, AdminSettingsCard, and TechBrandSelectLabel. - Implemented dynamic loading for admin settings sections to optimize performance. - Enhanced the layout of various admin settings sections for better user experience. - Updated the AiAssistantSection to include LLM provider management and improved model selection. - Refactored authentication settings to streamline configuration and improve accessibility.
467 lines
17 KiB
TypeScript
467 lines
17 KiB
TypeScript
"use client"
|
|
|
|
import { cn } from "@/lib/utils"
|
|
import { AgendaCalendarsSettingsFields } from "@/components/agenda/agenda-calendars-settings"
|
|
import { AgendaSettingsChipPicker } from "@/components/agenda/agenda-settings-chip-picker"
|
|
import { AgendaVideoProviderSelectLabel } from "@/components/agenda/agenda-video-provider-select-label"
|
|
import { ThemeModePreview } from "@/components/gmail/quick-settings/settings-preview-icons"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select"
|
|
import {
|
|
AGENDA_DURATION_STEPS,
|
|
AGENDA_QUICK_DURATION_OPTIONS,
|
|
AGENDA_VIDEO_PROVIDERS,
|
|
AGENDA_WEEK_START_OPTIONS,
|
|
type AgendaDurationStep,
|
|
type AgendaTimeFormat,
|
|
type AgendaVideoProvider,
|
|
type AgendaWeekStart,
|
|
} from "@/lib/agenda/agenda-settings-types"
|
|
import {
|
|
formatDurationStepLabel,
|
|
formatQuickDurationLabel,
|
|
formatTimeFormatLabel,
|
|
formatWeekStartLabel,
|
|
videoProviderLabel,
|
|
} from "@/lib/agenda/agenda-settings-labels"
|
|
import { useAgendaSettingsStore } from "@/lib/agenda/agenda-store"
|
|
import {
|
|
useAgendaSettingsDestinationOptions,
|
|
useAgendaSettingsIdentityOptions,
|
|
} from "@/lib/agenda/use-agenda-invitation-suggestions"
|
|
import {
|
|
normalizeAutoImportInvitationSources,
|
|
normalizeInvitationImportExclusions,
|
|
} from "@/lib/agenda/agenda-destination-identities"
|
|
import { useEffectiveAgendaSettings } from "@/lib/agenda/use-effective-agenda-settings"
|
|
import { useIsDemoApp } from "@/lib/demo/use-is-demo-app"
|
|
import { useThemeModeControls } from "@/lib/demo/use-theme-mode-controls"
|
|
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
|
|
import {
|
|
MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS,
|
|
} from "@/lib/mail-chrome-classes"
|
|
import type { MailThemeMode } from "@/lib/mail-settings/types"
|
|
|
|
const THEME_OPTIONS: { id: MailThemeMode; label: string }[] = [
|
|
{ id: "light", label: "Clair" },
|
|
{ id: "dark", label: "Sombre" },
|
|
{ id: "system", label: "Système" },
|
|
]
|
|
|
|
function SettingsSection({
|
|
title,
|
|
description,
|
|
action,
|
|
children,
|
|
variant = "panel",
|
|
}: {
|
|
title: string
|
|
description?: string
|
|
action?: React.ReactNode
|
|
children: React.ReactNode
|
|
variant?: "panel" | "page"
|
|
}) {
|
|
return (
|
|
<section
|
|
className={cn(
|
|
"space-y-2 border-b border-border px-4 py-3",
|
|
variant === "page" && MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS,
|
|
)}
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
|
{description ? (
|
|
<p className="mt-0.5 text-[11px] leading-snug text-muted-foreground">{description}</p>
|
|
) : null}
|
|
</div>
|
|
{action}
|
|
</div>
|
|
{children}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function PickerRow({
|
|
label,
|
|
children,
|
|
}: {
|
|
label: string
|
|
children: React.ReactNode
|
|
}) {
|
|
return (
|
|
<div className="grid grid-cols-[minmax(0,0.72fr)_minmax(9.75rem,1.18fr)] items-center gap-2 py-1">
|
|
<Label className="min-w-0 text-xs font-normal text-muted-foreground">{label}</Label>
|
|
<div className="min-w-0">{children}</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function minutesToTimeValue(minutes: number): string {
|
|
const h = Math.floor(minutes / 60)
|
|
const m = minutes % 60
|
|
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`
|
|
}
|
|
|
|
function timeValueToMinutes(value: string): number {
|
|
const [h, m] = value.split(":").map(Number)
|
|
if (!Number.isFinite(h) || !Number.isFinite(m)) return 0
|
|
return h * 60 + m
|
|
}
|
|
|
|
export function AgendaSettingsFields({
|
|
variant = "panel",
|
|
onOpenThemeDialog,
|
|
}: {
|
|
variant?: "panel" | "page"
|
|
onOpenThemeDialog?: () => void
|
|
}) {
|
|
const effective = useEffectiveAgendaSettings()
|
|
const isDemo = useIsDemoApp()
|
|
const { themeMode, setThemeMode } = useThemeModeControls()
|
|
|
|
const defaultVideoProvider = useAgendaSettingsStore((s) => s.defaultVideoProvider)
|
|
const setDefaultVideoProvider = useAgendaSettingsStore((s) => s.setDefaultVideoProvider)
|
|
const videoProviderApiKeys = useAgendaSettingsStore((s) => s.videoProviderApiKeys)
|
|
const setVideoProviderApiKey = useAgendaSettingsStore((s) => s.setVideoProviderApiKey)
|
|
const defaultInvitationIdentityKey = useAgendaSettingsStore(
|
|
(s) => s.defaultInvitationIdentityKey,
|
|
)
|
|
const setDefaultInvitationIdentityKey = useAgendaSettingsStore(
|
|
(s) => s.setDefaultInvitationIdentityKey,
|
|
)
|
|
const autoImportInvitationSources = useAgendaSettingsStore(
|
|
(s) => s.autoImportInvitationSources,
|
|
)
|
|
const setAutoImportInvitationSources = useAgendaSettingsStore(
|
|
(s) => s.setAutoImportInvitationSources,
|
|
)
|
|
const invitationImportExclusions = useAgendaSettingsStore(
|
|
(s) => s.invitationImportExclusions,
|
|
)
|
|
const setInvitationImportExclusions = useAgendaSettingsStore(
|
|
(s) => s.setInvitationImportExclusions,
|
|
)
|
|
const weekStart = useAgendaSettingsStore((s) => s.weekStart)
|
|
const setWeekStart = useAgendaSettingsStore((s) => s.setWeekStart)
|
|
const defaultQuickDurationMinutes = useAgendaSettingsStore(
|
|
(s) => s.defaultQuickDurationMinutes,
|
|
)
|
|
const setDefaultQuickDurationMinutes = useAgendaSettingsStore(
|
|
(s) => s.setDefaultQuickDurationMinutes,
|
|
)
|
|
const visibleHoursStart = useAgendaSettingsStore((s) => s.visibleHoursStart)
|
|
const visibleHoursEnd = useAgendaSettingsStore((s) => s.visibleHoursEnd)
|
|
const setVisibleHoursStart = useAgendaSettingsStore((s) => s.setVisibleHoursStart)
|
|
const setVisibleHoursEnd = useAgendaSettingsStore((s) => s.setVisibleHoursEnd)
|
|
const timeFormat = useAgendaSettingsStore((s) => s.timeFormat)
|
|
const setTimeFormat = useAgendaSettingsStore((s) => s.setTimeFormat)
|
|
const dragSnapMinutes = useAgendaSettingsStore((s) => s.dragSnapMinutes)
|
|
const setDragSnapMinutes = useAgendaSettingsStore((s) => s.setDragSnapMinutes)
|
|
const buttonSnapMinutes = useAgendaSettingsStore((s) => s.buttonSnapMinutes)
|
|
const setButtonSnapMinutes = useAgendaSettingsStore((s) => s.setButtonSnapMinutes)
|
|
|
|
const identityOptions = useAgendaSettingsIdentityOptions()
|
|
const destinationOptions = useAgendaSettingsDestinationOptions()
|
|
const activeTheme = effective.orgEnforcesTheme ? effective.themeMode : themeMode
|
|
const activeProvider = effective.orgEnforcesVideoProvider
|
|
? effective.defaultVideoProvider
|
|
: defaultVideoProvider
|
|
|
|
const needsUserApiKey =
|
|
!effective.orgEnforcesVideoProvider &&
|
|
activeProvider !== "ultimeet" &&
|
|
activeProvider !== "none"
|
|
|
|
const autoImportItems = normalizeAutoImportInvitationSources(
|
|
autoImportInvitationSources,
|
|
destinationOptions,
|
|
)
|
|
const exclusionItems = normalizeInvitationImportExclusions(
|
|
invitationImportExclusions,
|
|
destinationOptions,
|
|
)
|
|
|
|
const isPage = variant === "page"
|
|
|
|
const fields = (
|
|
<>
|
|
{!isPage && !isDemo ? (
|
|
<SettingsSection
|
|
title="Thème"
|
|
variant={variant}
|
|
action={
|
|
onOpenThemeDialog ? (
|
|
<button
|
|
type="button"
|
|
className="shrink-0 text-xs text-[#1a73e8] hover:underline disabled:opacity-50"
|
|
disabled={effective.orgEnforcesTheme}
|
|
onClick={onOpenThemeDialog}
|
|
>
|
|
Arrière-plan
|
|
</button>
|
|
) : null
|
|
}
|
|
>
|
|
{effective.orgEnforcesTheme ? (
|
|
<p className="text-[11px] text-muted-foreground">
|
|
Thème imposé par votre organisation.
|
|
</p>
|
|
) : null}
|
|
<div className="grid grid-cols-3 gap-1.5">
|
|
{THEME_OPTIONS.map((opt) => (
|
|
<button
|
|
key={opt.id}
|
|
type="button"
|
|
disabled={effective.orgEnforcesTheme}
|
|
onClick={() => setThemeMode(opt.id)}
|
|
className={cn(
|
|
"rounded-lg border-2 p-1 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
|
activeTheme === opt.id
|
|
? "border-primary bg-accent/60"
|
|
: "border-border hover:border-muted-foreground/50 hover:bg-accent/40",
|
|
)}
|
|
>
|
|
<ThemeModePreview mode={opt.id} className="h-9" />
|
|
<span className="mt-0.5 block text-center text-[11px] text-foreground">
|
|
{opt.label}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</SettingsSection>
|
|
) : null}
|
|
|
|
<SettingsSection title="Affichage" variant={variant}>
|
|
<PickerRow label="Premier jour">
|
|
<Select
|
|
value={String(weekStart)}
|
|
onValueChange={(v) =>
|
|
setWeekStart(
|
|
v === "auto" ? "auto" : (Number(v) as AgendaWeekStart),
|
|
)
|
|
}
|
|
>
|
|
<SelectTrigger className="h-8 w-full text-xs">
|
|
<SelectValue>{formatWeekStartLabel(weekStart)}</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{AGENDA_WEEK_START_OPTIONS.map((opt) => (
|
|
<SelectItem key={String(opt.value)} value={String(opt.value)}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</PickerRow>
|
|
<PickerRow label="Format horaire">
|
|
<Select
|
|
value={timeFormat}
|
|
onValueChange={(v) => setTimeFormat(v as AgendaTimeFormat)}
|
|
>
|
|
<SelectTrigger className="h-8 w-full text-xs">
|
|
<SelectValue>{formatTimeFormatLabel(timeFormat)}</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="24h">24 h</SelectItem>
|
|
<SelectItem value="12h">AM / PM</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</PickerRow>
|
|
<PickerRow label="Heures visibles">
|
|
<div className="grid grid-cols-2 gap-1.5">
|
|
<Input
|
|
type="time"
|
|
className="h-8 text-xs [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
|
|
value={minutesToTimeValue(visibleHoursStart)}
|
|
onChange={(e) => setVisibleHoursStart(timeValueToMinutes(e.target.value))}
|
|
/>
|
|
<Input
|
|
type="time"
|
|
className="h-8 text-xs [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
|
|
value={minutesToTimeValue(visibleHoursEnd)}
|
|
onChange={(e) => setVisibleHoursEnd(timeValueToMinutes(e.target.value))}
|
|
/>
|
|
</div>
|
|
</PickerRow>
|
|
</SettingsSection>
|
|
|
|
<SettingsSection title="Création rapide" variant={variant}>
|
|
<PickerRow label="Durée par défaut">
|
|
<Select
|
|
value={String(defaultQuickDurationMinutes)}
|
|
onValueChange={(v) => setDefaultQuickDurationMinutes(Number(v))}
|
|
>
|
|
<SelectTrigger className="h-8 w-full text-xs">
|
|
<SelectValue>{formatQuickDurationLabel(defaultQuickDurationMinutes)}</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{AGENDA_QUICK_DURATION_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.minutes} value={String(opt.minutes)}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</PickerRow>
|
|
<PickerRow label="Arrondi glisser">
|
|
<Select
|
|
value={String(dragSnapMinutes)}
|
|
onValueChange={(v) => setDragSnapMinutes(Number(v) as AgendaDurationStep)}
|
|
>
|
|
<SelectTrigger className="h-8 w-full text-xs">
|
|
<SelectValue>{formatDurationStepLabel(dragSnapMinutes)}</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{AGENDA_DURATION_STEPS.map((step) => (
|
|
<SelectItem key={step} value={String(step)}>
|
|
{formatDurationStepLabel(step)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</PickerRow>
|
|
<PickerRow label="Arrondi boutons +/-">
|
|
<Select
|
|
value={String(buttonSnapMinutes)}
|
|
onValueChange={(v) => setButtonSnapMinutes(Number(v) as AgendaDurationStep)}
|
|
>
|
|
<SelectTrigger className="h-8 w-full text-xs">
|
|
<SelectValue>{formatDurationStepLabel(buttonSnapMinutes)}</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{AGENDA_DURATION_STEPS.map((step) => (
|
|
<SelectItem key={step} value={String(step)}>
|
|
{formatDurationStepLabel(step)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</PickerRow>
|
|
</SettingsSection>
|
|
|
|
<SettingsSection
|
|
title="Visioconférence"
|
|
variant={variant}
|
|
description={
|
|
effective.orgEnforcesVideoProvider
|
|
? "Fournisseur imposé par votre organisation."
|
|
: undefined
|
|
}
|
|
>
|
|
<PickerRow label="Fournisseur">
|
|
<Select
|
|
value={activeProvider}
|
|
disabled={effective.orgEnforcesVideoProvider}
|
|
onValueChange={(v) => setDefaultVideoProvider(v as AgendaVideoProvider)}
|
|
>
|
|
<SelectTrigger className="h-8 w-full text-xs">
|
|
<SelectValue>
|
|
<AgendaVideoProviderSelectLabel provider={activeProvider} />
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{AGENDA_VIDEO_PROVIDERS.map((provider) => (
|
|
<SelectItem key={provider} value={provider}>
|
|
<AgendaVideoProviderSelectLabel provider={provider} />
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</PickerRow>
|
|
{needsUserApiKey ? (
|
|
<div className="pt-1">
|
|
<Label htmlFor="agenda-video-api-key" className="text-[11px] text-muted-foreground">
|
|
Clé API {videoProviderLabel(activeProvider)}
|
|
</Label>
|
|
<Input
|
|
id="agenda-video-api-key"
|
|
type="password"
|
|
autoComplete="off"
|
|
className="mt-1 h-8 text-xs"
|
|
placeholder="Optionnel si configurée par l'organisation"
|
|
value={videoProviderApiKeys[activeProvider] ?? ""}
|
|
onChange={(e) => setVideoProviderApiKey(activeProvider, e.target.value)}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</SettingsSection>
|
|
|
|
<SettingsSection
|
|
title="Invitations par mail"
|
|
variant={variant}
|
|
description="Import automatique des fichiers .ics reçus et envoi des réponses."
|
|
>
|
|
<PickerRow label="Envoi par défaut">
|
|
<Select
|
|
value={defaultInvitationIdentityKey ?? "auto"}
|
|
onValueChange={(v) =>
|
|
setDefaultInvitationIdentityKey(v === "auto" ? null : v)
|
|
}
|
|
>
|
|
<SelectTrigger className="h-8 w-full text-xs">
|
|
<SelectValue placeholder="Identité par défaut" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="auto">Identité mail par défaut</SelectItem>
|
|
{identityOptions.map((identity) => (
|
|
<SelectItem key={identity.key} value={identity.key}>
|
|
{identity.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</PickerRow>
|
|
|
|
<div className="space-y-1 pt-1">
|
|
<Label className="text-xs font-normal text-muted-foreground">
|
|
Import automatique depuis
|
|
</Label>
|
|
<AgendaSettingsChipPicker
|
|
items={autoImportItems}
|
|
allowedTypes={["identity", "contact"]}
|
|
placeholder="Mails de destination ou contacts…"
|
|
emptyHint="Boîtes qui reçoivent les .ics, ou expéditeurs à importer."
|
|
onChange={(items) =>
|
|
setAutoImportInvitationSources(
|
|
normalizeAutoImportInvitationSources(items, destinationOptions),
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1 pt-2">
|
|
<Label className="text-xs font-normal text-muted-foreground">
|
|
Ne pas importer automatiquement
|
|
</Label>
|
|
<AgendaSettingsChipPicker
|
|
items={exclusionItems}
|
|
placeholder="Contact, adresse, dossier, libellé…"
|
|
emptyHint="Excluez expéditeurs, dossiers ou règles contacts."
|
|
onChange={(items) =>
|
|
setInvitationImportExclusions(
|
|
normalizeInvitationImportExclusions(items, destinationOptions),
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
</SettingsSection>
|
|
|
|
<AgendaCalendarsSettingsFields variant={variant} />
|
|
</>
|
|
)
|
|
|
|
if (isPage) {
|
|
return <AutomationTabMasonry columns={2}>{fields}</AutomationTabMasonry>
|
|
}
|
|
|
|
return fields
|
|
}
|