330 lines
11 KiB
TypeScript
330 lines
11 KiB
TypeScript
"use client"
|
|
|
|
import { useRouter } from "next/navigation"
|
|
import { toast } from "sonner"
|
|
import { Icon } from "@iconify/react"
|
|
import {
|
|
Check,
|
|
CircleHelp,
|
|
Mail,
|
|
MapPin,
|
|
Pencil,
|
|
Repeat,
|
|
Trash2,
|
|
Users,
|
|
X,
|
|
} from "lucide-react"
|
|
import { AgendaFloatingCard, type AnchorRect } from "@/components/agenda/agenda-floating-card"
|
|
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
|
import {
|
|
useDeleteAgendaEvent,
|
|
useRespondAgendaInvitation,
|
|
} from "@/lib/api/hooks/use-calendar-mutations"
|
|
import { formatEventRange } from "@/lib/agenda/agenda-date"
|
|
import { describeRRule } from "@/lib/agenda/agenda-recurrence"
|
|
import { stashPendingCompose } from "@/lib/agenda/agenda-mail-compose"
|
|
import type { AgendaCalendar, AgendaEvent } from "@/lib/agenda/agenda-types"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
export interface AgendaEventPopoverState {
|
|
event: AgendaEvent
|
|
anchor: AnchorRect
|
|
}
|
|
|
|
const STATUS_LABELS: Record<string, string> = {
|
|
ACCEPTED: "accepté",
|
|
DECLINED: "refusé",
|
|
TENTATIVE: "peut-être",
|
|
"NEEDS-ACTION": "en attente",
|
|
}
|
|
|
|
export function AgendaEventPopover({
|
|
state,
|
|
calendars,
|
|
userEmail,
|
|
onClose,
|
|
onEdit,
|
|
}: {
|
|
state: AgendaEventPopoverState | null
|
|
calendars: AgendaCalendar[]
|
|
userEmail?: string
|
|
onClose: () => void
|
|
onEdit: (event: AgendaEvent) => void
|
|
}) {
|
|
const router = useRouter()
|
|
const deleteMutation = useDeleteAgendaEvent()
|
|
const respondMutation = useRespondAgendaInvitation()
|
|
|
|
if (!state) return null
|
|
const { event, anchor } = state
|
|
const calendar = calendars.find((c) => c.id === event.calendarId)
|
|
|
|
const selfAttendee = userEmail
|
|
? event.attendees.find((a) => a.email.toLowerCase() === userEmail.toLowerCase())
|
|
: undefined
|
|
|
|
const remove = async () => {
|
|
try {
|
|
await deleteMutation.mutateAsync({ path: event.path })
|
|
toast.success("Événement supprimé")
|
|
onClose()
|
|
} catch {
|
|
toast.error("Impossible de supprimer l'événement")
|
|
}
|
|
}
|
|
|
|
const respond = async (response: "accepted" | "declined" | "tentative") => {
|
|
try {
|
|
await respondMutation.mutateAsync({ path: event.path, response, etag: event.etag })
|
|
toast.success("Réponse enregistrée")
|
|
onClose()
|
|
} catch {
|
|
toast.error("Impossible d'enregistrer la réponse")
|
|
}
|
|
}
|
|
|
|
const emailGuests = () => {
|
|
const recipients = event.attendees
|
|
.filter((a) => a.email.toLowerCase() !== (userEmail ?? "").toLowerCase())
|
|
.map((a) => ({ name: a.name ?? a.email, email: a.email }))
|
|
if (event.organizer && event.organizer.toLowerCase() !== (userEmail ?? "").toLowerCase()) {
|
|
if (!recipients.some((r) => r.email.toLowerCase() === event.organizer.toLowerCase())) {
|
|
recipients.push({ name: event.organizer, email: event.organizer })
|
|
}
|
|
}
|
|
stashPendingCompose({ to: recipients, subject: event.title })
|
|
router.push("/mail")
|
|
}
|
|
|
|
return (
|
|
<AgendaFloatingCard anchor={anchor} onClose={onClose} width={420}>
|
|
{/* Barre d'actions */}
|
|
<div className="flex items-center justify-end gap-0.5 px-2 pt-2">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-8 rounded-full text-muted-foreground"
|
|
aria-label="Modifier"
|
|
onClick={() => {
|
|
onEdit(event)
|
|
}}
|
|
>
|
|
<Pencil className="size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Modifier</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-8 rounded-full text-muted-foreground"
|
|
aria-label="Supprimer"
|
|
disabled={deleteMutation.isPending}
|
|
onClick={() => void remove()}
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Supprimer</TooltipContent>
|
|
</Tooltip>
|
|
{event.attendees.length > 0 && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-8 rounded-full text-muted-foreground"
|
|
aria-label="Envoyer un email aux invités"
|
|
onClick={emailGuests}
|
|
>
|
|
<Mail className="size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Envoyer un email aux invités</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-8 rounded-full text-muted-foreground"
|
|
aria-label="Fermer"
|
|
onClick={onClose}
|
|
>
|
|
<X className="size-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-3 overflow-y-auto px-5 pt-1 pb-5">
|
|
{/* Titre */}
|
|
<div className="flex items-start gap-3.5">
|
|
<span
|
|
aria-hidden
|
|
className="mt-1.5 size-4 shrink-0 rounded-[5px]"
|
|
style={{ backgroundColor: event.color }}
|
|
/>
|
|
<div className="min-w-0">
|
|
<h2 className="text-[1.3rem] leading-snug font-normal break-words text-foreground">
|
|
{event.title}
|
|
</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
{formatEventRange(event.start, event.end, event.allDay)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{event.recurring && (
|
|
<Row icon={<Repeat className="size-4.5" />}>
|
|
{describeRRule(event.rrule)}
|
|
</Row>
|
|
)}
|
|
|
|
{event.meetUrl && (
|
|
<Row
|
|
icon={
|
|
<Icon icon="simple-icons:jitsi" className="size-4.5" aria-hidden />
|
|
}
|
|
>
|
|
<Button asChild className="h-9 rounded-full">
|
|
<a href={event.meetUrl} target="_blank" rel="noopener noreferrer">
|
|
Rejoindre la visio
|
|
</a>
|
|
</Button>
|
|
</Row>
|
|
)}
|
|
|
|
{event.location && (
|
|
<Row icon={<MapPin className="size-4.5" />}>
|
|
<span className="break-words">{event.location}</span>
|
|
</Row>
|
|
)}
|
|
|
|
{event.attendees.length > 0 && (
|
|
<Row icon={<Users className="size-4.5" />} alignTop>
|
|
<div className="flex w-full flex-col gap-1.5">
|
|
<span className="text-sm text-foreground/85">
|
|
{event.attendees.length} invité
|
|
{event.attendees.length > 1 ? "s" : ""}
|
|
</span>
|
|
{event.attendees.map((a) => (
|
|
<div key={a.email} className="flex items-center gap-2.5">
|
|
<div className="relative">
|
|
<ContactAvatar name={a.name || a.email} email={a.email} size="xs" />
|
|
{a.status === "ACCEPTED" && (
|
|
<span className="absolute -right-0.5 -bottom-0.5 flex size-3.5 items-center justify-center rounded-full bg-green-600 text-white ring-2 ring-popover">
|
|
<Check className="size-2.5" />
|
|
</span>
|
|
)}
|
|
{a.status === "DECLINED" && (
|
|
<span className="absolute -right-0.5 -bottom-0.5 flex size-3.5 items-center justify-center rounded-full bg-red-600 text-white ring-2 ring-popover">
|
|
<X className="size-2.5" />
|
|
</span>
|
|
)}
|
|
{a.status === "TENTATIVE" && (
|
|
<span className="absolute -right-0.5 -bottom-0.5 flex size-3.5 items-center justify-center rounded-full bg-amber-500 text-white ring-2 ring-popover">
|
|
<CircleHelp className="size-2.5" />
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className="min-w-0">
|
|
<span className="block truncate text-sm">
|
|
{a.name || a.email}
|
|
{event.organizer &&
|
|
a.email.toLowerCase() === event.organizer.toLowerCase() && (
|
|
<span className="text-muted-foreground"> — organisateur</span>
|
|
)}
|
|
</span>
|
|
<span className="block truncate text-xs text-muted-foreground">
|
|
{STATUS_LABELS[a.status ?? "NEEDS-ACTION"] ?? a.status?.toLowerCase()}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Row>
|
|
)}
|
|
|
|
{event.description && (
|
|
<Row
|
|
icon={<span className="block w-4.5" aria-hidden />}
|
|
alignTop
|
|
>
|
|
<p className="text-sm break-words whitespace-pre-wrap text-foreground/80">
|
|
{event.description}
|
|
</p>
|
|
</Row>
|
|
)}
|
|
|
|
{calendar && (
|
|
<Row
|
|
icon={
|
|
<span
|
|
aria-hidden
|
|
className="block size-3.5 rounded-full"
|
|
style={{ backgroundColor: event.color }}
|
|
/>
|
|
}
|
|
>
|
|
<span className="text-sm text-foreground/80">{calendar.display_name}</span>
|
|
</Row>
|
|
)}
|
|
|
|
{/* RSVP */}
|
|
{selfAttendee && (
|
|
<div className="mt-1 flex items-center justify-between gap-2 border-t border-border/60 pt-3">
|
|
<span className="text-sm text-foreground/85">Vous participez ?</span>
|
|
<div className="flex gap-1">
|
|
{(
|
|
[
|
|
["accepted", "Oui", "ACCEPTED"],
|
|
["tentative", "Peut-être", "TENTATIVE"],
|
|
["declined", "Non", "DECLINED"],
|
|
] as const
|
|
).map(([value, label, partstat]) => (
|
|
<Button
|
|
key={value}
|
|
variant={selfAttendee.status === partstat ? "default" : "outline"}
|
|
className={cn("h-8 rounded-full px-3 text-xs")}
|
|
disabled={respondMutation.isPending}
|
|
onClick={() => void respond(value)}
|
|
>
|
|
{label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</AgendaFloatingCard>
|
|
)
|
|
}
|
|
|
|
function Row({
|
|
icon,
|
|
children,
|
|
alignTop,
|
|
}: {
|
|
icon: React.ReactNode
|
|
children: React.ReactNode
|
|
alignTop?: boolean
|
|
}) {
|
|
return (
|
|
<div className={cn("flex gap-3.5", alignTop ? "items-start" : "items-center")}>
|
|
<span
|
|
className={cn(
|
|
"flex w-4 shrink-0 justify-center text-muted-foreground",
|
|
alignTop && "mt-0.5",
|
|
)}
|
|
>
|
|
{icon}
|
|
</span>
|
|
<div className="min-w-0 flex-1 text-sm text-foreground/85">{children}</div>
|
|
</div>
|
|
)
|
|
}
|