ultisuite-client/components/agenda/agenda-event-dialog.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

416 lines
13 KiB
TypeScript

"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import { addDays, addHours } from "date-fns"
import { toast } from "sonner"
import {
AlignLeft,
CalendarDays,
MapPin,
Repeat,
Trash2,
Users,
Video,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
import { AgendaEventScheduleFields, agendaScheduleFieldCount } from "@/components/agenda/agenda-event-schedule-fields"
import { AgendaGuestPicker } from "@/components/agenda/agenda-guest-picker"
import { AgendaVideoToggle } from "@/components/agenda/agenda-video-toggle"
import {
createAgendaEventWithVideo,
saveAgendaEventEdit,
} from "@/lib/agenda/agenda-save-with-video"
import {
useCreateAgendaEvent,
useCreateAgendaMeetLink,
useDeleteAgendaEvent,
useUpdateAgendaEvent,
} from "@/lib/api/hooks/use-calendar-mutations"
import { AGENDA_COLOR_PALETTE } from "@/lib/agenda/agenda-colors"
import { calendarColor } from "@/lib/agenda/agenda-events"
import { describeRRule, recurrenceOptionsFor } from "@/lib/agenda/agenda-recurrence"
import type {
AgendaCalendar,
AgendaEvent,
AgendaEventAttendee,
AgendaEventDraft,
} from "@/lib/agenda/agenda-types"
import { useEffectiveAgendaSettings } from "@/lib/agenda/use-effective-agenda-settings"
import { cn } from "@/lib/utils"
export interface AgendaEventDialogState {
mode: "create" | "edit"
draft: AgendaEventDraft
/** Présent en édition. */
event?: AgendaEvent
}
export function AgendaEventDialog({
state,
onClose,
calendars,
userEmail,
onDraftChange,
}: {
state: AgendaEventDialogState | null
onClose: () => void
calendars: AgendaCalendar[]
userEmail?: string
onDraftChange?: (draft: AgendaEventDraft) => void
}) {
const createMutation = useCreateAgendaEvent()
const updateMutation = useUpdateAgendaEvent()
const deleteMutation = useDeleteAgendaEvent()
const meetLinkMutation = useCreateAgendaMeetLink()
const { buttonSnapMinutes, defaultVideoProvider } = useEffectiveAgendaSettings()
const [title, setTitle] = useState("")
const [allDay, setAllDay] = useState(false)
const [start, setStart] = useState(() => new Date())
const [end, setEnd] = useState(() => new Date())
const [calendarId, setCalendarId] = useState("")
const [rrule, setRRule] = useState("")
const [color, setColor] = useState("")
const [location, setLocation] = useState("")
const [description, setDescription] = useState("")
const [attendees, setAttendees] = useState<AgendaEventAttendee[]>([])
const [includeVideo, setIncludeVideo] = useState(false)
const [meetUrl, setMeetUrl] = useState("")
const titleRef = useRef<HTMLInputElement>(null)
const open = state !== null
const isEdit = state?.mode === "edit"
useEffect(() => {
if (!state) return
const d = state.draft
setTitle(d.title)
setAllDay(d.allDay)
setStart(d.start)
setEnd(d.allDay ? addDays(d.end, -1) : d.end)
setCalendarId(d.calendarId || calendars[0]?.id || "")
setRRule(d.rrule ?? "")
setColor(d.color ?? "")
setLocation(d.location ?? "")
setDescription(d.description ?? "")
setAttendees(d.attendees ?? [])
setIncludeVideo(Boolean(d.includeVideo || state.event?.meetUrl))
setMeetUrl(state.event?.meetUrl ?? "")
if (state.mode === "create") {
const timer = window.setTimeout(() => titleRef.current?.focus(), 0)
return () => window.clearTimeout(timer)
}
}, [state, calendars])
useEffect(() => {
if (allDay) setIncludeVideo(false)
}, [allDay])
const recurrenceOptions = useMemo(() => {
const options = recurrenceOptionsFor(start)
if (rrule && !options.some((o) => o.value === rrule)) {
options.push({ value: rrule, label: describeRRule(rrule) })
}
return options
}, [start, rrule])
const pending =
createMutation.isPending ||
updateMutation.isPending ||
deleteMutation.isPending ||
meetLinkMutation.isPending
const buildDraft = (): AgendaEventDraft | null => {
if (!calendarId) return null
let eventStart = start
let eventEnd = allDay ? addDays(end, 1) : end
if (allDay && eventEnd <= eventStart) eventEnd = addDays(eventStart, 1)
if (!allDay && eventEnd <= eventStart) eventEnd = addHours(eventStart, 1)
return {
title,
start: eventStart,
end: eventEnd,
allDay,
calendarId,
description,
location,
attendees,
rrule,
color: color || undefined,
includeVideo: includeVideo && !allDay,
}
}
useEffect(() => {
if (!open || isEdit || !onDraftChange) return
const draft = buildDraft()
if (draft) onDraftChange(draft)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
open,
isEdit,
title,
allDay,
start,
end,
calendarId,
color,
includeVideo,
onDraftChange,
])
const calendar = calendars.find((c) => c.id === calendarId) ?? calendars[0]
const submit = async () => {
const draft = buildDraft()
if (!draft || !calendar) return
try {
if (isEdit && state?.event) {
await saveAgendaEventEdit({
draft,
path: state.event.path,
etag: state.event.etag,
master: state.event.master,
includeVideo: includeVideo && !allDay,
meetUrl,
videoProvider: defaultVideoProvider,
updateMutation,
meetLinkMutation,
})
toast.success("Événement mis à jour")
} else {
await createAgendaEventWithVideo({
draft,
calendar,
userEmail,
includeVideo: includeVideo && !allDay,
videoProvider: defaultVideoProvider,
createMutation,
meetLinkMutation,
})
toast.success(
includeVideo && !allDay ? "Événement et visio créés" : "Événement créé",
)
}
onClose()
} catch {
toast.error("Impossible d'enregistrer l'événement")
}
}
const remove = async () => {
if (!state?.event) return
try {
await deleteMutation.mutateAsync({ path: state.event.path })
toast.success("Événement supprimé")
onClose()
} catch {
toast.error("Impossible de supprimer l'événement")
}
}
const handleVideoChange = (enabled: boolean) => {
setIncludeVideo(enabled)
if (!enabled) setMeetUrl("")
}
const scheduleTabCount = agendaScheduleFieldCount({
allDay,
showAllDayToggle: true,
compact: false,
})
const tab = (offset: number) => 2 + scheduleTabCount + offset
return (
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) onClose()
}}
>
<DialogContent
className="flex max-h-[90vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-xl"
aria-describedby={undefined}
>
<DialogHeader className="sr-only">
<DialogTitle>
{isEdit ? "Modifier l'événement" : "Nouvel événement"}
</DialogTitle>
</DialogHeader>
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-5 pb-4 pt-4">
<Input
ref={titleRef}
value={title}
tabIndex={1}
autoFocus={!isEdit}
onChange={(e) => setTitle(e.target.value)}
placeholder="Ajouter un titre"
className="h-11 rounded-none border-0 border-b-2 border-border/60 !bg-transparent px-1 !text-xl shadow-none focus-visible:border-primary focus-visible:ring-0"
/>
<AgendaEventScheduleFields
start={start}
end={end}
allDay={allDay}
stepMinutes={buttonSnapMinutes}
tabIndexBase={2}
showRowLabels
showAllDayToggle
onAllDayChange={setAllDay}
onChange={(nextStart, nextEnd) => {
setStart(nextStart)
setEnd(allDay ? nextEnd : nextEnd)
}}
/>
<div className="flex items-center gap-3">
<Repeat className="size-5 shrink-0 text-muted-foreground" />
<Select value={rrule || "none"} onValueChange={(v) => setRRule(v === "none" ? "" : v)}>
<SelectTrigger tabIndex={tab(0)} className="h-9 w-fit min-w-52 border-0 bg-muted/60 shadow-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
{recurrenceOptions.map((o) => (
<SelectItem key={o.value || "none"} value={o.value || "none"}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-start gap-3">
<Users className="mt-2 size-5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<AgendaGuestPicker
attendees={attendees}
onChange={setAttendees}
organizerEmail={userEmail}
tabIndex={tab(1)}
/>
</div>
</div>
{!allDay && defaultVideoProvider !== "none" ? (
<div className="flex items-start gap-3">
<Video className="mt-2 size-5 shrink-0 text-muted-foreground" aria-hidden />
<AgendaVideoToggle
provider={defaultVideoProvider}
enabled={includeVideo}
meetUrl={includeVideo ? meetUrl : undefined}
onEnabledChange={handleVideoChange}
pending={pending}
tabIndex={tab(2)}
/>
</div>
) : null}
<div className="flex items-center gap-3">
<MapPin className="size-5 shrink-0 text-muted-foreground" />
<Input
value={location}
tabIndex={tab(3)}
onChange={(e) => setLocation(e.target.value)}
placeholder="Ajouter un lieu"
className="h-9 flex-1"
/>
</div>
<div className="flex items-start gap-3">
<AlignLeft className="mt-2 size-5 shrink-0 text-muted-foreground" />
<Textarea
value={description}
tabIndex={tab(4)}
onChange={(e) => setDescription(e.target.value)}
placeholder="Ajouter une description"
className="min-h-20 flex-1"
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<CalendarDays className="size-5 shrink-0 text-muted-foreground" />
<Select value={calendarId} onValueChange={setCalendarId} disabled={isEdit}>
<SelectTrigger tabIndex={tab(5)} className="h-9 w-fit min-w-40 border-0 bg-muted/60 shadow-none">
<SelectValue placeholder="Agenda" />
</SelectTrigger>
<SelectContent>
{calendars.map((cal) => (
<SelectItem key={cal.id} value={cal.id}>
<span className="flex items-center gap-2">
<span
className="size-3 rounded-full"
style={{ backgroundColor: calendarColor(cal) }}
/>
{cal.display_name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1.5">
{AGENDA_COLOR_PALETTE.slice(0, 8).map((c, i) => (
<button
key={c.value}
type="button"
tabIndex={tab(6 + i)}
title={c.label}
aria-label={`Couleur ${c.label}`}
onClick={() => setColor(color === c.value ? "" : c.value)}
className={cn(
"size-5 rounded-full transition-transform hover:scale-110",
color === c.value &&
"ring-2 ring-foreground/60 ring-offset-1 ring-offset-background",
)}
style={{ backgroundColor: c.value }}
/>
))}
</div>
</div>
</div>
<DialogFooter className="flex-row items-center border-t border-border/60 px-5 py-3">
{isEdit && (
<Button
variant="ghost"
tabIndex={tab(14)}
className="mr-auto gap-2 text-destructive hover:text-destructive"
disabled={pending}
onClick={() => void remove()}
>
<Trash2 className="size-4" /> Supprimer
</Button>
)}
<Button variant="ghost" tabIndex={tab(15)} onClick={onClose} disabled={pending}>
Annuler
</Button>
<Button
tabIndex={tab(16)}
onClick={() => void submit()}
disabled={pending || !calendarId}
className="rounded-full px-6"
>
Enregistrer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}