437 lines
14 KiB
TypeScript
437 lines
14 KiB
TypeScript
"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>
|
||
)
|
||
}
|