ultisuite-client/components/agenda/agenda-event-dialog.tsx
R3D347HR4Y 3bbf3691b0
Some checks failed
E2E / Playwright e2e (push) Has been cancelled
bordel c'est beau
2026-06-11 10:10:39 +02:00

437 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { useEffect, useMemo, useState } from "react"
import { addDays, addHours, format, parse } from "date-fns"
import { toast } from "sonner"
import { Icon } from "@iconify/react"
import {
AlignLeft,
CalendarDays,
Clock,
MapPin,
Repeat,
Trash2,
Users,
} 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 { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { AgendaGuestPicker } from "@/components/agenda/agenda-guest-picker"
import {
draftToApiEvent,
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 { cn } from "@/lib/utils"
export interface AgendaEventDialogState {
mode: "create" | "edit"
draft: AgendaEventDraft
/** Présent en édition. */
event?: AgendaEvent
}
function toDateInput(d: Date): string {
return format(d, "yyyy-MM-dd")
}
function toTimeInput(d: Date): string {
return format(d, "HH:mm")
}
function fromInputs(date: string, time: string): Date | null {
const parsed = parse(`${date} ${time}`, "yyyy-MM-dd HH:mm", new Date())
return Number.isNaN(parsed.getTime()) ? null : parsed
}
export function AgendaEventDialog({
state,
onClose,
calendars,
userEmail,
}: {
state: AgendaEventDialogState | null
onClose: () => void
calendars: AgendaCalendar[]
userEmail?: string
}) {
const createMutation = useCreateAgendaEvent()
const updateMutation = useUpdateAgendaEvent()
const deleteMutation = useDeleteAgendaEvent()
const meetLinkMutation = useCreateAgendaMeetLink()
const [title, setTitle] = useState("")
const [allDay, setAllDay] = useState(false)
const [startDate, setStartDate] = useState("")
const [startTime, setStartTime] = useState("09:00")
const [endDate, setEndDate] = useState("")
const [endTime, setEndTime] = useState("10:00")
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 open = state !== null
const isEdit = state?.mode === "edit"
useEffect(() => {
if (!state) return
const d = state.draft
setTitle(d.title)
setAllDay(d.allDay)
setStartDate(toDateInput(d.start))
setStartTime(toTimeInput(d.start))
// ICS : fin exclusive pour les journées entières → réaffiche la date incluse.
const displayEnd = d.allDay ? addDays(d.end, -1) : d.end
setEndDate(toDateInput(displayEnd < d.start ? d.start : displayEnd))
setEndTime(toTimeInput(d.end))
setCalendarId(d.calendarId || calendars[0]?.id || "")
setRRule(d.rrule ?? "")
setColor(d.color ?? "")
setLocation(d.location ?? "")
setDescription(d.description ?? "")
setAttendees(d.attendees ?? [])
}, [state, calendars])
const recurrenceOptions = useMemo(() => {
const start = fromInputs(startDate, startTime) ?? new Date()
const options = recurrenceOptionsFor(start)
if (rrule && !options.some((o) => o.value === rrule)) {
options.push({ value: rrule, label: describeRRule(rrule) })
}
return options
}, [startDate, startTime, rrule])
const pending =
createMutation.isPending || updateMutation.isPending || deleteMutation.isPending
const buildDraft = (): AgendaEventDraft | null => {
const start = fromInputs(startDate, allDay ? "00:00" : startTime)
if (!start) return null
let end = fromInputs(endDate, allDay ? "00:00" : endTime)
if (!end) return null
if (allDay) {
// Fin exclusive : jour affiché inclus + 1.
end = addDays(end, 1)
if (end <= start) end = addDays(start, 1)
} else if (end <= start) {
end = addHours(start, 1)
}
return {
title,
start,
end,
allDay,
calendarId,
description,
location,
attendees,
rrule,
color: color || undefined,
}
}
const submit = async () => {
const draft = buildDraft()
if (!draft || !calendarId) return
try {
if (isEdit && state?.event) {
await updateMutation.mutateAsync({
path: state.event.path,
etag: state.event.etag,
event: draftToApiEvent(draft, state.event.master),
})
toast.success("Événement mis à jour")
} else {
const apiEvent = draftToApiEvent(draft)
if (userEmail) apiEvent.organizer = userEmail
await createMutation.mutateAsync({ calendarId, event: apiEvent })
toast.success("É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 addMeetLink = async () => {
if (!state?.event) return
try {
const res = await meetLinkMutation.mutateAsync({
path: state.event.path,
etag: state.event.etag,
})
toast.success("Visio UltiMeet ajoutée")
window.open(res.meet_url, "_blank", "noopener")
onClose()
} catch {
toast.error("Impossible de créer le lien UltiMeet")
}
}
const meetUrl = state?.event?.meetUrl
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">
<DialogHeader className="border-b border-border/60 px-5 py-3">
<DialogTitle className="text-base font-medium">
{isEdit ? "Modifier l'événement" : "Nouvel événement"}
</DialogTitle>
</DialogHeader>
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
<Input
value={title}
autoFocus={!isEdit}
onChange={(e) => setTitle(e.target.value)}
placeholder="Ajouter un titre"
className="h-11 border-0 border-b-2 border-border/60 !bg-transparent px-1 !text-xl shadow-none rounded-none focus-visible:border-primary focus-visible:ring-0"
/>
{/* Dates */}
<div className="flex flex-col gap-2">
<div className="flex items-start gap-3">
<Clock className="mt-2 size-5 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex flex-wrap items-center gap-1.5">
<Input
type="date"
value={startDate}
onChange={(e) => {
setStartDate(e.target.value)
if (e.target.value > endDate) setEndDate(e.target.value)
}}
className="h-9 w-fit"
/>
{!allDay && (
<Input
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
className="h-9 w-fit"
/>
)}
<span className="px-0.5 text-muted-foreground"></span>
{!allDay && (
<Input
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className="h-9 w-fit"
/>
)}
<Input
type="date"
value={endDate}
min={startDate}
onChange={(e) => setEndDate(e.target.value)}
className="h-9 w-fit"
/>
</div>
<label className="flex w-fit cursor-pointer items-center gap-2 text-sm text-foreground/80">
<Switch checked={allDay} onCheckedChange={setAllDay} />
Toute la journée
</label>
</div>
</div>
<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 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>
{/* Invités */}
<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}
/>
</div>
</div>
{/* Visio */}
<div className="flex items-center gap-3">
<Icon
icon="simple-icons:jitsi"
className="size-5 shrink-0 text-muted-foreground"
aria-hidden
/>
{meetUrl ? (
<Button asChild className="h-9 rounded-full">
<a href={meetUrl} target="_blank" rel="noopener noreferrer">
Rejoindre la visio UltiMeet
</a>
</Button>
) : isEdit ? (
<Button
variant="outline"
className="h-9 rounded-full"
disabled={meetLinkMutation.isPending}
onClick={() => void addMeetLink()}
>
Ajouter une visio UltiMeet
</Button>
) : (
<span className="text-sm text-muted-foreground">
La visio peut être ajoutée après création
</span>
)}
</div>
{/* Lieu */}
<div className="flex items-center gap-3">
<MapPin className="size-5 shrink-0 text-muted-foreground" />
<Input
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="Ajouter un lieu"
className="h-9 flex-1"
/>
</div>
{/* Description */}
<div className="flex items-start gap-3">
<AlignLeft className="mt-2 size-5 shrink-0 text-muted-foreground" />
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Ajouter une description"
className="min-h-20 flex-1"
/>
</div>
{/* Agenda + couleur */}
<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 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) => (
<button
key={c.value}
type="button"
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"
className="mr-auto gap-2 text-destructive hover:text-destructive"
disabled={pending}
onClick={() => void remove()}
>
<Trash2 className="size-4" /> Supprimer
</Button>
)}
<Button variant="ghost" onClick={onClose} disabled={pending}>
Annuler
</Button>
<Button
onClick={() => void submit()}
disabled={pending || !calendarId || !startDate || !endDate}
className="rounded-full px-6"
>
Enregistrer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}