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

332 lines
11 KiB
TypeScript

"use client"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { toast } from "sonner"
import {
Check,
CircleHelp,
Mail,
MapPin,
Pencil,
Repeat,
Trash2,
Users,
X,
} from "lucide-react"
import { AgendaFloatingCard, type AnchorRect } from "@/components/agenda/agenda-floating-card"
import { AgendaVideoProviderIcon } from "@/components/agenda/agenda-video-provider-icon"
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 { isUltiMeetUrl, meetJoinPath } from "@/lib/meet/meet-url"
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}>
<div className="flex flex-col gap-3 overflow-y-auto px-5 pt-4 pb-5">
{/* Titre + actions */}
<div className="flex items-start gap-2">
<span
aria-hidden
className="mt-1.5 size-4 shrink-0 rounded-[5px]"
style={{ backgroundColor: event.color }}
/>
<div className="min-w-0 flex-1">
<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 className="flex shrink-0 items-center gap-0.5">
<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>
{event.recurring && (
<Row icon={<Repeat className="size-4.5" />}>
{describeRRule(event.rrule)}
</Row>
)}
{event.meetUrl && (
<Row
icon={<AgendaVideoProviderIcon provider="ultimeet" className="size-4.5" />}
>
<Button asChild className="h-9 rounded-full">
{isUltiMeetUrl(event.meetUrl) ? (
<Link href={meetJoinPath(event.meetUrl)}>Rejoindre la visio</Link>
) : (
<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>
)
}