bordel c'est beau
Some checks failed
E2E / Playwright e2e (push) Has been cancelled

This commit is contained in:
R3D347HR4Y 2026-06-11 10:10:39 +02:00
parent 303b2b1074
commit 3bbf3691b0
49 changed files with 4450 additions and 11 deletions

View File

@ -0,0 +1,12 @@
"use client"
import { Suspense } from "react"
import { AgendaPage } from "@/components/agenda/agenda-page"
export default function AgendaRoutePage() {
return (
<Suspense fallback={null}>
<AgendaPage />
</Suspense>
)
}

9
app/agenda/layout.tsx Normal file
View File

@ -0,0 +1,9 @@
import type { ReactNode } from "react"
import { AgendaAppShell } from "@/components/agenda/agenda-app-shell"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata = suitePageMetadata({ app: "agenda" })
export default function AgendaLayout({ children }: { children: ReactNode }) {
return <AgendaAppShell>{children}</AgendaAppShell>
}

View File

@ -0,0 +1,10 @@
import { CompteSettingsSectionFromSegments } from "@/components/compte/compte-settings-section-view"
export default async function CompteSectionPage({
params,
}: {
params: Promise<{ section?: string[] }>
}) {
const { section } = await params
return <CompteSettingsSectionFromSegments segments={section} />
}

15
app/compte/layout.tsx Normal file
View File

@ -0,0 +1,15 @@
import { CompteSettingsLayout } from "@/components/compte/compte-settings-layout"
import type { Metadata } from "next"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata: Metadata = suitePageMetadata({
app: "compte",
})
export default function CompteRootLayout({
children,
}: {
children: React.ReactNode
}) {
return <CompteSettingsLayout>{children}</CompteSettingsLayout>
}

View File

@ -27,6 +27,7 @@ import { MoveDragIndicator } from "@/components/gmail/move-drag-indicator"
import { ComposeProvider } from "@/lib/compose-context" import { ComposeProvider } from "@/lib/compose-context"
import { ScheduledMailProvider } from "@/lib/scheduled-mail-context" import { ScheduledMailProvider } from "@/lib/scheduled-mail-context"
import { ComposeModalManager } from "@/components/gmail/compose-modal" import { ComposeModalManager } from "@/components/gmail/compose-modal"
import { PendingComposeBridge } from "@/components/gmail/pending-compose-bridge"
import { SidebarNavProvider } from "@/lib/sidebar-nav-context" import { SidebarNavProvider } from "@/lib/sidebar-nav-context"
import { mailNavVisitKey } from "@/lib/mail-folder-display" import { mailNavVisitKey } from "@/lib/mail-folder-display"
import { MailDocumentTitle } from "@/components/gmail/mail-document-title" import { MailDocumentTitle } from "@/components/gmail/mail-document-title"
@ -270,6 +271,7 @@ export function MailAppShell({
<MailNotificationsBridge /> <MailNotificationsBridge />
<QuickSettingsRoot /> <QuickSettingsRoot />
<MoveDragIndicator /> <MoveDragIndicator />
<PendingComposeBridge />
<ComposeModalManager /> <ComposeModalManager />
<FilePreviewDialog /> <FilePreviewDialog />
</EmailDragProvider> </EmailDragProvider>

View File

@ -0,0 +1,44 @@
"use client"
import { useEffect, useLayoutEffect, type ReactNode } from "react"
import { AiChatPanel } from "@/components/ai/ai-chat-panel"
import { SuiteThemeShell } from "@/components/suite/suite-theme-shell"
import { TooltipProvider } from "@/components/ui/tooltip"
import { useIsMobile } from "@/hooks/use-mobile"
import { useAgendaUIStore } from "@/lib/agenda/agenda-store"
export function AgendaAppShell({ children }: { children: ReactNode }) {
const isMobile = useIsMobile()
const sidebarCollapsed = useAgendaUIStore((s) => s.sidebarCollapsed)
const setSidebarCollapsed = useAgendaUIStore((s) => s.setSidebarCollapsed)
useLayoutEffect(() => {
if (!isMobile) setSidebarCollapsed(false)
}, [isMobile, setSidebarCollapsed])
useEffect(() => {
if (isMobile) setSidebarCollapsed(true)
}, [isMobile, setSidebarCollapsed])
return (
<SuiteThemeShell>
<TooltipProvider delayDuration={400}>
<div
className="ultimail-app relative flex h-dvh flex-col overflow-hidden bg-app-canvas"
data-agenda-app
>
{isMobile && !sidebarCollapsed && (
<button
type="button"
aria-label="Fermer le menu"
className="absolute inset-0 z-40 bg-black/20"
onClick={() => setSidebarCollapsed(true)}
/>
)}
{children}
<AiChatPanel />
</div>
</TooltipProvider>
</SuiteThemeShell>
)
}

View File

@ -0,0 +1,121 @@
"use client"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
useCreateAgendaCalendar,
useUpdateAgendaCalendar,
} from "@/lib/api/hooks/use-calendar-mutations"
import { AGENDA_COLOR_PALETTE } from "@/lib/agenda/agenda-colors"
import { calendarColor } from "@/lib/agenda/agenda-events"
import type { AgendaCalendar } from "@/lib/agenda/agenda-types"
import { cn } from "@/lib/utils"
export function AgendaCalendarDialog({
open,
onOpenChange,
calendar,
}: {
open: boolean
onOpenChange: (open: boolean) => void
/** Agenda à modifier — absent en création. */
calendar?: AgendaCalendar | null
}) {
const [name, setName] = useState("")
const [color, setColor] = useState(AGENDA_COLOR_PALETTE[0].value)
const createMutation = useCreateAgendaCalendar()
const updateMutation = useUpdateAgendaCalendar()
const pending = createMutation.isPending || updateMutation.isPending
useEffect(() => {
if (!open) return
setName(calendar?.display_name ?? "")
setColor(calendar ? calendarColor(calendar) : AGENDA_COLOR_PALETTE[0].value)
}, [open, calendar])
const submit = async () => {
const displayName = name.trim()
if (!displayName) return
try {
if (calendar) {
await updateMutation.mutateAsync({
id: calendar.id,
display_name: displayName,
color,
})
toast.success("Agenda mis à jour")
} else {
await createMutation.mutateAsync({ display_name: displayName, color })
toast.success(`Agenda « ${displayName} » créé`)
}
onOpenChange(false)
} catch {
toast.error("Impossible d'enregistrer l'agenda")
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{calendar ? "Modifier l'agenda" : "Nouvel agenda"}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="agenda-cal-name">Nom</Label>
<Input
id="agenda-cal-name"
value={name}
autoFocus
placeholder="Mon agenda"
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void submit()
}}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Couleur</Label>
<div className="flex flex-wrap gap-2">
{AGENDA_COLOR_PALETTE.map((c) => (
<button
key={c.value}
type="button"
title={c.label}
aria-label={c.label}
onClick={() => setColor(c.value)}
className={cn(
"size-7 rounded-full transition-transform hover:scale-110",
color === c.value &&
"ring-2 ring-foreground/70 ring-offset-2 ring-offset-background",
)}
style={{ backgroundColor: c.value }}
/>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Annuler
</Button>
<Button onClick={() => void submit()} disabled={!name.trim() || pending}>
{calendar ? "Enregistrer" : "Créer"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,63 @@
"use client"
import type { CSSProperties, MouseEvent } from "react"
import { formatEventTime } from "@/lib/agenda/agenda-date"
import { readableTextColor } from "@/lib/agenda/agenda-colors"
import type { AgendaEvent } from "@/lib/agenda/agenda-types"
import { cn } from "@/lib/utils"
/** Chip compact (vue mois / rangée journée entière). */
export function AgendaEventChip({
event,
filled,
onClick,
className,
}: {
event: AgendaEvent
/** Fond plein (journée entière / multi-jours) vs point coloré + heure. */
filled?: boolean
onClick?: (e: MouseEvent<HTMLButtonElement>) => void
className?: string
}) {
const solid = filled ?? event.allDay
const style: CSSProperties = solid
? { backgroundColor: event.color, color: readableTextColor(event.color) }
: {}
const declined = isDeclinedForAll(event)
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex w-full min-w-0 items-center gap-1.5 truncate rounded-md px-1.5 py-[1px] text-left text-xs leading-[1.4] transition-[filter] hover:brightness-95 dark:hover:brightness-110",
!solid && "hover:bg-mail-nav-hover",
declined && "opacity-50 line-through",
className,
)}
style={style}
title={event.title}
>
{!solid && (
<span
aria-hidden
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: event.color }}
/>
)}
{!solid && !event.allDay && (
<span className="shrink-0 text-muted-foreground">
{formatEventTime(event.start)}
</span>
)}
<span className="truncate font-medium">{event.title}</span>
</button>
)
}
function isDeclinedForAll(event: AgendaEvent): boolean {
return (
event.attendees.length > 0 &&
event.attendees.every((a) => a.status === "DECLINED")
)
}

View File

@ -0,0 +1,436 @@
"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>
)
}

View File

@ -0,0 +1,329 @@
"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>
)
}

View File

@ -0,0 +1,96 @@
"use client"
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"
import { createPortal } from "react-dom"
import { cn } from "@/lib/utils"
export interface AnchorRect {
left: number
top: number
width: number
height: number
}
/**
* Carte flottante ancrée à un rectangle (popover détails / création rapide),
* positionnée en `fixed` et recadrée dans la fenêtre.
*/
export function AgendaFloatingCard({
anchor,
onClose,
children,
className,
width = 416,
}: {
anchor: AnchorRect
onClose: () => void
children: ReactNode
className?: string
width?: number
}) {
const cardRef = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState<{ left: number; top: number } | null>(null)
useLayoutEffect(() => {
const card = cardRef.current
if (!card) return
const margin = 8
const vw = window.innerWidth
const vh = window.innerHeight
const rect = card.getBoundingClientRect()
const w = Math.min(width, vw - margin * 2)
const h = rect.height
// Préférence : à droite de l'ancre, sinon à gauche, sinon en dessous.
let left = anchor.left + anchor.width + margin
if (left + w > vw - margin) left = anchor.left - w - margin
if (left < margin) left = Math.min(Math.max(margin, anchor.left), vw - w - margin)
let top = anchor.top
if (top + h > vh - margin) top = vh - h - margin
if (top < margin) top = margin
setPos({ left, top })
}, [anchor, width, children])
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation()
onClose()
}
}
window.addEventListener("keydown", onKey, true)
return () => window.removeEventListener("keydown", onKey, true)
}, [onClose])
if (typeof document === "undefined") return null
return createPortal(
<div className="fixed inset-0 z-[60]">
<button
type="button"
aria-label="Fermer"
className="absolute inset-0 cursor-default"
onClick={onClose}
/>
<div
ref={cardRef}
role="dialog"
className={cn(
"absolute flex max-h-[85vh] flex-col overflow-hidden rounded-xl border border-border/60 bg-popover text-popover-foreground shadow-2xl",
className,
)}
style={{
width: Math.min(width, window.innerWidth - 16),
left: pos?.left ?? -9999,
top: pos?.top ?? -9999,
visibility: pos ? "visible" : "hidden",
}}
>
{children}
</div>
</div>,
document.body,
)
}

View File

@ -0,0 +1,181 @@
"use client"
import { useMemo, useRef, useState } from "react"
import { X } from "lucide-react"
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
import { Input } from "@/components/ui/input"
import { useContactsList } from "@/lib/contacts/use-contacts-list"
import { fullContactDisplayName } from "@/lib/contacts/types"
import type { AgendaEventAttendee } from "@/lib/agenda/agenda-types"
import { cn } from "@/lib/utils"
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
interface Suggestion {
email: string
name: string
avatarUrl?: string
}
/** Champ « Ajouter des invités » avec autocomplétion sur le carnet d'adresses. */
export function AgendaGuestPicker({
attendees,
onChange,
organizerEmail,
}: {
attendees: AgendaEventAttendee[]
onChange: (attendees: AgendaEventAttendee[]) => void
organizerEmail?: string
}) {
const [query, setQuery] = useState("")
const [focused, setFocused] = useState(false)
const [activeIndex, setActiveIndex] = useState(0)
const blurTimer = useRef<number | null>(null)
const { contacts } = useContactsList()
const suggestions: Suggestion[] = useMemo(() => {
const q = query.trim().toLowerCase()
if (q.length < 1) return []
const taken = new Set(attendees.map((a) => a.email.toLowerCase()))
if (organizerEmail) taken.add(organizerEmail.toLowerCase())
const out: Suggestion[] = []
for (const contact of contacts) {
const name = fullContactDisplayName(contact)
for (const { value: email } of contact.emails) {
if (!email || taken.has(email.toLowerCase())) continue
if (
name.toLowerCase().includes(q) ||
email.toLowerCase().includes(q)
) {
out.push({ email, name: name || email, avatarUrl: contact.avatarUrl })
}
}
if (out.length >= 6) break
}
return out
}, [contacts, query, attendees, organizerEmail])
const addAttendee = (s: Suggestion) => {
onChange([...attendees, { email: s.email, name: s.name, status: "NEEDS-ACTION" }])
setQuery("")
setActiveIndex(0)
}
const tryAddRaw = () => {
const email = query.trim().replace(/[,;]$/, "")
if (!EMAIL_RE.test(email)) return false
if (attendees.some((a) => a.email.toLowerCase() === email.toLowerCase())) {
setQuery("")
return true
}
addAttendee({ email, name: email })
return true
}
const showSuggestions = focused && (suggestions.length > 0 || EMAIL_RE.test(query.trim()))
return (
<div className="flex flex-col gap-1.5">
<div className="relative">
<Input
value={query}
placeholder="Ajouter des invités"
onChange={(e) => {
setQuery(e.target.value)
setActiveIndex(0)
}}
onFocus={() => {
if (blurTimer.current) window.clearTimeout(blurTimer.current)
setFocused(true)
}}
onBlur={() => {
blurTimer.current = window.setTimeout(() => setFocused(false), 150)
}}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault()
setActiveIndex((i) => Math.min(i + 1, suggestions.length - 1))
} else if (e.key === "ArrowUp") {
e.preventDefault()
setActiveIndex((i) => Math.max(i - 1, 0))
} else if (e.key === "Enter") {
e.preventDefault()
if (suggestions[activeIndex]) addAttendee(suggestions[activeIndex])
else tryAddRaw()
} else if ((e.key === "," || e.key === ";") && query.trim()) {
e.preventDefault()
tryAddRaw()
}
}}
/>
{showSuggestions && (
<div className="absolute top-full right-0 left-0 z-50 mt-1 overflow-hidden rounded-lg border border-border/60 bg-popover py-1 shadow-lg">
{suggestions.map((s, i) => (
<button
key={s.email}
type="button"
className={cn(
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left",
i === activeIndex ? "bg-mail-nav-hover" : "hover:bg-mail-nav-hover",
)}
onMouseDown={(e) => {
e.preventDefault()
addAttendee(s)
}}
>
<ContactAvatar name={s.name} email={s.email} avatarUrl={s.avatarUrl} size="xs" />
<span className="min-w-0">
<span className="block truncate text-sm">{s.name}</span>
<span className="block truncate text-xs text-muted-foreground">
{s.email}
</span>
</span>
</button>
))}
{suggestions.length === 0 && EMAIL_RE.test(query.trim()) && (
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-mail-nav-hover"
onMouseDown={(e) => {
e.preventDefault()
tryAddRaw()
}}
>
Ajouter « {query.trim()} »
</button>
)}
</div>
)}
</div>
{attendees.length > 0 && (
<div className="flex flex-col gap-1">
{attendees.map((a) => (
<div
key={a.email}
className="group flex items-center gap-2.5 rounded-lg px-1.5 py-1 hover:bg-mail-nav-hover"
>
<ContactAvatar name={a.name || a.email} email={a.email} size="xs" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">{a.name || a.email}</span>
{a.name && a.name !== a.email && (
<span className="block truncate text-xs text-muted-foreground">
{a.email}
</span>
)}
</span>
<button
type="button"
aria-label={`Retirer ${a.email}`}
className="rounded-full p-1 text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-black/5 dark:hover:bg-white/10"
onClick={() => onChange(attendees.filter((x) => x.email !== a.email))}
>
<X className="size-4" />
</button>
</div>
))}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,140 @@
"use client"
import Link from "next/link"
import { ChevronDown, ChevronLeft, ChevronRight, Menu } from "lucide-react"
import { HeaderAccountActions } from "@/components/suite/header-account-actions"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { viewTitle } from "@/lib/agenda/agenda-date"
import {
AGENDA_VIEW_LABELS,
AGENDA_VIEWS,
type AgendaView,
} from "@/lib/agenda/agenda-url"
import { useAgendaUIStore } from "@/lib/agenda/agenda-store"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
const VIEW_SHORTCUTS: Record<AgendaView, string> = {
day: "J",
week: "S",
month: "M",
}
export function AgendaHeader({
view,
date,
onToday,
onStep,
onViewChange,
}: {
view: AgendaView
date: Date
onToday: () => void
onStep: (delta: 1 | -1) => void
onViewChange: (view: AgendaView) => void
}) {
const isMobile = useIsMobile()
const sidebarCollapsed = useAgendaUIStore((s) => s.sidebarCollapsed)
const setSidebarCollapsed = useAgendaUIStore((s) => s.setSidebarCollapsed)
return (
<header className="flex h-16 shrink-0 items-center gap-2 border-b border-border/60 bg-app-canvas px-2 sm:px-4">
<Button
variant="ghost"
size="icon"
className="size-10 shrink-0 rounded-full text-muted-foreground hover:bg-mail-nav-hover"
aria-label={sidebarCollapsed ? "Ouvrir le menu" : "Fermer le menu"}
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
>
<Menu className="size-5" />
</Button>
<Link href="/agenda" className="mr-1 hidden min-w-0 items-center gap-2 sm:flex">
<img src="/agenda-mark.svg" alt="" className="size-9 shrink-0" />
<span className="hidden truncate text-[1.35rem] leading-none text-foreground/80 md:block">
Agenda
</span>
</Link>
<Button
variant="outline"
className="ml-1 h-9 rounded-full px-4 text-sm font-medium sm:ml-4"
onClick={onToday}
>
Aujourd&apos;hui
</Button>
<div className="flex items-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-9 rounded-full text-muted-foreground hover:bg-mail-nav-hover"
aria-label="Période précédente"
onClick={() => onStep(-1)}
>
<ChevronLeft className="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent>Période précédente</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-9 rounded-full text-muted-foreground hover:bg-mail-nav-hover"
aria-label="Période suivante"
onClick={() => onStep(1)}
>
<ChevronRight className="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent>Période suivante</TooltipContent>
</Tooltip>
</div>
<h1
className={cn(
"min-w-0 truncate text-[1.05rem] font-normal text-foreground/90 sm:text-[1.35rem]",
)}
>
{viewTitle(view, date)}
</h1>
<div className="ml-auto flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="h-9 gap-1.5 rounded-full px-3 text-sm sm:px-4"
>
{isMobile
? AGENDA_VIEW_LABELS[view].slice(0, 1)
: AGENDA_VIEW_LABELS[view]}
<ChevronDown className="size-4 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
{AGENDA_VIEWS.map((v) => (
<DropdownMenuItem key={v} onSelect={() => onViewChange(v)}>
{AGENDA_VIEW_LABELS[v]}
<DropdownMenuShortcut>{VIEW_SHORTCUTS[v]}</DropdownMenuShortcut>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<HeaderAccountActions className="pl-1" settingsHref="/mail/settings" />
</div>
</header>
)
}

View File

@ -0,0 +1,101 @@
"use client"
import { useEffect, useState } from "react"
import {
addDays,
addMonths,
format,
isSameDay,
isSameMonth,
startOfMonth,
startOfWeek,
} from "date-fns"
import { fr } from "date-fns/locale"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { WEEK_OPTS } from "@/lib/agenda/agenda-date"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
const WEEKDAYS = ["L", "M", "M", "J", "V", "S", "D"]
export function AgendaMiniMonth({
selected,
onSelect,
}: {
selected: Date
onSelect: (date: Date) => void
}) {
const [cursor, setCursor] = useState(() => startOfMonth(selected))
useEffect(() => {
setCursor(startOfMonth(selected))
}, [selected])
const gridStart = startOfWeek(cursor, WEEK_OPTS)
const today = new Date()
const cells: Date[] = []
for (let i = 0; i < 42; i++) cells.push(addDays(gridStart, i))
return (
<div className="select-none px-1">
<div className="flex items-center justify-between pb-1 pl-2">
<span className="text-sm font-medium text-foreground/90">
{format(cursor, "MMMM yyyy", { locale: fr }).replace(/^./, (c) =>
c.toUpperCase(),
)}
</span>
<div className="flex">
<Button
variant="ghost"
size="icon"
className="size-7 rounded-full text-muted-foreground"
aria-label="Mois précédent"
onClick={() => setCursor((c) => addMonths(c, -1))}
>
<ChevronLeft className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 rounded-full text-muted-foreground"
aria-label="Mois suivant"
onClick={() => setCursor((c) => addMonths(c, 1))}
>
<ChevronRight className="size-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-7 text-center">
{WEEKDAYS.map((d, i) => (
<span
key={`${d}-${i}`}
className="py-1 text-[0.65rem] font-medium text-muted-foreground"
>
{d}
</span>
))}
{cells.map((day) => {
const isToday = isSameDay(day, today)
const isSelected = isSameDay(day, selected)
const inMonth = isSameMonth(day, cursor)
return (
<button
key={day.getTime()}
type="button"
onClick={() => onSelect(day)}
className={cn(
"mx-auto flex size-6 items-center justify-center rounded-full text-[0.7rem] transition-colors",
inMonth ? "text-foreground/85" : "text-muted-foreground/50",
isToday && "bg-primary font-semibold text-primary-foreground",
isSelected && !isToday && "bg-primary/15 font-semibold text-primary",
!isToday && !isSelected && "hover:bg-mail-nav-hover",
)}
>
{format(day, "d")}
</button>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,284 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useParams, useRouter, useSearchParams } from "next/navigation"
import { addDays, addHours, addMonths, startOfHour } from "date-fns"
import { AgendaEventDialog, type AgendaEventDialogState } from "@/components/agenda/agenda-event-dialog"
import { AgendaEventPopover, type AgendaEventPopoverState } from "@/components/agenda/agenda-event-popover"
import type { AnchorRect } from "@/components/agenda/agenda-floating-card"
import { AgendaHeader } from "@/components/agenda/agenda-header"
import { AgendaQuickCreate, type AgendaQuickCreateState } from "@/components/agenda/agenda-quick-create"
import { AgendaSidebar } from "@/components/agenda/agenda-sidebar"
import { AgendaViewMonth } from "@/components/agenda/agenda-view-month"
import { AgendaViewWeek } from "@/components/agenda/agenda-view-week"
import { Skeleton } from "@/components/ui/skeleton"
import { useAgendaCalendars, useAgendaEvents } from "@/lib/api/hooks/use-calendar-queries"
import { parseICSDate, viewDays, viewRange } from "@/lib/agenda/agenda-date"
import { useAgendaSettingsStore } from "@/lib/agenda/agenda-store"
import type { AgendaEvent, AgendaEventDraft } from "@/lib/agenda/agenda-types"
import {
buildAgendaPath,
parseAgendaSegments,
type AgendaView,
} from "@/lib/agenda/agenda-url"
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
import { useIsMobile } from "@/hooks/use-mobile"
export function AgendaPage() {
const router = useRouter()
const params = useParams()
const searchParams = useSearchParams()
const isMobile = useIsMobile()
const identity = useChromeIdentity()
const route = useMemo(
() => parseAgendaSegments(params.segments as string[] | undefined),
[params.segments],
)
const lastView = useAgendaSettingsStore((s) => s.lastView)
const setLastView = useAgendaSettingsStore((s) => s.setLastView)
const hiddenIds = useAgendaSettingsStore((s) => s.hiddenCalendarIds)
const view: AgendaView = route.view ?? (isMobile ? "day" : lastView)
const date = route.date
// Normalise l'URL quand la vue est implicite.
useEffect(() => {
if (!route.view) {
router.replace(buildAgendaPath(view, date) + window.location.search)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [route.view])
useEffect(() => {
if (route.view) setLastView(route.view)
}, [route.view, setLastView])
const navigate = useCallback(
(nextView: AgendaView, nextDate: Date) => {
router.push(buildAgendaPath(nextView, nextDate))
},
[router],
)
const step = useCallback(
(delta: 1 | -1) => {
const next =
view === "month"
? addMonths(date, delta)
: addDays(date, view === "week" ? delta * 7 : delta)
navigate(view, next)
},
[view, date, navigate],
)
// Données
const { data: calendars = [], isLoading: calendarsLoading } = useAgendaCalendars()
const visibleCalendars = useMemo(
() => calendars.filter((c) => !hiddenIds.includes(c.id)),
[calendars, hiddenIds],
)
const fetchRange = useMemo(() => viewRange("month", date), [date])
const { events } = useAgendaEvents(visibleCalendars, fetchRange.start, fetchRange.end)
// UI : création rapide / dialog / détails
const [quickCreate, setQuickCreate] = useState<AgendaQuickCreateState | null>(null)
const [dialogState, setDialogState] = useState<AgendaEventDialogState | null>(null)
const [popover, setPopover] = useState<AgendaEventPopoverState | null>(null)
const defaultCalendarId = visibleCalendars[0]?.id ?? calendars[0]?.id ?? ""
const userEmail = identity?.email
const closeOverlays = useCallback(() => {
setQuickCreate(null)
setPopover(null)
}, [])
const openCreateDialog = useCallback(
(base?: Partial<AgendaEventDraft>) => {
closeOverlays()
const start = base?.start ?? addHours(startOfHour(new Date()), 1)
const end = base?.end ?? addHours(start, 1)
setDialogState({
mode: "create",
draft: {
title: "",
start,
end,
allDay: false,
calendarId: defaultCalendarId,
...base,
},
})
},
[closeOverlays, defaultCalendarId],
)
const openEditDialog = useCallback(
(event: AgendaEvent) => {
closeOverlays()
const masterStart = parseICSDate(event.master.start) ?? event.start
const masterEnd = parseICSDate(event.master.end) ?? event.end
setDialogState({
mode: "edit",
event,
draft: {
title: event.title === "(Sans titre)" ? "" : event.title,
start: masterStart,
end: masterEnd,
allDay: event.allDay,
calendarId: event.calendarId,
description: event.description,
location: event.location,
attendees: event.attendees,
rrule: event.rrule,
color: event.master.color,
},
})
},
[closeOverlays],
)
// Interop : /agenda?new=1&guest=…&title=…
const handledNewParam = useRef(false)
useEffect(() => {
if (handledNewParam.current || !route.view) return
if (searchParams.get("new") !== "1") return
handledNewParam.current = true
const guest = searchParams.get("guest")?.trim()
const guestName = searchParams.get("guest_name")?.trim()
openCreateDialog({
title: searchParams.get("title")?.trim() ?? "",
attendees: guest
? [{ email: guest, name: guestName || guest, status: "NEEDS-ACTION" }]
: [],
})
router.replace(buildAgendaPath(view, date))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, route.view])
// Raccourcis clavier façon Google Calendar.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement
if (
e.metaKey ||
e.ctrlKey ||
e.altKey ||
target.closest("input, textarea, select, [contenteditable], [role=dialog]")
)
return
switch (e.key.toLowerCase()) {
case "j":
case "d":
navigate("day", date)
break
case "s":
case "w":
navigate("week", date)
break
case "m":
navigate("month", date)
break
case "t":
navigate(view, new Date())
break
case "c":
openCreateDialog()
break
default:
return
}
e.preventDefault()
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [navigate, view, date, openCreateDialog])
const handleEventClick = useCallback((event: AgendaEvent, anchor: AnchorRect) => {
setQuickCreate(null)
setPopover({ event, anchor })
}, [])
const handleCreateRange = useCallback(
(start: Date, end: Date, allDay: boolean, anchor: AnchorRect) => {
setPopover(null)
setQuickCreate({ start, end, allDay, anchor })
},
[],
)
const days = useMemo(() => viewDays(view, date), [view, date])
return (
<>
<AgendaHeader
view={view}
date={date}
onToday={() => navigate(view, new Date())}
onStep={step}
onViewChange={(v) => navigate(v, date)}
/>
<div className="flex min-h-0 flex-1">
<AgendaSidebar
selectedDate={date}
onSelectDate={(d) => navigate(view, d)}
onCreateEvent={() => openCreateDialog()}
/>
<main className="flex min-w-0 flex-1 flex-col">
{calendarsLoading ? (
<div className="flex h-full flex-col gap-3 p-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="min-h-0 flex-1" />
</div>
) : view === "month" ? (
<AgendaViewMonth
date={date}
events={events}
onCreateAt={(day, anchor) =>
handleCreateRange(day, addDays(day, 1), true, anchor)
}
onEventClick={handleEventClick}
onOpenDay={(day) => navigate("day", day)}
/>
) : (
<AgendaViewWeek
days={days}
events={events}
onCreateRange={handleCreateRange}
onEventClick={handleEventClick}
onOpenDay={(day) => navigate("day", day)}
/>
)}
</main>
</div>
<AgendaQuickCreate
state={quickCreate}
calendars={visibleCalendars.length > 0 ? visibleCalendars : calendars}
defaultCalendarId={defaultCalendarId}
userEmail={userEmail}
onClose={() => setQuickCreate(null)}
onMoreOptions={(draft) => {
setQuickCreate(null)
setDialogState({ mode: "create", draft })
}}
/>
<AgendaEventPopover
state={popover}
calendars={calendars}
userEmail={userEmail}
onClose={() => setPopover(null)}
onEdit={openEditDialog}
/>
<AgendaEventDialog
state={dialogState}
onClose={() => setDialogState(null)}
calendars={calendars}
userEmail={userEmail}
/>
</>
)
}

View File

@ -0,0 +1,157 @@
"use client"
import { useEffect, useState } from "react"
import { format } from "date-fns"
import { fr } from "date-fns/locale"
import { toast } from "sonner"
import { X } from "lucide-react"
import { AgendaFloatingCard, type AnchorRect } from "@/components/agenda/agenda-floating-card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
draftToApiEvent,
useCreateAgendaEvent,
} from "@/lib/api/hooks/use-calendar-mutations"
import { formatEventTime } from "@/lib/agenda/agenda-date"
import { calendarColor } from "@/lib/agenda/agenda-events"
import type {
AgendaCalendar,
AgendaEventDraft,
} from "@/lib/agenda/agenda-types"
export interface AgendaQuickCreateState {
start: Date
end: Date
allDay: boolean
anchor: AnchorRect
}
export function AgendaQuickCreate({
state,
calendars,
defaultCalendarId,
userEmail,
onClose,
onMoreOptions,
}: {
state: AgendaQuickCreateState | null
calendars: AgendaCalendar[]
defaultCalendarId: string
userEmail?: string
onClose: () => void
onMoreOptions: (draft: AgendaEventDraft) => void
}) {
const [title, setTitle] = useState("")
const [calendarId, setCalendarId] = useState(defaultCalendarId)
const createMutation = useCreateAgendaEvent()
useEffect(() => {
if (state) {
setTitle("")
setCalendarId(defaultCalendarId)
}
}, [state, defaultCalendarId])
if (!state) return null
const draft: AgendaEventDraft = {
title,
start: state.start,
end: state.end,
allDay: state.allDay,
calendarId,
}
const dateLabel = format(state.start, "EEEE d MMMM", { locale: fr }).replace(
/^./,
(c) => c.toUpperCase(),
)
const timeLabel = state.allDay
? "Toute la journée"
: `${formatEventTime(state.start)} ${formatEventTime(state.end)}`
const save = async () => {
try {
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 de créer l'événement")
}
}
return (
<AgendaFloatingCard anchor={state.anchor} onClose={onClose} width={400}>
<div className="flex items-center justify-end px-2 pt-2">
<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-4 px-5 pb-5">
<Input
value={title}
autoFocus
placeholder="Ajouter un titre"
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void save()
}}
className="h-11 rounded-none border-0 border-b-2 border-border/60 !bg-transparent px-1 !text-lg shadow-none focus-visible:border-primary focus-visible:ring-0"
/>
<div className="flex flex-col gap-1 text-sm text-foreground/85">
<span>{dateLabel}</span>
<span className="text-muted-foreground">{timeLabel}</span>
</div>
<Select value={calendarId} onValueChange={setCalendarId}>
<SelectTrigger className="h-9 w-fit min-w-44 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 justify-end gap-2">
<Button
variant="ghost"
className="rounded-full"
onClick={() => onMoreOptions(draft)}
>
Autres options
</Button>
<Button
className="rounded-full px-5"
disabled={createMutation.isPending || !calendarId}
onClick={() => void save()}
>
Enregistrer
</Button>
</div>
</div>
</AgendaFloatingCard>
)
}

View File

@ -0,0 +1,238 @@
"use client"
import { useState } from "react"
import { toast } from "sonner"
import { MoreVertical, Pencil, Plus, Trash2 } from "lucide-react"
import { AgendaCalendarDialog } from "@/components/agenda/agenda-calendar-dialog"
import { AgendaMiniMonth } from "@/components/agenda/agenda-mini-month"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Skeleton } from "@/components/ui/skeleton"
import { useAgendaCalendars } from "@/lib/api/hooks/use-calendar-queries"
import { useDeleteAgendaCalendar } from "@/lib/api/hooks/use-calendar-mutations"
import { calendarColor } from "@/lib/agenda/agenda-events"
import { useAgendaSettingsStore, useAgendaUIStore } from "@/lib/agenda/agenda-store"
import type { AgendaCalendar } from "@/lib/agenda/agenda-types"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
export function AgendaSidebar({
selectedDate,
onSelectDate,
onCreateEvent,
}: {
selectedDate: Date
onSelectDate: (date: Date) => void
onCreateEvent: () => void
}) {
const isMobile = useIsMobile()
const sidebarCollapsed = useAgendaUIStore((s) => s.sidebarCollapsed)
const setSidebarCollapsed = useAgendaUIStore((s) => s.setSidebarCollapsed)
const hiddenIds = useAgendaSettingsStore((s) => s.hiddenCalendarIds)
const toggleCalendar = useAgendaSettingsStore((s) => s.toggleCalendarVisible)
const { data: calendars, isLoading } = useAgendaCalendars()
const deleteMutation = useDeleteAgendaCalendar()
const [calendarDialogOpen, setCalendarDialogOpen] = useState(false)
const [editingCalendar, setEditingCalendar] = useState<AgendaCalendar | null>(null)
const [deletingCalendar, setDeletingCalendar] = useState<AgendaCalendar | null>(null)
const open = !sidebarCollapsed
const confirmDelete = async () => {
if (!deletingCalendar) return
try {
await deleteMutation.mutateAsync({ id: deletingCalendar.id })
toast.success(`Agenda « ${deletingCalendar.display_name} » supprimé`)
} catch {
toast.error("Impossible de supprimer cet agenda")
} finally {
setDeletingCalendar(null)
}
}
return (
<>
<aside
className={cn(
"flex h-full w-64 shrink-0 flex-col gap-4 overflow-y-auto bg-app-canvas px-3 pt-3 pb-4",
isMobile
? cn(
"fixed inset-y-0 left-0 z-50 shadow-xl transition-transform duration-200 ease-linear",
open ? "translate-x-0" : "-translate-x-full pointer-events-none",
)
: cn(!open && "hidden"),
)}
aria-hidden={isMobile && !open}
>
<Button
className="h-13 w-fit gap-3 rounded-2xl border border-border/60 bg-card px-5 text-[0.95rem] font-medium text-foreground shadow-md hover:bg-mail-nav-hover hover:shadow-lg"
variant="ghost"
onClick={() => {
onCreateEvent()
if (isMobile) setSidebarCollapsed(true)
}}
>
<Plus className="size-6 text-primary" />
Créer
</Button>
<AgendaMiniMonth
selected={selectedDate}
onSelect={(d) => {
onSelectDate(d)
if (isMobile) setSidebarCollapsed(true)
}}
/>
<div className="flex min-h-0 flex-col gap-0.5">
<div className="flex items-center justify-between pr-1 pl-2">
<span className="py-1 text-sm font-medium text-foreground/90">
Mes agendas
</span>
<Button
variant="ghost"
size="icon"
className="size-7 rounded-full text-muted-foreground"
aria-label="Créer un agenda"
onClick={() => {
setEditingCalendar(null)
setCalendarDialogOpen(true)
}}
>
<Plus className="size-4" />
</Button>
</div>
{isLoading && (
<div className="flex flex-col gap-2 px-2 py-1">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-5 w-32" />
</div>
)}
{(calendars ?? []).map((cal) => {
const color = calendarColor(cal)
const visible = !hiddenIds.includes(cal.id)
return (
<div
key={cal.id}
className="group flex items-center gap-2.5 rounded-lg px-2 py-1.5 hover:bg-mail-nav-hover"
>
<label className="flex min-w-0 flex-1 cursor-pointer items-center gap-2.5">
<input
type="checkbox"
checked={visible}
onChange={() => toggleCalendar(cal.id)}
className="peer sr-only"
/>
<span
aria-hidden
className={cn(
"flex size-4.5 shrink-0 items-center justify-center rounded-[5px] border-2 transition-colors",
)}
style={{
borderColor: color,
backgroundColor: visible ? color : "transparent",
}}
>
{visible && (
<svg viewBox="0 0 24 24" className="size-3.5 text-white" fill="none">
<path
d="M5 13l4 4L19 7"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</span>
<span className="truncate text-sm text-foreground/85">
{cal.display_name}
</span>
</label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 rounded-full text-muted-foreground opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100"
aria-label={`Options de ${cal.display_name}`}
>
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44">
<DropdownMenuItem
onSelect={() => {
setEditingCalendar(cal)
setCalendarDialogOpen(true)
}}
>
<Pencil className="size-4" /> Modifier
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => setDeletingCalendar(cal)}
>
<Trash2 className="size-4" /> Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
})}
</div>
</aside>
<AgendaCalendarDialog
open={calendarDialogOpen}
onOpenChange={setCalendarDialogOpen}
calendar={editingCalendar}
/>
<AlertDialog
open={deletingCalendar !== null}
onOpenChange={(o) => {
if (!o) setDeletingCalendar(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Supprimer « {deletingCalendar?.display_name} » ?
</AlertDialogTitle>
<AlertDialogDescription>
Tous les événements de cet agenda seront définitivement supprimés.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
onClick={() => void confirmDelete()}
>
Supprimer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@ -0,0 +1,124 @@
"use client"
import type { MouseEvent } from "react"
import { format, isSameDay, isSameMonth } from "date-fns"
import { fr } from "date-fns/locale"
import { AgendaEventChip } from "@/components/agenda/agenda-event-chip"
import type { AnchorRect } from "@/components/agenda/agenda-floating-card"
import { viewDays } from "@/lib/agenda/agenda-date"
import { eventsOnDay, isMultiDay } from "@/lib/agenda/agenda-events"
import type { AgendaEvent } from "@/lib/agenda/agenda-types"
import { cn } from "@/lib/utils"
const MAX_CHIPS = 4
function anchorFromEvent(e: MouseEvent<HTMLElement>): AnchorRect {
const rect = e.currentTarget.getBoundingClientRect()
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
}
export function AgendaViewMonth({
date,
events,
onCreateAt,
onEventClick,
onOpenDay,
}: {
date: Date
events: AgendaEvent[]
onCreateAt: (day: Date, anchor: AnchorRect) => void
onEventClick: (event: AgendaEvent, anchor: AnchorRect) => void
onOpenDay: (day: Date) => void
}) {
const days = viewDays("month", date)
const weeks: Date[][] = []
for (let i = 0; i < days.length; i += 7) weeks.push(days.slice(i, i + 7))
const today = new Date()
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-tl-2xl border-t border-l border-border/60 bg-card">
<div className="grid shrink-0 grid-cols-7 border-b border-border/60">
{days.slice(0, 7).map((d) => (
<div
key={d.getTime()}
className="py-1.5 text-center text-[0.7rem] font-medium tracking-wide text-muted-foreground uppercase"
>
{format(d, "EEE", { locale: fr }).replace(".", "")}
</div>
))}
</div>
<div
className="grid min-h-0 flex-1 grid-cols-7"
style={{ gridTemplateRows: `repeat(${weeks.length}, minmax(0, 1fr))` }}
>
{weeks.map((week) =>
week.map((day) => {
const dayEvents = eventsOnDay(events, day).sort((a, b) => {
const aBanner = a.allDay || isMultiDay(a)
const bBanner = b.allDay || isMultiDay(b)
if (aBanner !== bBanner) return aBanner ? -1 : 1
return a.start.getTime() - b.start.getTime()
})
const visible = dayEvents.slice(0, MAX_CHIPS)
const hidden = dayEvents.length - visible.length
const isToday = isSameDay(day, today)
const inMonth = isSameMonth(day, date)
return (
<div
key={day.getTime()}
role="gridcell"
className={cn(
"flex min-h-0 cursor-pointer flex-col gap-0.5 overflow-hidden border-r border-b border-border/40 px-1 pb-1",
!inMonth && "bg-muted/30",
)}
onClick={(e) => onCreateAt(day, anchorFromEvent(e))}
>
<div className="flex justify-center pt-1">
<button
type="button"
className={cn(
"flex h-6 min-w-6 items-center justify-center rounded-full px-1 text-xs hover:bg-mail-nav-hover",
isToday && "bg-primary font-semibold text-primary-foreground hover:bg-primary",
!inMonth && "text-muted-foreground/60",
)}
onClick={(e) => {
e.stopPropagation()
onOpenDay(day)
}}
>
{day.getDate() === 1 && inMonth
? format(day, "d MMM", { locale: fr })
: day.getDate()}
</button>
</div>
{visible.map((event) => (
<AgendaEventChip
key={event.key}
event={event}
filled={event.allDay || isMultiDay(event)}
onClick={(e) => {
e.stopPropagation()
onEventClick(event, anchorFromEvent(e))
}}
/>
))}
{hidden > 0 && (
<button
type="button"
className="w-full truncate rounded-md px-1.5 py-[1px] text-left text-xs font-medium text-muted-foreground hover:bg-mail-nav-hover"
onClick={(e) => {
e.stopPropagation()
onOpenDay(day)
}}
>
{hidden} autre{hidden > 1 ? "s" : ""}
</button>
)}
</div>
)
}),
)}
</div>
</div>
)
}

View File

@ -0,0 +1,319 @@
"use client"
import {
useEffect,
useMemo,
useRef,
useState,
type MouseEvent,
type PointerEvent as ReactPointerEvent,
} from "react"
import { format, isSameDay } from "date-fns"
import { fr } from "date-fns/locale"
import { AgendaEventChip } from "@/components/agenda/agenda-event-chip"
import type { AnchorRect } from "@/components/agenda/agenda-floating-card"
import { formatEventTime, roundToStep } from "@/lib/agenda/agenda-date"
import { readableTextColor } from "@/lib/agenda/agenda-colors"
import { layoutDayEvents } from "@/lib/agenda/agenda-event-layout"
import { eventsOnDay, isMultiDay } from "@/lib/agenda/agenda-events"
import type { AgendaEvent } from "@/lib/agenda/agenda-types"
import { cn } from "@/lib/utils"
const HOUR_PX = 48
const GUTTER_PX = 56
const MIN_EVENT_PX = 22
function anchorFromEvent(e: MouseEvent<HTMLElement>): AnchorRect {
const rect = e.currentTarget.getBoundingClientRect()
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
}
interface DragState {
dayIndex: number
anchorMin: number
startMin: number
endMin: number
moved: boolean
}
export function AgendaViewWeek({
days,
events,
onCreateRange,
onEventClick,
onOpenDay,
}: {
days: Date[]
events: AgendaEvent[]
onCreateRange: (start: Date, end: Date, allDay: boolean, anchor: AnchorRect) => void
onEventClick: (event: AgendaEvent, anchor: AnchorRect) => void
onOpenDay: (day: Date) => void
}) {
const scrollRef = useRef<HTMLDivElement>(null)
const [drag, setDrag] = useState<DragState | null>(null)
const [now, setNow] = useState(() => new Date())
useEffect(() => {
const id = window.setInterval(() => setNow(new Date()), 60_000)
return () => window.clearInterval(id)
}, [])
useEffect(() => {
const el = scrollRef.current
if (!el) return
const target = Math.max(0, (Math.min(now.getHours(), 18) - 1.5) * HOUR_PX)
el.scrollTop = target
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const perDay = useMemo(
() =>
days.map((day) => {
const dayEvents = eventsOnDay(events, day)
const banners = dayEvents
.filter((e) => e.allDay || isMultiDay(e))
.sort((a, b) => a.start.getTime() - b.start.getTime())
const timed = dayEvents.filter((e) => !e.allDay && !isMultiDay(e))
return { day, banners, positioned: layoutDayEvents(timed, day) }
}),
[days, events],
)
const hasBanners = perDay.some((d) => d.banners.length > 0)
const colTemplate = `${GUTTER_PX}px repeat(${days.length}, minmax(0, 1fr))`
const minuteFromPointer = (e: ReactPointerEvent<HTMLElement>): number => {
const rect = e.currentTarget.getBoundingClientRect()
const minutes = ((e.clientY - rect.top) / HOUR_PX) * 60
return Math.min(24 * 60, Math.max(0, roundToStep(minutes, 15)))
}
const handlePointerDown = (dayIndex: number) => (e: ReactPointerEvent<HTMLDivElement>) => {
if (e.button !== 0 || (e.target as HTMLElement).closest("[data-agenda-event]")) return
const min = minuteFromPointer(e)
e.currentTarget.setPointerCapture(e.pointerId)
setDrag({ dayIndex, anchorMin: min, startMin: min, endMin: min + 15, moved: false })
}
const handlePointerMove = (dayIndex: number) => (e: ReactPointerEvent<HTMLDivElement>) => {
setDrag((d) => {
if (!d || d.dayIndex !== dayIndex) return d
const min = minuteFromPointer(e)
if (min === d.anchorMin && !d.moved) return d
return {
...d,
moved: true,
startMin: Math.min(d.anchorMin, min),
endMin: Math.max(d.anchorMin, min, Math.min(d.anchorMin, min) + 15),
}
})
}
const handlePointerUp = (dayIndex: number) => (e: ReactPointerEvent<HTMLDivElement>) => {
if (!drag || drag.dayIndex !== dayIndex) return
const day = days[dayIndex]
const startMin = drag.startMin
const endMin = drag.moved ? drag.endMin : drag.startMin + 60
const start = new Date(day)
start.setHours(0, startMin, 0, 0)
const end = new Date(day)
end.setHours(0, Math.max(endMin, startMin + 15), 0, 0)
const colRect = e.currentTarget.getBoundingClientRect()
const anchor: AnchorRect = {
left: colRect.left,
top: colRect.top + (startMin / 60) * HOUR_PX,
width: colRect.width,
height: Math.max(((endMin - startMin) / 60) * HOUR_PX, MIN_EVENT_PX),
}
setDrag(null)
onCreateRange(start, end, false, anchor)
}
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-tl-2xl border-t border-l border-border/60 bg-card">
{/* En-tête : jours + rangée journée entière */}
<div className="shrink-0 border-b border-border/60 pr-[var(--agenda-sbw,0px)]">
<div className="grid" style={{ gridTemplateColumns: colTemplate }}>
<div />
{days.map((day) => {
const isToday = isSameDay(day, now)
return (
<div
key={day.getTime()}
className="flex flex-col items-center gap-0.5 border-l border-border/40 pt-2 pb-1"
>
<span className="text-[0.7rem] font-medium tracking-wide text-muted-foreground uppercase">
{format(day, "EEE", { locale: fr }).replace(".", "")}
</span>
<button
type="button"
onClick={() => onOpenDay(day)}
className={cn(
"flex size-10 items-center justify-center rounded-full text-[1.45rem] font-normal text-foreground/80 hover:bg-mail-nav-hover",
isToday &&
"bg-primary font-medium text-primary-foreground hover:bg-primary",
)}
>
{day.getDate()}
</button>
</div>
)
})}
</div>
{hasBanners && (
<div className="grid" style={{ gridTemplateColumns: colTemplate }}>
<div className="pt-0.5 pr-2 text-right text-[0.65rem] text-muted-foreground" />
{perDay.map(({ day, banners }) => (
<div
key={day.getTime()}
className="flex min-h-6 flex-col gap-0.5 border-l border-border/40 px-0.5 pb-1"
>
{banners.map((event) => (
<AgendaEventChip
key={event.key}
event={event}
filled
onClick={(e) => {
e.stopPropagation()
onEventClick(event, anchorFromEvent(e))
}}
/>
))}
</div>
))}
</div>
)}
</div>
{/* Grille horaire */}
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
<div
className="relative grid"
style={{ gridTemplateColumns: colTemplate, height: 24 * HOUR_PX }}
>
{/* Gouttière heures */}
<div className="relative">
{Array.from({ length: 23 }, (_, i) => i + 1).map((h) => (
<span
key={h}
className="absolute right-2 -translate-y-1/2 text-[0.65rem] text-muted-foreground"
style={{ top: h * HOUR_PX }}
>
{String(h).padStart(2, "0")}:00
</span>
))}
</div>
{perDay.map(({ day, positioned }, dayIndex) => {
const isToday = isSameDay(day, now)
const nowTop = (now.getHours() * 60 + now.getMinutes()) * (HOUR_PX / 60)
return (
<div
key={day.getTime()}
className="relative cursor-pointer touch-none border-l border-border/40"
onPointerDown={handlePointerDown(dayIndex)}
onPointerMove={handlePointerMove(dayIndex)}
onPointerUp={handlePointerUp(dayIndex)}
>
{/* Lignes d'heures */}
{Array.from({ length: 23 }, (_, i) => i + 1).map((h) => (
<div
key={h}
aria-hidden
className="absolute right-0 left-0 border-t border-border/40"
style={{ top: h * HOUR_PX }}
/>
))}
{/* Sélection en cours */}
{drag && drag.dayIndex === dayIndex && drag.moved && (
<div
aria-hidden
className="absolute right-1 left-0.5 z-10 rounded-md bg-primary/25 ring-1 ring-primary/50"
style={{
top: (drag.startMin / 60) * HOUR_PX,
height: Math.max(
((drag.endMin - drag.startMin) / 60) * HOUR_PX,
8,
),
}}
>
<span className="px-1.5 text-[0.65rem] font-medium text-primary">
{formatMinutes(drag.startMin)} {formatMinutes(drag.endMin)}
</span>
</div>
)}
{/* Événements positionnés */}
{positioned.map(({ event, top, duration, leftPct, widthPct }) => {
const compact = (duration / 60) * HOUR_PX < 40
return (
<button
key={event.key}
type="button"
data-agenda-event
className="absolute z-20 flex flex-col overflow-hidden rounded-md px-1.5 py-0.5 text-left shadow-sm ring-1 ring-black/5 transition-[filter] hover:z-30 hover:brightness-95 dark:ring-white/10 dark:hover:brightness-110"
style={{
top: (top / 60) * HOUR_PX,
height: Math.max((duration / 60) * HOUR_PX - 2, MIN_EVENT_PX),
left: `calc(${leftPct}% + 1px)`,
width: `calc(${widthPct}% - 3px)`,
backgroundColor: event.color,
color: readableTextColor(event.color),
}}
onClick={(e) => {
e.stopPropagation()
onEventClick(event, anchorFromEvent(e))
}}
onPointerDown={(e) => e.stopPropagation()}
>
<span
className={cn(
"truncate text-xs leading-tight font-medium",
compact && "text-[0.7rem]",
)}
>
{event.title}
{compact && (
<span className="font-normal opacity-90">
{" "}
{formatEventTime(event.start)}
</span>
)}
</span>
{!compact && (
<span className="truncate text-[0.7rem] leading-tight opacity-90">
{formatEventTime(event.start)} {formatEventTime(event.end)}
</span>
)}
</button>
)
})}
{/* Indicateur maintenant */}
{isToday && (
<div
aria-hidden
className="pointer-events-none absolute right-0 left-0 z-30"
style={{ top: nowTop }}
>
<div className="relative h-0.5 bg-red-500">
<span className="absolute top-1/2 -left-1 size-3 -translate-y-1/2 rounded-full bg-red-500" />
</div>
</div>
)}
</div>
)
})}
</div>
</div>
</div>
)
}
function formatMinutes(min: number): string {
const h = Math.floor(min / 60)
const m = min % 60
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`
}

View File

@ -0,0 +1,53 @@
"use client"
import Image from "next/image"
import Link from "next/link"
import { HeaderAccountActions } from "@/components/suite/header-account-actions"
import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
const COMPTE_HREF = "/compte"
export function CompteSettingsHeader() {
return (
<header
data-compte-settings-chrome-header
className="flex h-16 w-full shrink-0 items-center gap-0 bg-app-canvas pr-4 sm:gap-2"
>
<div className="hidden h-full w-64 shrink-0 items-center gap-2 pl-4 md:flex lg:w-72">
<Link href={COMPTE_HREF} className="inline-flex shrink-0 items-center gap-2">
<Image
src={suitePublicAsset("/compte-mark.svg")}
alt=""
width={32}
height={32}
className="h-8 w-8"
priority
/>
<span className="text-sm font-medium text-muted-foreground">
Compte Ulti
</span>
</Link>
</div>
<div className="flex shrink-0 items-center pl-2 md:hidden">
<Link href={COMPTE_HREF} className="inline-flex shrink-0 items-center">
<Image
src={suitePublicAsset("/compte-mark.svg")}
alt="Compte Ulti"
width={32}
height={32}
className="h-8 w-8"
priority
/>
</Link>
</div>
<div className="flex min-w-0 flex-1 items-center px-1 sm:pl-1 sm:pr-1" />
<HeaderAccountActions
className="ml-auto shrink-0 pl-2 sm:pl-4"
settingsHref={COMPTE_HREF}
/>
</header>
)
}

View File

@ -0,0 +1,114 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils"
import {
COMPTE_SETTINGS_NAV,
isCompteSettingsNavActive,
} from "@/lib/compte-settings/settings-nav"
import {
mailNavRowClass,
MAIL_SETTINGS_MAIN_CARD_CLASS,
MAIL_SETTINGS_MAIN_INSET_CLASS,
} from "@/lib/mail-chrome-classes"
import { CompteSettingsHeader } from "@/components/compte/compte-settings-header"
export function CompteSettingsLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
return (
<div
data-compte-settings-app
className="ultimail-app flex h-dvh max-h-dvh flex-col overflow-hidden bg-app-canvas"
>
<CompteSettingsHeader />
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
<aside
data-compte-settings-sidebar
className="hidden w-64 shrink-0 overflow-y-auto bg-app-canvas p-3 md:block lg:w-72"
>
<nav className="space-y-1" aria-label="Sections du compte">
{COMPTE_SETTINGS_NAV.map((item) => {
const active = isCompteSettingsNavActive(pathname, item)
const Icon = item.icon
return (
<Link
key={item.id}
href={item.href}
aria-current={active ? "page" : undefined}
className={cn(
"flex w-full items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
active
? "bg-mail-nav-selected"
: "hover:bg-mail-nav-hover"
)}
>
<Icon
className={cn(
"mt-0.5 size-4 shrink-0 opacity-70",
active ? "text-mail-nav-selected" : "text-muted-foreground"
)}
/>
<span className="min-w-0 flex-1">
<span
className={cn(
"block text-sm font-medium",
active ? "text-mail-nav-selected" : "text-muted-foreground"
)}
>
{item.label}
</span>
<span className="block text-xs font-normal text-muted-foreground">
{item.description}
</span>
</span>
</Link>
)
})}
</nav>
</aside>
<div className={MAIL_SETTINGS_MAIN_INSET_CLASS}>
<div data-compte-settings-main className={MAIL_SETTINGS_MAIN_CARD_CLASS}>
<nav
className="shrink-0 border-b border-border px-2 py-2 md:hidden"
aria-label="Sections du compte"
>
<div className="flex gap-1 overflow-x-auto">
{COMPTE_SETTINGS_NAV.map((item) => {
const active = isCompteSettingsNavActive(pathname, item)
const Icon = item.icon
return (
<Link
key={item.id}
href={item.href}
aria-label={item.label}
aria-current={active ? "page" : undefined}
className={cn(
"flex shrink-0 items-center rounded-lg",
active
? cn("gap-2 px-3 py-2", mailNavRowClass({ isSelected: true }))
: cn("size-9 justify-center", mailNavRowClass({ isSelected: false }))
)}
>
<Icon className="size-4 shrink-0 opacity-70" />
{active ? (
<span className="text-sm font-medium">{item.label}</span>
) : null}
</Link>
)
})}
</div>
</nav>
<main className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-8">
<div className="mx-auto w-full max-w-3xl">{children}</div>
</main>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,33 @@
"use client"
import {
resolveCompteSettingsSection,
type CompteSettingsSectionId,
} from "@/lib/compte-settings/settings-nav"
import { CompteHomeSection } from "@/components/compte/sections/compte-home-section"
import { ComptePersonalInfoSection } from "@/components/compte/sections/compte-personal-info-section"
import { CompteSecuritySection } from "@/components/compte/sections/compte-security-section"
const SECTIONS: Record<CompteSettingsSectionId, React.ComponentType> = {
home: CompteHomeSection,
"personal-info": ComptePersonalInfoSection,
security: CompteSecuritySection,
}
export function CompteSettingsSectionView({
sectionId,
}: {
sectionId: CompteSettingsSectionId
}) {
const Section = SECTIONS[sectionId]
return <Section />
}
export function CompteSettingsSectionFromSegments({
segments,
}: {
segments?: string[]
}) {
const sectionId = resolveCompteSettingsSection(segments)
return <CompteSettingsSectionView sectionId={sectionId} />
}

View File

@ -0,0 +1,77 @@
"use client"
import Link from "next/link"
import { ChevronRight, ShieldCheck, UserRound } from "lucide-react"
import { AccountAvatar } from "@/components/suite/account-avatar"
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
const CARDS = [
{
href: "/compte/informations",
icon: UserRound,
title: "Informations personnelles",
description:
"Consultez votre nom, votre adresse e-mail et votre identifiant Ulti.",
},
{
href: "/compte/securite",
icon: ShieldCheck,
title: "Sécurité",
description:
"Gérez votre mot de passe, la validation en deux étapes et vos sessions.",
},
] as const
export function CompteHomeSection() {
const identity = useChromeIdentity()
return (
<>
<header className="mb-8 flex flex-col items-center text-center">
{identity ? (
<AccountAvatar
account={{ name: identity.name, email: identity.email }}
size="lg"
/>
) : (
<div className="size-20 rounded-full bg-muted" aria-hidden />
)}
<h2 className="mt-4 text-2xl font-normal text-foreground">
{identity ? `Bonjour ${identity.firstName} !` : "Votre compte Ulti"}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Gérez vos informations et la sécurité de votre compte sur l&apos;ensemble
de la suite Ulti.
</p>
{identity ? (
<p className="mt-1 text-sm text-muted-foreground">{identity.email}</p>
) : null}
</header>
<div className="grid gap-4 sm:grid-cols-2">
{CARDS.map((card) => {
const Icon = card.icon
return (
<Link
key={card.href}
href={card.href}
className="group flex flex-col rounded-2xl border border-border bg-background p-5 transition-colors hover:bg-accent"
>
<Icon className="size-6 text-muted-foreground" aria-hidden />
<span className="mt-3 flex items-center gap-1 text-sm font-medium text-foreground">
{card.title}
<ChevronRight
className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5"
aria-hidden
/>
</span>
<span className="mt-1 text-sm text-muted-foreground">
{card.description}
</span>
</Link>
)
})}
</div>
</>
)
}

View File

@ -0,0 +1,95 @@
"use client"
import { ExternalLink } from "lucide-react"
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
import { useCurrentUser } from "@/lib/api/hooks/use-current-user"
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
import { authentikUserSettingsUrl } from "@/lib/auth/authentik-user-url"
const ROLE_LABELS: Record<string, string> = {
admin: "Administrateur",
user: "Utilisateur",
guest: "Invité",
suspended: "Suspendu",
}
export function ComptePersonalInfoSection() {
const identity = useChromeIdentity()
const { data: user, isFetching, isError, refetch } = useCurrentUser()
const idpUrl = authentikUserSettingsUrl()
const name = user?.name || identity?.name || "—"
const email = user?.email || identity?.email || "—"
return (
<>
<SettingsSectionHeader
title="Informations personnelles"
description="Informations de votre compte Ulti, partagées par toutes les applications de la suite."
/>
<SettingsSyncBanner
isFetching={isFetching}
isError={isError}
onRetry={() => refetch()}
/>
<div className="overflow-hidden rounded-2xl border border-border">
<InfoRow label="Nom" value={name} />
<InfoRow label="Adresse e-mail" value={email} />
<InfoRow label="Identifiant" value={user?.sub ?? "—"} mono />
{user ? (
<InfoRow label="Rôle" value={ROLE_LABELS[user.role] ?? user.role} />
) : null}
{user?.groups?.length ? (
<InfoRow label="Groupes" value={user.groups.join(", ")} />
) : null}
</div>
<p className="mt-4 text-sm text-muted-foreground">
Votre identité est gérée par le fournisseur d&apos;identité de votre
organisation. Pour modifier votre nom ou votre adresse e-mail,
rapprochez-vous de votre administrateur
{idpUrl ? " ou utilisez le portail d'identité" : ""}.
</p>
{idpUrl ? (
<a
href={idpUrl}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
Ouvrir le portail d&apos;identité
<ExternalLink className="size-3.5" aria-hidden />
</a>
) : null}
</>
)
}
function InfoRow({
label,
value,
mono = false,
}: {
label: string
value: string
mono?: boolean
}) {
return (
<div className="flex flex-col gap-0.5 border-b border-border px-4 py-3 last:border-b-0 sm:flex-row sm:items-center sm:gap-4">
<span className="w-40 shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</span>
<span
className={
mono
? "min-w-0 break-all font-mono text-xs text-foreground"
: "min-w-0 truncate text-sm text-foreground"
}
>
{value}
</span>
</div>
)
}

View File

@ -0,0 +1,114 @@
"use client"
import { ExternalLink, KeyRound, LogOut, Smartphone } from "lucide-react"
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
import { Button } from "@/components/ui/button"
import { useAuthLogout } from "@/components/auth/auth-provider"
import { authentikUserSettingsUrl } from "@/lib/auth/authentik-user-url"
export function CompteSecuritySection() {
const signOut = useAuthLogout()
const idpUrl = authentikUserSettingsUrl()
return (
<>
<SettingsSectionHeader
title="Sécurité"
description="Paramètres de connexion et de protection de votre compte Ulti."
/>
<div className="space-y-4">
<SecurityCard
icon={<KeyRound className="size-5" aria-hidden />}
title="Mot de passe"
description="Modifiez votre mot de passe depuis le portail d'identité de votre organisation."
action={
idpUrl ? (
<ExternalAction href={idpUrl} label="Changer le mot de passe" />
) : (
<UnavailableNote />
)
}
/>
<SecurityCard
icon={<Smartphone className="size-5" aria-hidden />}
title="Validation en deux étapes"
description="Ajoutez ou gérez vos appareils de validation (application TOTP, WebAuthn, clés de sécurité)."
action={
idpUrl ? (
<ExternalAction href={idpUrl} label="Gérer la validation" />
) : (
<UnavailableNote />
)
}
/>
<SecurityCard
icon={<LogOut className="size-5" aria-hidden />}
title="Session sur cet appareil"
description="Met fin à votre session Ulti sur ce navigateur. Vous devrez vous reconnecter."
action={
<Button
type="button"
variant="outline"
className="h-9 rounded-full px-4 text-sm font-medium"
onClick={() => void signOut()}
>
Se déconnecter
</Button>
}
/>
</div>
</>
)
}
function SecurityCard({
icon,
title,
description,
action,
}: {
icon: React.ReactNode
title: string
description: string
action: React.ReactNode
}) {
return (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-5 sm:flex-row sm:items-center sm:gap-4">
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-accent text-muted-foreground">
{icon}
</span>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-medium text-foreground">{title}</h3>
<p className="mt-0.5 text-sm text-muted-foreground">{description}</p>
</div>
<div className="shrink-0">{action}</div>
</div>
)
}
function ExternalAction({ href, label }: { href: string; label: string }) {
return (
<Button
type="button"
variant="outline"
className="h-9 rounded-full px-4 text-sm font-medium"
asChild
>
<a href={href} target="_blank" rel="noreferrer">
{label}
<ExternalLink className="size-3.5" aria-hidden />
</a>
</Button>
)
}
function UnavailableNote() {
return (
<span className="text-xs text-muted-foreground">
Portail d&apos;identité non configuré
</span>
)
}

View File

@ -1,6 +1,7 @@
"use client" "use client"
import { useEffect, useRef, type RefObject } from "react" import { useEffect, useRef, type RefObject } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { Camera, ChevronDown, ChevronUp, LogOut, Plus, X } from "lucide-react" import { Camera, ChevronDown, ChevronUp, LogOut, Plus, X } from "lucide-react"
import { AccountAvatar } from "@/components/gmail/account-avatar" import { AccountAvatar } from "@/components/gmail/account-avatar"
@ -141,8 +142,11 @@ export function AccountSwitcherDropdown({
type="button" type="button"
variant="outline" variant="outline"
className="mt-4 h-9 rounded-full border-border bg-transparent px-5 text-sm font-medium text-primary hover:bg-accent hover:text-primary" className="mt-4 h-9 rounded-full border-border bg-transparent px-5 text-sm font-medium text-primary hover:bg-accent hover:text-primary"
asChild
> >
<Link href="/compte" onClick={() => onOpenChange(false)}>
Gérer votre compte Gérer votre compte
</Link>
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -1,6 +1,7 @@
"use client" "use client"
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { format } from "date-fns"
import { InvitationTimeChipText } from "@/components/gmail/invitation-time-chip-text" import { InvitationTimeChipText } from "@/components/gmail/invitation-time-chip-text"
import { Icon } from "@iconify/react" import { Icon } from "@iconify/react"
import { ThumbsDown, ThumbsUp, Users, MoreVertical } from "lucide-react" import { ThumbsDown, ThumbsUp, Users, MoreVertical } from "lucide-react"
@ -121,6 +122,12 @@ export function CalendarInvitationPreview({
<button type="button" className={RSVP_SECONDARY}> <button type="button" className={RSVP_SECONDARY}>
Proposer un autre horaire Proposer un autre horaire
</button> </button>
<a
href={`/agenda/day/${format(invitation.start, "yyyy-MM-dd")}`}
className={RSVP_SECONDARY}
>
Ouvrir dans Agenda
</a>
<button <button
type="button" type="button"
className="ml-auto flex size-10 items-center justify-center rounded-full border border-border bg-mail-surface text-muted-foreground hover:bg-accent md:ml-0" className="ml-auto flex size-10 items-center justify-center rounded-full border border-border bg-mail-surface text-muted-foreground hover:bg-accent md:ml-0"

View File

@ -235,7 +235,11 @@ export function ContactHoverCard({
<button <button
type="button" type="button"
className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-border bg-mail-surface text-muted-foreground transition-colors hover:bg-accent" className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-border bg-mail-surface text-muted-foreground transition-colors hover:bg-accent"
aria-label="Planifier" aria-label="Planifier un événement"
onClick={(e) => {
e.stopPropagation()
window.location.href = `/agenda?new=1&guest=${encodeURIComponent(email)}&guest_name=${encodeURIComponent(name)}`
}}
> >
<Calendar className="h-[18px] w-[18px]" strokeWidth={1.5} /> <Calendar className="h-[18px] w-[18px]" strokeWidth={1.5} />
<span className="absolute right-1 top-1 size-1.5 rounded-full bg-[#1a73e8]" aria-hidden /> <span className="absolute right-1 top-1 size-1.5 rounded-full bg-[#1a73e8]" aria-hidden />

View File

@ -0,0 +1,29 @@
"use client"
import { useEffect } from "react"
import { useComposeActions } from "@/lib/compose-context"
import { takePendingCompose } from "@/lib/agenda/agenda-mail-compose"
/**
* Récupère un brouillon déposé par une autre app de la suite (ex. Agenda
* « Envoyer un email aux invités ») et ouvre la fenêtre de composition.
*/
export function PendingComposeBridge() {
const { openComposeWithInitial } = useComposeActions()
useEffect(() => {
const pending = takePendingCompose()
if (!pending || pending.to.length === 0) return
openComposeWithInitial({
to: pending.to,
subject: pending.subject ?? "",
bodyHtml: pending.bodyText
? `<p>${pending.bodyText.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/\n/g, "<br/>")}</p>`
: undefined,
focusBodyOnMount: true,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return null
}

View File

@ -1,5 +1,6 @@
"use client" "use client"
import Link from "next/link"
import { Calendar, Users, CheckSquare, Plus, Sparkles } from "lucide-react" import { Calendar, Users, CheckSquare, Plus, Sparkles } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { useContactsStore } from "@/lib/contacts/contacts-store" import { useContactsStore } from "@/lib/contacts/contacts-store"
@ -13,8 +14,16 @@ export function RightPanel() {
return ( return (
<aside className="hidden w-10 shrink-0 flex-col items-center gap-2 bg-transparent py-3 sm:flex"> <aside className="hidden w-10 shrink-0 flex-col items-center gap-2 bg-transparent py-3 sm:flex">
<Button variant="ghost" size="icon" className="h-9 w-9 text-gray-600 rounded-full"> <Button
variant="ghost"
size="icon"
className="h-9 w-9 text-gray-600 rounded-full"
aria-label="Ouvrir l'agenda"
asChild
>
<Link href="/agenda">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
</Link>
</Button> </Button>
<Button variant="ghost" size="icon" className="h-9 w-9 text-gray-600 rounded-full"> <Button variant="ghost" size="icon" className="h-9 w-9 text-gray-600 rounded-full">
<CheckSquare className="h-4 w-4" /> <CheckSquare className="h-4 w-4" />

View File

@ -61,9 +61,9 @@ export const LANDING_APPS: LandingApp[] = [
name: "Agenda", name: "Agenda",
tagline: "Calendrier", tagline: "Calendrier",
description: description:
"Agenda partagé, invitations et disponibilités — bientôt dans la suite.", "Agenda partagé, invitations et disponibilités, connecté au mail et aux contacts.",
icon: suitePublicAsset("/agenda-mark.svg"), icon: suitePublicAsset("/agenda-mark.svg"),
soon: true, href: "/agenda",
accent: "#34c77b", accent: "#34c77b",
}, },
{ {

View File

@ -1,5 +1,6 @@
"use client" "use client"
import Link from "next/link"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { Camera, LogOut, Plus, X } from "lucide-react" import { Camera, LogOut, Plus, X } from "lucide-react"
import { AccountAvatar } from "@/components/suite/account-avatar" import { AccountAvatar } from "@/components/suite/account-avatar"
@ -60,8 +61,11 @@ export function AccountSwitcherPanel({ onClose }: { onClose: () => void }) {
type="button" type="button"
variant="outline" variant="outline"
className="mt-4 h-9 rounded-full border-border bg-transparent px-5 text-sm font-medium text-primary hover:bg-accent hover:text-primary" className="mt-4 h-9 rounded-full border-border bg-transparent px-5 text-sm font-medium text-primary hover:bg-accent hover:text-primary"
asChild
> >
<Link href="/compte" onClick={onClose}>
Gérer votre compte Gérer votre compte
</Link>
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -0,0 +1,46 @@
/** Palette inspirée de Google Calendar. */
export const AGENDA_COLOR_PALETTE: { value: string; label: string }[] = [
{ value: "#039be5", label: "Paon" },
{ value: "#7986cb", label: "Lavande" },
{ value: "#33b679", label: "Sauge" },
{ value: "#8e24aa", label: "Raisin" },
{ value: "#e67c73", label: "Flamant rose" },
{ value: "#f6bf26", label: "Banane" },
{ value: "#f4511e", label: "Mandarine" },
{ value: "#616161", label: "Graphite" },
{ value: "#3f51b5", label: "Myrtille" },
{ value: "#0b8043", label: "Basilic" },
{ value: "#d50000", label: "Tomate" },
]
export const AGENDA_DEFAULT_COLOR = AGENDA_COLOR_PALETTE[0].value
/** Normalise une couleur DAV (`#RRGGBBAA` Nextcloud → `#RRGGBB`). */
export function normalizeAgendaColor(color: string | undefined | null): string {
const c = (color ?? "").trim()
if (/^#[0-9a-fA-F]{8}$/.test(c)) return c.slice(0, 7).toLowerCase()
if (/^#[0-9a-fA-F]{6}$/.test(c)) return c.toLowerCase()
if (/^#[0-9a-fA-F]{3}$/.test(c)) {
return `#${c[1]}${c[1]}${c[2]}${c[2]}${c[3]}${c[3]}`.toLowerCase()
}
return ""
}
/** Couleur par défaut stable pour un agenda sans couleur. */
export function fallbackCalendarColor(calendarId: string): string {
let hash = 0
for (let i = 0; i < calendarId.length; i++) {
hash = (hash * 31 + calendarId.charCodeAt(i)) | 0
}
return AGENDA_COLOR_PALETTE[Math.abs(hash) % AGENDA_COLOR_PALETTE.length].value
}
/** Couleur de texte lisible (blanc/noir) sur un fond hex. */
export function readableTextColor(hex: string): string {
const c = normalizeAgendaColor(hex)
if (!c) return "#fff"
const r = parseInt(c.slice(1, 3), 16)
const g = parseInt(c.slice(3, 5), 16)
const b = parseInt(c.slice(5, 7), 16)
return (r * 299 + g * 587 + b * 114) / 1000 > 150 ? "#1f1f1f" : "#fff"
}

135
lib/agenda/agenda-date.ts Normal file
View File

@ -0,0 +1,135 @@
import {
addDays,
endOfDay,
endOfMonth,
endOfWeek,
format,
isSameMonth,
isSameYear,
parse,
startOfDay,
startOfMonth,
startOfWeek,
} from "date-fns"
import { fr } from "date-fns/locale"
import type { AgendaView } from "./agenda-url.ts"
export const WEEK_OPTS = { weekStartsOn: 1 as const, locale: fr }
/** Parse une valeur de date ICS (`YYYYMMDD` ou `YYYYMMDDTHHMMSS[Z]`). */
export function parseICSDate(value: string): Date | null {
const v = value.trim()
if (/^\d{8}$/.test(v)) {
const y = Number(v.slice(0, 4))
const m = Number(v.slice(4, 6))
const d = Number(v.slice(6, 8))
return new Date(y, m - 1, d)
}
const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/.exec(v)
if (!match) {
const fallback = new Date(v)
return Number.isNaN(fallback.getTime()) ? null : fallback
}
const [, y, mo, d, h, mi, s, z] = match
if (z === "Z") {
return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +s))
}
return new Date(+y, +mo - 1, +d, +h, +mi, +s)
}
/** Format ICS UTC : `YYYYMMDDTHHMMSSZ`. */
export function formatICSDateTimeUTC(date: Date): string {
const p = (n: number, w = 2) => String(n).padStart(w, "0")
return (
`${p(date.getUTCFullYear(), 4)}${p(date.getUTCMonth() + 1)}${p(date.getUTCDate())}` +
`T${p(date.getUTCHours())}${p(date.getUTCMinutes())}${p(date.getUTCSeconds())}Z`
)
}
/** Format ICS date locale (journée entière) : `YYYYMMDD`. */
export function formatICSDateOnly(date: Date): string {
return format(date, "yyyyMMdd")
}
export function dateKey(date: Date): string {
return format(date, "yyyy-MM-dd")
}
export function parseDateKey(key: string): Date | null {
const parsed = parse(key, "yyyy-MM-dd", new Date())
return Number.isNaN(parsed.getTime()) ? null : parsed
}
/** Bornes affichées pour une vue (le mois inclut les semaines débordantes). */
export function viewRange(view: AgendaView, date: Date): { start: Date; end: Date } {
switch (view) {
case "day":
return { start: startOfDay(date), end: endOfDay(date) }
case "week":
return {
start: startOfWeek(date, WEEK_OPTS),
end: endOfWeek(date, WEEK_OPTS),
}
case "month":
return {
start: startOfWeek(startOfMonth(date), WEEK_OPTS),
end: endOfWeek(endOfMonth(date), WEEK_OPTS),
}
}
}
export function viewDays(view: AgendaView, date: Date): Date[] {
const { start, end } = viewRange(view, date)
const days: Date[] = []
for (let d = start; d <= end; d = addDays(d, 1)) days.push(d)
return days
}
function capitalize(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1)
}
/** Titre du header façon Google Calendar. */
export function viewTitle(view: AgendaView, date: Date): string {
if (view === "day") {
return capitalize(format(date, "EEEE d MMMM yyyy", { locale: fr }))
}
if (view === "month") {
return capitalize(format(date, "MMMM yyyy", { locale: fr }))
}
const { start, end } = viewRange("week", date)
if (isSameMonth(start, end)) {
return capitalize(format(start, "MMMM yyyy", { locale: fr }))
}
if (isSameYear(start, end)) {
return `${capitalize(format(start, "MMM", { locale: fr }))} ${format(end, "MMM yyyy", { locale: fr })}`
}
return `${capitalize(format(start, "MMM yyyy", { locale: fr }))} ${format(end, "MMM yyyy", { locale: fr })}`
}
export function formatEventTime(date: Date): string {
return format(date, "HH:mm")
}
/** Plage horaire lisible pour le popover de détails. */
export function formatEventRange(start: Date, end: Date, allDay: boolean): string {
const sameDay = dateKey(start) === dateKey(end)
const dayLabel = (d: Date) => capitalize(format(d, "EEEE d MMMM", { locale: fr }))
if (allDay) {
const endIncl = addDays(end, -1)
if (dateKey(start) === dateKey(endIncl) || end <= start) return dayLabel(start)
return `${dayLabel(start)} ${dayLabel(endIncl)}`
}
if (sameDay) {
return `${dayLabel(start)}${formatEventTime(start)} ${formatEventTime(end)}`
}
return `${dayLabel(start)} ${formatEventTime(start)} ${dayLabel(end)} ${formatEventTime(end)}`
}
export function minutesSinceMidnight(date: Date): number {
return date.getHours() * 60 + date.getMinutes()
}
export function roundToStep(minutes: number, step = 15): number {
return Math.round(minutes / step) * step
}

View File

@ -0,0 +1,89 @@
import type { AgendaEvent } from "./agenda-types.ts"
export interface PositionedEvent {
event: AgendaEvent
/** Minutes depuis minuit. */
top: number
/** Durée en minutes (minimum appliqué côté rendu). */
duration: number
leftPct: number
widthPct: number
}
interface WorkItem {
event: AgendaEvent
startMin: number
endMin: number
col: number
cluster: number
}
/**
* Positionne les événements horaires d'une colonne jour façon Google Calendar :
* les événements qui se chevauchent se partagent la largeur par colonnes.
*/
export function layoutDayEvents(events: AgendaEvent[], day: Date): PositionedEvent[] {
const dayStart = new Date(day)
dayStart.setHours(0, 0, 0, 0)
const dayStartMs = dayStart.getTime()
const minutesInDay = 24 * 60
const items: WorkItem[] = events
.map((event) => {
const startMin = Math.max(0, (event.start.getTime() - dayStartMs) / 60000)
const endMin = Math.min(minutesInDay, (event.end.getTime() - dayStartMs) / 60000)
return { event, startMin, endMin: Math.max(endMin, startMin + 15), col: 0, cluster: 0 }
})
.filter((it) => it.startMin < minutesInDay && it.endMin > 0)
.sort((a, b) => a.startMin - b.startMin || b.endMin - a.endMin)
// Regroupe en clusters d'événements transitivement chevauchants.
let clusterId = 0
let clusterEnd = -1
for (const it of items) {
if (it.startMin >= clusterEnd) {
clusterId++
clusterEnd = it.endMin
} else {
clusterEnd = Math.max(clusterEnd, it.endMin)
}
it.cluster = clusterId
}
const positioned: PositionedEvent[] = []
const clusters = new Map<number, WorkItem[]>()
for (const it of items) {
const list = clusters.get(it.cluster) ?? []
list.push(it)
clusters.set(it.cluster, list)
}
for (const list of clusters.values()) {
// Attribution gloutonne de colonnes.
const colEnds: number[] = []
for (const it of list) {
let col = colEnds.findIndex((end) => end <= it.startMin)
if (col === -1) {
col = colEnds.length
colEnds.push(it.endMin)
} else {
colEnds[col] = it.endMin
}
it.col = col
}
const colCount = colEnds.length
const width = 100 / colCount
for (const it of list) {
positioned.push({
event: it.event,
top: it.startMin,
duration: it.endMin - it.startMin,
leftPct: it.col * width,
// Léger débord à la Google quand il reste de la place à droite.
widthPct: it.col === colCount - 1 ? width : Math.min(width * 1.7, 100 - it.col * width),
})
}
}
return positioned.sort((a, b) => a.top - b.top)
}

111
lib/agenda/agenda-events.ts Normal file
View File

@ -0,0 +1,111 @@
import { addDays } from "date-fns"
import { parseICSDate } from "./agenda-date.ts"
import {
fallbackCalendarColor,
normalizeAgendaColor,
} from "./agenda-colors.ts"
import { expandOccurrences, parseRRule } from "./agenda-recurrence.ts"
import type {
AgendaApiEvent,
AgendaCalendar,
AgendaEvent,
} from "./agenda-types.ts"
export function calendarColor(calendar: AgendaCalendar): string {
return normalizeAgendaColor(calendar.color) || fallbackCalendarColor(calendar.id)
}
/**
* Transforme les événements API d'un agenda en occurrences affichables,
* en développant les récurrences dans la fenêtre demandée.
*/
export function expandApiEvents(
calendar: AgendaCalendar,
events: AgendaApiEvent[],
rangeStart: Date,
rangeEnd: Date,
): AgendaEvent[] {
const baseColor = calendarColor(calendar)
const out: AgendaEvent[] = []
for (const api of events) {
const start = parseICSDate(api.start)
if (!start) continue
let end = api.end ? parseICSDate(api.end) : null
if (!end || end <= start) {
end = api.all_day ? addDays(start, 1) : new Date(start.getTime() + 30 * 60000)
}
const durationMs = end.getTime() - start.getTime()
const color = normalizeAgendaColor(api.color) || baseColor
const base: Omit<AgendaEvent, "key" | "start" | "end"> = {
calendarId: calendar.id,
path: api.path ?? "",
etag: api.etag ?? "",
uid: api.uid,
title: api.summary || "(Sans titre)",
description: api.description ?? "",
location: api.location ?? "",
meetUrl: api.meet_url ?? "",
organizer: api.organizer ?? "",
attendees: api.attendees ?? [],
allDay: api.all_day,
color,
rrule: api.rrule ?? "",
recurring: Boolean(api.rrule),
master: api,
}
const rule = api.rrule ? parseRRule(api.rrule) : null
if (!rule) {
if (end >= rangeStart && start <= rangeEnd) {
out.push({ ...base, key: `${base.path}@${start.getTime()}`, start, end })
}
continue
}
const exdates = new Set<number>(
(api.exdates ?? [])
.map((ex) => parseICSDate(ex)?.getTime())
.filter((t): t is number => typeof t === "number"),
)
// Étend la fenêtre vers l'amont pour capter les occurrences qui débordent.
const occurrenceWindowStart = new Date(rangeStart.getTime() - durationMs)
for (const occStart of expandOccurrences(
start,
rule,
exdates,
occurrenceWindowStart,
rangeEnd,
)) {
const occEnd = new Date(occStart.getTime() + durationMs)
out.push({
...base,
key: `${base.path}@${occStart.getTime()}`,
start: occStart,
end: occEnd,
})
}
}
return out.sort((a, b) => a.start.getTime() - b.start.getTime())
}
/** Événements (déjà développés) qui couvrent un jour donné. */
export function eventsOnDay(events: AgendaEvent[], day: Date): AgendaEvent[] {
const dayStart = new Date(day)
dayStart.setHours(0, 0, 0, 0)
const dayEnd = addDays(dayStart, 1)
return events.filter((e) => e.start < dayEnd && e.end > dayStart)
}
export function isMultiDay(event: AgendaEvent): boolean {
if (event.allDay) {
return event.end.getTime() - event.start.getTime() > 24 * 3600 * 1000
}
const sameDay =
event.start.getFullYear() === event.end.getFullYear() &&
event.start.getMonth() === event.end.getMonth() &&
event.start.getDate() === event.end.getDate()
return !sameDay
}

View File

@ -0,0 +1,36 @@
"use client"
/**
* Passage de relais Agenda Ultimail : on dépose un brouillon en
* sessionStorage puis on navigue vers /mail, le shell mail le récupère
* et ouvre la fenêtre de composition.
*/
export const PENDING_COMPOSE_KEY = "ulti_pending_compose"
export interface PendingCompose {
to: { name: string; email: string }[]
subject?: string
bodyText?: string
}
export function stashPendingCompose(compose: PendingCompose): void {
try {
sessionStorage.setItem(PENDING_COMPOSE_KEY, JSON.stringify(compose))
} catch {
// sessionStorage indisponible : on navigue quand même, sans préremplissage.
}
}
export function takePendingCompose(): PendingCompose | null {
try {
const raw = sessionStorage.getItem(PENDING_COMPOSE_KEY)
if (!raw) return null
sessionStorage.removeItem(PENDING_COMPOSE_KEY)
const parsed = JSON.parse(raw) as PendingCompose
if (!Array.isArray(parsed.to)) return null
return parsed
} catch {
return null
}
}

View File

@ -0,0 +1,116 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { describeRRule, expandOccurrences, parseRRule } from "./agenda-recurrence.ts"
const d = (iso: string) => new Date(iso)
describe("parseRRule", () => {
it("parse une règle hebdomadaire avec BYDAY", () => {
const rule = parseRRule("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,TH")
assert.deepEqual(rule, {
freq: "WEEKLY",
interval: 2,
count: undefined,
until: undefined,
byday: [1, 4],
})
})
it("rejette une fréquence inconnue", () => {
assert.equal(parseRRule("FREQ=HOURLY"), null)
})
})
describe("expandOccurrences", () => {
it("développe une règle quotidienne dans la fenêtre", () => {
const rule = parseRRule("FREQ=DAILY")!
const out = expandOccurrences(
d("2026-06-01T10:00:00"),
rule,
new Set(),
d("2026-06-10T00:00:00"),
d("2026-06-12T23:59:59"),
)
assert.deepEqual(out.map((x) => x.getDate()), [10, 11, 12])
})
it("respecte COUNT depuis la première occurrence", () => {
const rule = parseRRule("FREQ=DAILY;COUNT=5")!
const out = expandOccurrences(
d("2026-06-01T10:00:00"),
rule,
new Set(),
d("2026-06-04T00:00:00"),
d("2026-06-30T23:59:59"),
)
// Occurrences 15 = 1er au 5 juin ; fenêtre depuis le 4 → 4 et 5 juin.
assert.deepEqual(out.map((x) => x.getDate()), [4, 5])
})
it("respecte UNTIL", () => {
const rule = parseRRule("FREQ=WEEKLY;UNTIL=20260615T000000Z")!
const out = expandOccurrences(
d("2026-06-01T10:00:00"),
rule,
new Set(),
d("2026-06-01T00:00:00"),
d("2026-07-31T23:59:59"),
)
assert.equal(out.length, 2) // 1er et 8 juin (15 juin 10:00 > UNTIL)
})
it("développe BYDAY hebdomadaire en gardant l'heure", () => {
const rule = parseRRule("FREQ=WEEKLY;BYDAY=MO,WE")!
// Lundi 1er juin 2026 à 09:30.
const out = expandOccurrences(
d("2026-06-01T09:30:00"),
rule,
new Set(),
d("2026-06-01T00:00:00"),
d("2026-06-07T23:59:59"),
)
assert.deepEqual(
out.map((x) => [x.getDate(), x.getHours(), x.getMinutes()]),
[
[1, 9, 30],
[3, 9, 30],
],
)
})
it("exclut les EXDATE", () => {
const rule = parseRRule("FREQ=DAILY")!
const out = expandOccurrences(
d("2026-06-01T10:00:00"),
rule,
new Set([d("2026-06-02T10:00:00").getTime()]),
d("2026-06-01T00:00:00"),
d("2026-06-03T23:59:59"),
)
assert.deepEqual(out.map((x) => x.getDate()), [1, 3])
})
it("ne produit aucune occurrence avant le début du master", () => {
const rule = parseRRule("FREQ=WEEKLY;BYDAY=MO,FR")!
// Master mercredi 3 juin 2026 → seul le vendredi 5 reste cette semaine-là.
const out = expandOccurrences(
d("2026-06-03T10:00:00"),
rule,
new Set(),
d("2026-06-01T00:00:00"),
d("2026-06-07T23:59:59"),
)
assert.deepEqual(out.map((x) => x.getDate()), [5])
})
})
describe("describeRRule", () => {
it("décrit les règles courantes", () => {
assert.equal(describeRRule("FREQ=DAILY"), "Tous les jours")
assert.equal(
describeRRule("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"),
"Tous les jours ouvrés (lun.ven.)",
)
assert.equal(describeRRule("FREQ=MONTHLY;INTERVAL=3"), "Tous les 3 mois")
})
})

View File

@ -0,0 +1,182 @@
import { addDays, addMonths, addWeeks, addYears, format, startOfWeek } from "date-fns"
import { fr } from "date-fns/locale"
import { parseICSDate, WEEK_OPTS } from "./agenda-date.ts"
export type RRuleFreq = "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY"
export interface ParsedRRule {
freq: RRuleFreq
interval: number
count?: number
until?: Date
/** Jours (0 = dimanche … 6 = samedi), uniquement pour WEEKLY. */
byday: number[]
}
const BYDAY_TO_INDEX: Record<string, number> = {
SU: 0,
MO: 1,
TU: 2,
WE: 3,
TH: 4,
FR: 5,
SA: 6,
}
const INDEX_TO_BYDAY = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]
export function parseRRule(rrule: string): ParsedRRule | null {
const value = rrule.trim().replace(/^RRULE:/i, "")
if (!value) return null
const parts: Record<string, string> = {}
for (const seg of value.split(";")) {
const [k, v] = seg.split("=")
if (k && v) parts[k.toUpperCase()] = v.toUpperCase()
}
const freq = parts.FREQ as RRuleFreq | undefined
if (!freq || !["DAILY", "WEEKLY", "MONTHLY", "YEARLY"].includes(freq)) return null
const interval = Math.max(1, Number(parts.INTERVAL ?? "1") || 1)
const count = parts.COUNT ? Math.max(1, Number(parts.COUNT) || 1) : undefined
const until = parts.UNTIL ? (parseICSDate(parts.UNTIL) ?? undefined) : undefined
const byday =
freq === "WEEKLY" && parts.BYDAY
? parts.BYDAY.split(",")
.map((d) => BYDAY_TO_INDEX[d.trim()])
.filter((d): d is number => d !== undefined)
: []
return { freq, interval, count, until, byday }
}
/**
* Développe les occurrences d'une règle dans une fenêtre donnée.
* COUNT est compté depuis la première occurrence, même hors fenêtre.
*/
export function expandOccurrences(
masterStart: Date,
rule: ParsedRRule,
exdates: Set<number>,
rangeStart: Date,
rangeEnd: Date,
maxOccurrences = 400,
): Date[] {
const out: Date[] = []
let produced = 0
const push = (occurrence: Date): boolean => {
if (rule.until && occurrence > rule.until) return false
produced++
if (rule.count && produced > rule.count) return false
if (
occurrence >= rangeStart &&
occurrence <= rangeEnd &&
!exdates.has(occurrence.getTime())
) {
out.push(occurrence)
}
return out.length < maxOccurrences
}
if (rule.freq === "WEEKLY" && rule.byday.length > 0) {
const days = [...rule.byday].sort((a, b) => a - b)
let weekStart = startOfWeek(masterStart, WEEK_OPTS)
// Garde-fou : 1 000 semaines ≈ 19 ans.
for (let w = 0; w < 1000; w++) {
for (const day of days) {
// weekStart est un lundi (index 1) — offset vers le jour demandé.
const offset = (day - 1 + 7) % 7
const occurrence = new Date(addDays(weekStart, offset))
occurrence.setHours(
masterStart.getHours(),
masterStart.getMinutes(),
masterStart.getSeconds(),
0,
)
if (occurrence < masterStart) continue
if (!push(occurrence)) return out
}
weekStart = addWeeks(weekStart, rule.interval)
if (weekStart > rangeEnd && (!rule.count || produced > rule.count)) break
if (weekStart > rangeEnd && !rule.count) break
}
return out
}
let occurrence = new Date(masterStart)
for (let i = 0; i < 5000; i++) {
if (!push(occurrence)) return out
switch (rule.freq) {
case "DAILY":
occurrence = addDays(occurrence, rule.interval)
break
case "WEEKLY":
occurrence = addWeeks(occurrence, rule.interval)
break
case "MONTHLY":
occurrence = addMonths(occurrence, rule.interval)
break
case "YEARLY":
occurrence = addYears(occurrence, rule.interval)
break
}
if (occurrence > rangeEnd && !rule.count && !rule.until) break
if (occurrence > rangeEnd && rule.until && rule.until <= rangeEnd) break
}
return out
}
export interface RecurrenceOption {
value: string
label: string
}
/** Options de récurrence proposées dans l'éditeur, dépendantes de la date. */
export function recurrenceOptionsFor(start: Date): RecurrenceOption[] {
const weekday = format(start, "EEEE", { locale: fr })
const dayOfMonth = format(start, "d", { locale: fr })
const monthDay = format(start, "d MMMM", { locale: fr })
return [
{ value: "", label: "Ne se répète pas" },
{ value: "FREQ=DAILY", label: "Tous les jours" },
{
value: `FREQ=WEEKLY;BYDAY=${INDEX_TO_BYDAY[start.getDay()]}`,
label: `Toutes les semaines le ${weekday}`,
},
{ value: "FREQ=MONTHLY", label: `Tous les mois le ${dayOfMonth}` },
{ value: "FREQ=YEARLY", label: `Tous les ans le ${monthDay}` },
{
value: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
label: "Tous les jours ouvrés (lun.ven.)",
},
]
}
const FREQ_LABELS: Record<RRuleFreq, [string, string]> = {
DAILY: ["Tous les jours", "Tous les {n} jours"],
WEEKLY: ["Toutes les semaines", "Toutes les {n} semaines"],
MONTHLY: ["Tous les mois", "Tous les {n} mois"],
YEARLY: ["Tous les ans", "Tous les {n} ans"],
}
const DAY_LABELS = ["dim.", "lun.", "mar.", "mer.", "jeu.", "ven.", "sam."]
/** Libellé français d'une règle de récurrence arbitraire. */
export function describeRRule(rrule: string): string {
const rule = parseRRule(rrule)
if (!rule) return "Récurrence personnalisée"
const [singular, plural] = FREQ_LABELS[rule.freq]
let label =
rule.interval === 1 ? singular : plural.replace("{n}", String(rule.interval))
if (rule.freq === "WEEKLY" && rule.byday.length > 0) {
const sorted = [...rule.byday].sort((a, b) => ((a + 6) % 7) - ((b + 6) % 7))
if (sorted.length === 5 && sorted.every((d) => d >= 1 && d <= 5)) {
label = "Tous les jours ouvrés (lun.ven.)"
} else {
label += ` le ${sorted.map((d) => DAY_LABELS[d]).join(", ")}`
}
}
if (rule.count) label += `, ${rule.count} fois`
if (rule.until) {
label += `, jusqu'au ${format(rule.until, "d MMM yyyy", { locale: fr })}`
}
return label
}

View File

@ -0,0 +1,50 @@
"use client"
import { create } from "zustand"
import { persist } from "zustand/middleware"
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
import type { AgendaView } from "./agenda-url.ts"
interface AgendaSettingsState {
/** Agendas décochés dans la barre latérale. */
hiddenCalendarIds: string[]
lastView: AgendaView
toggleCalendarVisible: (id: string) => void
setLastView: (view: AgendaView) => void
}
export const useAgendaSettingsStore = create<AgendaSettingsState>()(
persist(
(set, get) => ({
hiddenCalendarIds: [],
lastView: "week",
toggleCalendarVisible: (id) => {
const hidden = get().hiddenCalendarIds
set({
hiddenCalendarIds: hidden.includes(id)
? hidden.filter((h) => h !== id)
: [...hidden, id],
})
},
setLastView: (lastView) => set({ lastView }),
}),
{
name: "agenda-settings-store",
storage: debouncedPersistJSONStorage,
partialize: (s) => ({
hiddenCalendarIds: s.hiddenCalendarIds,
lastView: s.lastView,
}),
},
),
)
interface AgendaUIState {
sidebarCollapsed: boolean
setSidebarCollapsed: (v: boolean) => void
}
export const useAgendaUIStore = create<AgendaUIState>((set) => ({
sidebarCollapsed: false,
setSidebarCollapsed: (sidebarCollapsed) => set({ sidebarCollapsed }),
}))

View File

@ -0,0 +1,81 @@
export interface AgendaCalendar {
id: string
display_name: string
color: string
path: string
}
export interface AgendaEventAttendee {
email: string
name?: string
/** PARTSTAT : NEEDS-ACTION | ACCEPTED | DECLINED | TENTATIVE */
status?: string
role?: string
}
/** Événement tel que renvoyé par l'API (`/calendar/{id}/events`). */
export interface AgendaApiEvent {
uid: string
summary: string
description: string
location: string
/** ICS : `YYYYMMDD` (journée entière) ou `YYYYMMDDTHHMMSSZ` (UTC). */
start: string
end: string
all_day: boolean
path?: string
etag?: string
organizer?: string
attendees?: AgendaEventAttendee[]
meet_url?: string
color?: string
rrule?: string
exdates?: string[]
raw_ics?: string
}
export interface AgendaEventsResponse {
events: AgendaApiEvent[]
}
export interface AgendaCalendarsResponse {
calendars: AgendaCalendar[]
}
/** Occurrence affichable (événement simple ou instance d'une récurrence). */
export interface AgendaEvent {
/** Clé unique d'occurrence : `path@startMs`. */
key: string
calendarId: string
path: string
etag: string
uid: string
title: string
description: string
location: string
meetUrl: string
organizer: string
attendees: AgendaEventAttendee[]
start: Date
end: Date
allDay: boolean
/** Couleur résolue : événement sinon agenda. */
color: string
rrule: string
recurring: boolean
/** Données API du master, pour l'édition aller-retour. */
master: AgendaApiEvent
}
export interface AgendaEventDraft {
title: string
start: Date
end: Date
allDay: boolean
calendarId: string
description?: string
location?: string
attendees?: AgendaEventAttendee[]
rrule?: string
color?: string
}

32
lib/agenda/agenda-url.ts Normal file
View File

@ -0,0 +1,32 @@
import { dateKey, parseDateKey } from "./agenda-date.ts"
export type AgendaView = "day" | "week" | "month"
export const AGENDA_VIEWS: AgendaView[] = ["day", "week", "month"]
export const AGENDA_VIEW_LABELS: Record<AgendaView, string> = {
day: "Jour",
week: "Semaine",
month: "Mois",
}
export interface AgendaRoute {
view: AgendaView | null
date: Date
}
function isAgendaView(value: string): value is AgendaView {
return (AGENDA_VIEWS as string[]).includes(value)
}
/** `/agenda[/{view}[/{yyyy-MM-dd}]]` — view null = laisser le choix au client. */
export function parseAgendaSegments(segments?: string[]): AgendaRoute {
const [rawView, rawDate] = segments ?? []
const view = rawView && isAgendaView(rawView) ? rawView : null
const date = (rawDate ? parseDateKey(rawDate) : null) ?? new Date()
return { view, date }
}
export function buildAgendaPath(view: AgendaView, date: Date): string {
return `/agenda/${view}/${dateKey(date)}`
}

View File

@ -0,0 +1,158 @@
"use client"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { apiClient } from "@/lib/api/client"
import { formatICSDateOnly, formatICSDateTimeUTC } from "@/lib/agenda/agenda-date"
import type { AgendaApiEvent, AgendaEventDraft } from "@/lib/agenda/agenda-types"
function eventApiPath(davPath: string): string {
return `/calendar/events/${davPath.replace(/^\/+/, "")}`
}
/** Convertit un brouillon UI en payload API (dates ICS). */
export function draftToApiEvent(
draft: AgendaEventDraft,
existing?: AgendaApiEvent,
): Partial<AgendaApiEvent> {
const start = draft.allDay
? formatICSDateOnly(draft.start)
: formatICSDateTimeUTC(draft.start)
const end = draft.allDay
? formatICSDateOnly(draft.end)
: formatICSDateTimeUTC(draft.end)
return {
uid: existing?.uid,
summary: draft.title.trim() || "(Sans titre)",
description: draft.description ?? existing?.description ?? "",
location: draft.location ?? existing?.location ?? "",
start,
end,
all_day: draft.allDay,
attendees: draft.attendees ?? existing?.attendees ?? [],
organizer: existing?.organizer,
meet_url: existing?.meet_url,
color: draft.color ?? existing?.color,
rrule: draft.rrule ?? existing?.rrule ?? "",
exdates: existing?.exdates,
}
}
function useInvalidateAgenda() {
const queryClient = useQueryClient()
return () => {
void queryClient.invalidateQueries({ queryKey: ["agenda", "events"] })
}
}
export function useCreateAgendaEvent() {
const invalidate = useInvalidateAgenda()
return useMutation({
mutationFn: ({
calendarId,
event,
}: {
calendarId: string
event: Partial<AgendaApiEvent>
}) =>
apiClient.post(`/calendar/${encodeURIComponent(calendarId)}/events`, event),
onSuccess: invalidate,
})
}
export function useUpdateAgendaEvent() {
const invalidate = useInvalidateAgenda()
return useMutation({
mutationFn: ({
path,
etag,
event,
}: {
path: string
etag: string
event: Partial<AgendaApiEvent>
}) =>
apiClient.put<{ etag: string }>(eventApiPath(path), event, {
"If-Match": etag || "*",
}),
onSuccess: invalidate,
})
}
export function useDeleteAgendaEvent() {
const invalidate = useInvalidateAgenda()
return useMutation({
mutationFn: ({ path }: { path: string }) => apiClient.delete(eventApiPath(path)),
onSuccess: invalidate,
})
}
export function useRespondAgendaInvitation() {
const invalidate = useInvalidateAgenda()
return useMutation({
mutationFn: ({
path,
response,
etag,
}: {
path: string
response: "accepted" | "declined" | "tentative"
etag?: string
}) =>
apiClient.post<{ etag: string }>(
`/calendar/events/response/${path.replace(/^\/+/, "")}`,
{ response, if_match: etag ?? "" },
),
onSuccess: invalidate,
})
}
export function useCreateAgendaMeetLink() {
const invalidate = useInvalidateAgenda()
return useMutation({
mutationFn: ({ path, etag }: { path: string; etag?: string }) =>
apiClient.post<{ meet_url: string; etag: string }>(
`/calendar/events/meet-link/${path.replace(/^\/+/, "")}`,
{ if_match: etag ?? "" },
),
onSuccess: invalidate,
})
}
export function useCreateAgendaCalendar() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (input: { display_name: string; color?: string }) =>
apiClient.post<{ id: string }>("/calendar", input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["agenda", "calendars"] })
},
})
}
export function useUpdateAgendaCalendar() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({
id,
...input
}: {
id: string
display_name?: string
color?: string
}) => apiClient.patch(`/calendar/${encodeURIComponent(id)}`, input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["agenda"] })
},
})
}
export function useDeleteAgendaCalendar() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id }: { id: string }) =>
apiClient.delete(`/calendar/${encodeURIComponent(id)}`),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["agenda"] })
},
})
}

View File

@ -0,0 +1,77 @@
"use client"
import { useMemo } from "react"
import { useQueries, useQuery } from "@tanstack/react-query"
import { format } from "date-fns"
import { apiClient } from "@/lib/api/client"
import { useAuthReady } from "@/lib/api/use-auth-ready"
import { expandApiEvents } from "@/lib/agenda/agenda-events"
import type {
AgendaCalendar,
AgendaCalendarsResponse,
AgendaEvent,
AgendaEventsResponse,
} from "@/lib/agenda/agenda-types"
export const agendaCalendarsKey = ["agenda", "calendars"] as const
export function agendaEventsKey(calendarId: string, from: string, to: string) {
return ["agenda", "events", calendarId, from, to] as const
}
export function useAgendaCalendars() {
const { ready, authenticated } = useAuthReady()
return useQuery({
queryKey: agendaCalendarsKey,
enabled: ready && authenticated,
staleTime: 5 * 60_000,
queryFn: async () => {
const res = await apiClient.get<AgendaCalendarsResponse>("/calendar")
return res.calendars ?? []
},
})
}
/**
* Charge et développe les événements de plusieurs agendas sur une fenêtre.
* La fenêtre est élargie au mois pour profiter du cache entre vues.
*/
export function useAgendaEvents(
calendars: AgendaCalendar[],
rangeStart: Date,
rangeEnd: Date,
) {
const { ready, authenticated } = useAuthReady()
const from = format(rangeStart, "yyyy-MM-dd")
const to = format(rangeEnd, "yyyy-MM-dd")
const results = useQueries({
queries: calendars.map((cal) => ({
queryKey: agendaEventsKey(cal.id, from, to),
enabled: ready && authenticated,
staleTime: 30_000,
queryFn: async () => {
const res = await apiClient.get<AgendaEventsResponse>(
`/calendar/${encodeURIComponent(cal.id)}/events`,
{ from, to, page_size: "500" },
)
return res.events ?? []
},
})),
})
const isLoading = results.some((r) => r.isLoading)
const isError = results.every((r) => r.isError) && results.length > 0
const events: AgendaEvent[] = useMemo(() => {
const all: AgendaEvent[] = []
calendars.forEach((cal, i) => {
const data = results[i]?.data
if (data) all.push(...expandApiEvents(cal, data, rangeStart, rangeEnd))
})
return all.sort((a, b) => a.start.getTime() - b.start.getTime())
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [calendars, rangeStart.getTime(), rangeEnd.getTime(), ...results.map((r) => r.data)])
return { events, isLoading, isError }
}

View File

@ -0,0 +1,18 @@
/**
* URLs de l'interface utilisateur Authentik, dérivées de l'issuer OIDC.
* Issuer attendu : `https://host/auth/application/o/<slug>/` base `https://host/auth`.
*/
const OIDC_ISSUER = process.env.NEXT_PUBLIC_OIDC_ISSUER ?? ""
function authentikBaseUrl(): string | null {
const idx = OIDC_ISSUER.indexOf("/application/")
if (idx === -1) return null
return OIDC_ISSUER.slice(0, idx)
}
/** Page « Paramètres » du portail utilisateur Authentik (mot de passe, MFA, sessions). */
export function authentikUserSettingsUrl(): string | null {
const base = authentikBaseUrl()
return base ? `${base}/if/user/#/settings` : null
}

View File

@ -0,0 +1,59 @@
import type { LucideIcon } from "lucide-react"
import { Home, ShieldCheck, UserRound } from "lucide-react"
export type CompteSettingsSectionId = "home" | "personal-info" | "security"
export type CompteSettingsNavItem = {
id: CompteSettingsSectionId
label: string
description: string
href: string
icon: LucideIcon
}
export const COMPTE_SETTINGS_NAV: CompteSettingsNavItem[] = [
{
id: "home",
label: "Accueil",
description: "Vue d'ensemble de votre compte Ulti",
href: "/compte",
icon: Home,
},
{
id: "personal-info",
label: "Informations personnelles",
description: "Nom, adresse e-mail et identifiant",
href: "/compte/informations",
icon: UserRound,
},
{
id: "security",
label: "Sécurité",
description: "Mot de passe, sessions et appareils",
href: "/compte/securite",
icon: ShieldCheck,
},
]
export function isCompteSettingsNavActive(
pathname: string | null,
item: CompteSettingsNavItem
): boolean {
if (item.href === "/compte") {
return pathname === "/compte" || pathname === "/compte/accueil"
}
return (
pathname === item.href || Boolean(pathname?.startsWith(`${item.href}/`))
)
}
export function resolveCompteSettingsSection(
segments: string[] | undefined
): CompteSettingsSectionId {
const slug = segments?.[0]
const match = COMPTE_SETTINGS_NAV.find((item) => {
if (item.id === "home") return !slug || slug === "accueil"
return item.href.endsWith(`/${slug}`)
})
return match?.id ?? "home"
}

View File

@ -11,8 +11,16 @@ export type FavoriteApp = {
} }
export const SUITE_FAVORITE_APPS: FavoriteApp[] = [ export const SUITE_FAVORITE_APPS: FavoriteApp[] = [
{ name: "Compte", icon: suitePublicAsset("/compte-mark.svg") }, {
{ name: "Agenda", icon: suitePublicAsset("/agenda-mark.svg") }, name: "Compte",
icon: suitePublicAsset("/compte-mark.svg"),
href: "/compte",
},
{
name: "Agenda",
icon: suitePublicAsset("/agenda-mark.svg"),
href: "/agenda",
},
{ name: "Photos", icon: suitePublicAsset("/photos-mark.svg") }, { name: "Photos", icon: suitePublicAsset("/photos-mark.svg") },
{ {
name: "Ultimail", name: "Ultimail",

View File

@ -2,7 +2,7 @@ import type { Metadata } from "next"
import { displayFileName } from "@/lib/drive/display-file-name" import { displayFileName } from "@/lib/drive/display-file-name"
import { parseDriveSegments } from "@/lib/drive/drive-url" import { parseDriveSegments } from "@/lib/drive/drive-url"
export type SuiteApp = "mail" | "drive" | "contacts" | "admin" | "suite" export type SuiteApp = "mail" | "drive" | "contacts" | "agenda" | "admin" | "compte" | "suite"
/** Separator between page segment and product name in document titles. */ /** Separator between page segment and product name in document titles. */
export const SUITE_TITLE_SEP = " - " export const SUITE_TITLE_SEP = " - "
@ -13,7 +13,9 @@ const DESCRIPTIONS: Record<SuiteApp, string> = {
mail: "Client mail Ultimail — suite souveraine", mail: "Client mail Ultimail — suite souveraine",
drive: "Stockage de fichiers UltiDrive — suite UltiSuite", drive: "Stockage de fichiers UltiDrive — suite UltiSuite",
contacts: "Carnet d'adresses — UltiSuite", contacts: "Carnet d'adresses — UltiSuite",
agenda: "Agenda partagé, invitations et disponibilités — UltiSuite",
admin: "Console d'administration — UltiSuite", admin: "Console d'administration — UltiSuite",
compte: "Réglages du compte — UltiSuite",
suite: "Ultimail, UltiDrive et contacts — interface suite unifiée", suite: "Ultimail, UltiDrive et contacts — interface suite unifiée",
} }
@ -21,7 +23,9 @@ const APP_LABELS: Record<SuiteApp, string> = {
mail: "Ultimail", mail: "Ultimail",
drive: "UltiDrive", drive: "UltiDrive",
contacts: "UltiSuite", contacts: "UltiSuite",
agenda: "Agenda",
admin: "Administration", admin: "Administration",
compte: "Compte Ulti",
suite: "UltiSuite", suite: "UltiSuite",
} }
@ -64,6 +68,19 @@ const ICONS: Record<SuiteApp, Metadata["icons"]> = {
apple: [{ url: "/contacts-mark.svg", type: "image/svg+xml" }], apple: [{ url: "/contacts-mark.svg", type: "image/svg+xml" }],
shortcut: "/contacts-mark.svg", shortcut: "/contacts-mark.svg",
}, },
compte: {
icon: [{ url: "/compte-mark.svg", type: "image/svg+xml" }],
apple: [{ url: "/compte-mark.svg", type: "image/svg+xml" }],
shortcut: "/compte-mark.svg",
},
agenda: {
icon: [
{ url: "/agenda-mark.svg", type: "image/svg+xml" },
{ url: "/icon.png", sizes: "32x32", type: "image/png" },
],
apple: [{ url: "/agenda-mark.svg", type: "image/svg+xml" }],
shortcut: "/agenda-mark.svg",
},
} }
type PageMetadataOptions = { type PageMetadataOptions = {

File diff suppressed because one or more lines are too long