ultisuite-client/components/agenda/agenda-calendars-settings.tsx
R3D347HR4Y ad1370ea7e
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat: enhance configuration and add new demo layouts
- Introduced turbopack alias for canvas in next.config.mjs.
- Updated package.json scripts for development and branding tasks.
- Added new dependencies for Tiptap extensions.
- Implemented new demo layouts for agenda, contacts, drive, and mail applications.
- Enhanced globals.css for improved theming and splash screen animations.
- Added OAuth callback handling for drive mounts.
- Updated layout components to integrate new demo shells and improve structure.
2026-06-12 19:10:24 +02:00

630 lines
22 KiB
TypeScript

"use client"
import { useEffect, useMemo, useState } from "react"
import { Link2, Pencil, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { AccountAvatar } from "@/components/gmail/account-avatar"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { AGENDA_COLOR_PALETTE } from "@/lib/agenda/agenda-colors"
import { isCalendarEnabledForAccount, isReservedAgendaViewName } from "@/lib/agenda/agenda-calendar-visibility"
import { calendarColor } from "@/lib/agenda/agenda-events"
import { useAgendaSettingsStore } from "@/lib/agenda/agenda-store"
import type { AgendaCalendarView, AgendaExternalCalendar } from "@/lib/agenda/agenda-settings-types"
import { useMergedAgendaCalendars } from "@/lib/agenda/use-visible-agenda-calendars"
import { useLabels } from "@/lib/api/hooks/use-folder-label-queries"
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
import { MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS } from "@/lib/mail-chrome-classes"
import { cn } from "@/lib/utils"
function CalendarCheckboxRow({
calendarId,
label,
color,
checked,
onToggle,
}: {
calendarId: string
label: string
color: string
checked: boolean
onToggle: () => void
}) {
return (
<label className="flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 hover:bg-mail-nav-hover">
<Checkbox checked={checked} onCheckedChange={onToggle} />
<span
aria-hidden
className="size-3.5 shrink-0 rounded-[4px]"
style={{ backgroundColor: color }}
/>
<span className="min-w-0 truncate text-sm text-foreground/85">{label}</span>
<span className="sr-only">{calendarId}</span>
</label>
)
}
export function ExternalCalendarDialog({
open,
onOpenChange,
calendar,
accounts,
}: {
open: boolean
onOpenChange: (open: boolean) => void
calendar: AgendaExternalCalendar | null
accounts: { id: string; name: string; email: string }[]
}) {
const addExternalCalendar = useAgendaSettingsStore((s) => s.addExternalCalendar)
const updateExternalCalendar = useAgendaSettingsStore((s) => s.updateExternalCalendar)
const [name, setName] = useState("")
const [url, setUrl] = useState("")
const [color, setColor] = useState(AGENDA_COLOR_PALETTE[0].value)
const [accountId, setAccountId] = useState<string>("all")
useEffect(() => {
if (!open) return
setName(calendar?.display_name ?? "")
setUrl(calendar?.url ?? "")
setColor(calendar?.color ?? AGENDA_COLOR_PALETTE[0].value)
setAccountId(calendar?.account_id ?? "all")
}, [open, calendar])
const submit = () => {
const displayName = name.trim()
const feedUrl = url.trim()
if (!displayName || !feedUrl) return
try {
const parsed = new URL(feedUrl)
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
toast.error("L'URL doit commencer par http:// ou https://")
return
}
} catch {
toast.error("URL iCal invalide")
return
}
const payload = {
display_name: displayName,
url: feedUrl,
color,
account_id: accountId === "all" ? null : accountId,
}
if (calendar) {
updateExternalCalendar(calendar.id, payload)
toast.success("Calendrier externe mis à jour")
} else {
addExternalCalendar(payload)
toast.success("Calendrier externe ajouté")
}
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>
{calendar ? "Modifier le calendrier externe" : "Ajouter un calendrier iCal"}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="space-y-2">
<Label htmlFor="ext-cal-name">Nom</Label>
<Input
id="ext-cal-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Calendrier externe"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ext-cal-url">Lien iCal</Label>
<Input
id="ext-cal-url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://…/calendar.ics"
/>
</div>
<div className="space-y-2">
<Label>Compte mail</Label>
<Select value={accountId} onValueChange={setAccountId}>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les comptes</SelectItem>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
{account.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Couleur</Label>
<div className="flex flex-wrap gap-2">
{AGENDA_COLOR_PALETTE.map((entry) => (
<button
key={entry.value}
type="button"
title={entry.label}
aria-label={entry.label}
onClick={() => setColor(entry.value)}
className={cn(
"size-7 rounded-full transition-transform hover:scale-110",
color === entry.value &&
"ring-2 ring-foreground/70 ring-offset-2 ring-offset-background",
)}
style={{ backgroundColor: entry.value }}
/>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Annuler
</Button>
<Button onClick={submit} disabled={!name.trim() || !url.trim()}>
{calendar ? "Enregistrer" : "Ajouter"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export function CalendarViewDialog({
open,
onOpenChange,
view,
calendarOptions,
labelOptions,
}: {
open: boolean
onOpenChange: (open: boolean) => void
view: AgendaCalendarView | null
calendarOptions: { id: string; label: string; color: string }[]
labelOptions: { id: string; label: string }[]
}) {
const addCalendarView = useAgendaSettingsStore((s) => s.addCalendarView)
const updateCalendarView = useAgendaSettingsStore((s) => s.updateCalendarView)
const [name, setName] = useState("")
const [calendarIds, setCalendarIds] = useState<string[]>([])
const [labelIds, setLabelIds] = useState<string[]>([])
useEffect(() => {
if (!open) return
setName(view?.name ?? "")
setCalendarIds(view?.calendar_ids ?? [])
setLabelIds(view?.label_ids ?? [])
}, [open, view])
const toggleCalendar = (calendarId: string) => {
setCalendarIds((current) =>
current.includes(calendarId)
? current.filter((id) => id !== calendarId)
: [...current, calendarId],
)
}
const toggleLabel = (labelId: string) => {
setLabelIds((current) =>
current.includes(labelId)
? current.filter((id) => id !== labelId)
: [...current, labelId],
)
}
const submit = () => {
const trimmed = name.trim()
if (!trimmed) return
if (isReservedAgendaViewName(trimmed)) {
toast.error('« Tous les agendas » est réservé à l\'affichage sans vue.')
return
}
if (calendarIds.length === 0) {
toast.error("Sélectionnez au moins un agenda")
return
}
const payload = {
name: trimmed,
calendar_ids: calendarIds,
label_ids: labelIds,
}
if (view) {
updateCalendarView(view.id, payload)
toast.success("Vue mise à jour")
} else {
addCalendarView(payload)
toast.success("Vue créée")
}
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>{view ? "Modifier la vue" : "Nouvelle vue d'agendas"}</DialogTitle>
</DialogHeader>
<div className="flex max-h-[min(70vh,520px)] flex-col gap-4 overflow-y-auto pr-1">
<div className="space-y-2">
<Label htmlFor="view-name">Nom de la vue</Label>
<Input
id="view-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Travail, Perso…"
/>
</div>
<div className="space-y-2">
<Label>Agendas inclus</Label>
<div className="max-h-44 space-y-0.5 overflow-y-auto rounded-lg border border-border p-1">
{calendarOptions.length === 0 ? (
<p className="px-2 py-3 text-xs text-muted-foreground">
Aucun agenda disponible.
</p>
) : (
calendarOptions.map((calendar) => (
<CalendarCheckboxRow
key={calendar.id}
calendarId={calendar.id}
label={calendar.label}
color={calendar.color}
checked={calendarIds.includes(calendar.id)}
onToggle={() => toggleCalendar(calendar.id)}
/>
))
)}
</div>
</div>
<div className="space-y-2">
<Label>Libellés associés</Label>
<p className="text-[11px] text-muted-foreground">
Pour filtrer les invitations importées depuis le mail (optionnel).
</p>
<div className="max-h-36 space-y-0.5 overflow-y-auto rounded-lg border border-border p-1">
{labelOptions.length === 0 ? (
<p className="px-2 py-3 text-xs text-muted-foreground">Aucun libellé.</p>
) : (
labelOptions.map((label) => (
<label
key={label.id}
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 hover:bg-mail-nav-hover"
>
<Checkbox
checked={labelIds.includes(label.id)}
onCheckedChange={() => toggleLabel(label.id)}
/>
<span className="truncate text-sm">{label.label}</span>
</label>
))
)}
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Annuler
</Button>
<Button onClick={submit} disabled={!name.trim()}>
{view ? "Enregistrer" : "Créer"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export function AgendaCalendarsSettingsFields({
variant = "panel",
}: {
variant?: "panel" | "page"
}) {
const { data: accounts = [] } = useMailAccounts()
const { data: labels = [] } = useLabels()
const { calendars, externalCalendars } = useMergedAgendaCalendars()
const accountEnabledCalendarIds = useAgendaSettingsStore(
(s) => s.accountEnabledCalendarIds,
)
const toggleAccountCalendar = useAgendaSettingsStore((s) => s.toggleAccountCalendar)
const removeExternalCalendar = useAgendaSettingsStore((s) => s.removeExternalCalendar)
const calendarViews = useAgendaSettingsStore((s) => s.calendarViews)
const defaultCalendarViewId = useAgendaSettingsStore((s) => s.defaultCalendarViewId)
const setDefaultCalendarViewId = useAgendaSettingsStore((s) => s.setDefaultCalendarViewId)
const removeCalendarView = useAgendaSettingsStore((s) => s.removeCalendarView)
const [externalDialogOpen, setExternalDialogOpen] = useState(false)
const [editingExternal, setEditingExternal] = useState<AgendaExternalCalendar | null>(
null,
)
const [viewDialogOpen, setViewDialogOpen] = useState(false)
const [editingView, setEditingView] = useState<AgendaCalendarView | null>(null)
const allCalendarIds = useMemo(
() => calendars.map((calendar) => calendar.id),
[calendars],
)
const calendarOptions = useMemo(
() =>
calendars.map((calendar) => ({
id: calendar.id,
label: calendar.display_name,
color: calendarColor(calendar),
})),
[calendars],
)
const labelOptions = useMemo(
() => labels.map((label) => ({ id: label.id, label: label.name })),
[labels],
)
const isPage = variant === "page"
const sectionShell = (position: "first" | "rest") =>
cn(
"space-y-3",
isPage
? MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS
: cn("px-4 py-3", position === "rest" && "border-t border-border"),
)
return (
<>
<section className={sectionShell("first")}>
<div>
<h2 className="text-sm font-medium text-foreground">Agendas par compte mail</h2>
<p className="mt-0.5 text-[11px] leading-snug text-muted-foreground">
Choisissez les agendas visibles pour chaque compte Ultimail.
</p>
</div>
{accounts.length === 0 ? (
<p className="text-xs text-muted-foreground">
Aucun compte mail connecté.{" "}
<a href="/mail/settings/accounts" className="text-[#1a73e8] hover:underline">
Ajouter un compte
</a>
</p>
) : (
<div className="divide-y divide-border overflow-hidden rounded-lg border border-border">
{accounts.map((account) => (
<div key={account.id} className="p-3">
<div className="mb-2 flex items-center gap-2">
<AccountAvatar account={account} size="sm" />
<div className="min-w-0">
<p className="truncate text-sm font-medium">{account.name}</p>
<p className="truncate text-xs text-muted-foreground">{account.email}</p>
</div>
</div>
<div className="space-y-0.5">
{calendars.length === 0 ? (
<p className="text-xs text-muted-foreground">Aucun agenda.</p>
) : (
calendars.map((calendar) => (
<CalendarCheckboxRow
key={`${account.id}-${calendar.id}`}
calendarId={calendar.id}
label={calendar.display_name}
color={calendarColor(calendar)}
checked={isCalendarEnabledForAccount(
calendar.id,
account.id,
accountEnabledCalendarIds,
)}
onToggle={() =>
toggleAccountCalendar(account.id, calendar.id, allCalendarIds)
}
/>
))
)}
</div>
</div>
))}
</div>
)}
</section>
<section className={sectionShell("rest")}>
<div className="flex items-start justify-between gap-2">
<div>
<h2 className="text-sm font-medium text-foreground">Calendriers externes</h2>
<p className="mt-0.5 text-[11px] leading-snug text-muted-foreground">
Abonnez un flux iCal (Google, Outlook, etc.).
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 gap-1 rounded-full"
onClick={() => {
setEditingExternal(null)
setExternalDialogOpen(true)
}}
>
<Plus className="size-3.5" />
Ajouter
</Button>
</div>
{externalCalendars.length === 0 ? (
<p className="text-xs text-muted-foreground">Aucun calendrier externe.</p>
) : (
<ul className="space-y-2">
{externalCalendars.map((calendar) => (
<li
key={calendar.id}
className="flex items-center gap-2 rounded-lg border border-border px-2 py-2"
>
<Link2 className="size-4 shrink-0 text-muted-foreground" />
<span
aria-hidden
className="size-3 shrink-0 rounded-full"
style={{ backgroundColor: calendar.color }}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{calendar.display_name}</p>
<p className="truncate text-[11px] text-muted-foreground">{calendar.url}</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0"
aria-label={`Modifier ${calendar.display_name}`}
onClick={() => {
setEditingExternal(calendar)
setExternalDialogOpen(true)
}}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0 text-destructive"
aria-label={`Supprimer ${calendar.display_name}`}
onClick={() => removeExternalCalendar(calendar.id)}
>
<Trash2 className="size-4" />
</Button>
</li>
))}
</ul>
)}
</section>
<section className={sectionShell("rest")}>
<div className="flex items-start justify-between gap-2">
<div>
<h2 className="text-sm font-medium text-foreground">Vues d&apos;agendas</h2>
<p className="mt-0.5 text-[11px] leading-snug text-muted-foreground">
Regroupez des agendas et libellés, puis ouvrez une vue depuis la barre latérale.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 gap-1 rounded-full"
onClick={() => {
setEditingView(null)
setViewDialogOpen(true)
}}
>
<Plus className="size-3.5" />
Nouvelle vue
</Button>
</div>
<div className="grid grid-cols-[minmax(0,0.72fr)_minmax(9.75rem,1.18fr)] items-center gap-2 py-1">
<Label className="text-xs font-normal text-muted-foreground">Vue par défaut</Label>
<Select
value={defaultCalendarViewId ?? "none"}
onValueChange={(value) =>
setDefaultCalendarViewId(value === "none" ? null : value)
}
>
<SelectTrigger className="h-8 w-full text-xs">
<SelectValue placeholder="Aucune vue" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Aucune vue</SelectItem>
{calendarViews.map((view) => (
<SelectItem key={view.id} value={view.id}>
{view.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{calendarViews.length === 0 ? (
<p className="text-xs text-muted-foreground">Aucune vue enregistrée.</p>
) : (
<ul className="space-y-2">
{calendarViews.map((view) => (
<li
key={view.id}
className="flex items-center gap-2 rounded-lg border border-border px-2 py-2"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{view.name}</p>
<p className="text-[11px] text-muted-foreground">
{view.calendar_ids.length} agenda
{view.calendar_ids.length > 1 ? "s" : ""}
{view.label_ids.length > 0
? ` · ${view.label_ids.length} libellé${view.label_ids.length > 1 ? "s" : ""}`
: ""}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0"
aria-label={`Modifier ${view.name}`}
onClick={() => {
setEditingView(view)
setViewDialogOpen(true)
}}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0 text-destructive"
aria-label={`Supprimer ${view.name}`}
onClick={() => removeCalendarView(view.id)}
>
<Trash2 className="size-4" />
</Button>
</li>
))}
</ul>
)}
</section>
<ExternalCalendarDialog
open={externalDialogOpen}
onOpenChange={setExternalDialogOpen}
calendar={editingExternal}
accounts={accounts}
/>
<CalendarViewDialog
open={viewDialogOpen}
onOpenChange={setViewDialogOpen}
view={editingView}
calendarOptions={calendarOptions}
labelOptions={labelOptions}
/>
</>
)
}