ultisuite-client/components/agenda/agenda-event-schedule-fields.tsx
R3D347HR4Y ad1370ea7e
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat: enhance configuration and add new demo layouts
- Introduced turbopack alias for canvas in next.config.mjs.
- Updated package.json scripts for development and branding tasks.
- Added new dependencies for Tiptap extensions.
- Implemented new demo layouts for agenda, contacts, drive, and mail applications.
- Enhanced globals.css for improved theming and splash screen animations.
- Added OAuth callback handling for drive mounts.
- Updated layout components to integrate new demo shells and improve structure.
2026-06-12 19:10:24 +02:00

404 lines
12 KiB
TypeScript

"use client"
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react"
import { addHours } from "date-fns"
import {
FocusableStepValue,
handleStepAdjustKeyDown,
StepAdjustGroup,
StepAdjustTimeDecreaseButton,
StepAdjustTimeIncreaseButton,
} from "@/components/agenda/agenda-step-adjust"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
import { formatAgendaStepDurationMinutes } from "@/lib/agenda/agenda-date"
import type { AgendaDurationStep } from "@/lib/agenda/agenda-settings-types"
import { cn } from "@/lib/utils"
function toDateInput(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, "0")
const day = String(d.getDate()).padStart(2, "0")
return `${y}-${m}-${day}`
}
function toTimeInput(d: Date): string {
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
}
function fromDateAndTime(date: string, time: string): Date | null {
const [y, mo, d] = date.split("-").map(Number)
const [h, mi] = time.split(":").map(Number)
if (![y, mo, d, h, mi].every(Number.isFinite)) return null
const parsed = new Date(y, mo - 1, d, h, mi, 0, 0)
return Number.isNaN(parsed.getTime()) ? null : parsed
}
function tabAt(base: number | undefined, offset: number): number | undefined {
return base !== undefined ? base + offset : undefined
}
const AGENDA_STEP_GROUP_CLASS =
"w-fit gap-0 border-border/70 bg-mail-surface px-0.5 py-0.5"
const AGENDA_STEP_BUTTON_CLASS = "size-6 shrink-0 [&_svg]:size-3.5"
const AGENDA_STEP_VALUE_SLOT_CLASS =
"flex h-8 w-[3.875rem] min-w-[3.875rem] max-w-[3.875rem] shrink-0 items-center justify-center"
const AGENDA_STEP_VALUE_CLASS =
"w-full min-w-0 px-0 text-center text-sm leading-none tabular-nums"
const AGENDA_TIME_INPUT_CLASS = cn(
AGENDA_STEP_VALUE_CLASS,
"h-full border-0 bg-transparent font-medium shadow-none focus-visible:ring-0",
"[&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none",
"[&::-webkit-datetime-edit]:m-0 [&::-webkit-datetime-edit]:p-0 [&::-webkit-datetime-edit]:text-center",
"[&::-webkit-datetime-edit-fields-wrapper]:flex [&::-webkit-datetime-edit-fields-wrapper]:w-full [&::-webkit-datetime-edit-fields-wrapper]:items-center [&::-webkit-datetime-edit-fields-wrapper]:justify-center",
"[&::-webkit-datetime-edit-hour-field]:p-0 [&::-webkit-datetime-edit-minute-field]:p-0 [&::-webkit-datetime-edit-text]:p-0",
)
const AGENDA_DURATION_VALUE_CLASS = "text-center text-sm font-medium leading-none tabular-nums"
function ScheduleLabeledRow({
label,
children,
}: {
label: string
children: ReactNode
}) {
return (
<div className="flex items-center gap-2">
<span className="w-8 shrink-0 text-center text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{label}
</span>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">{children}</div>
</div>
)
}
function AgendaStepTimeInput({
value,
tabIndex,
onChange,
onKeyDown,
}: {
value: string
tabIndex?: number
onChange: (value: string) => void
onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => void
}) {
return (
<div className={AGENDA_STEP_VALUE_SLOT_CLASS}>
<Input
type="time"
value={value}
tabIndex={tabIndex}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
className={AGENDA_TIME_INPUT_CLASS}
/>
</div>
)
}
/** Nombre de tab stops internes (base inclusif → dernier = base + count - 1). */
export function agendaScheduleFieldCount(options: {
allDay: boolean
showAllDayToggle: boolean
compact: boolean
}): number {
if (options.compact) {
if (options.allDay) return 1
return 3
}
if (options.allDay) {
return options.showAllDayToggle ? 3 : 2
}
return options.showAllDayToggle ? 6 : 5
}
export function AgendaEventScheduleFields({
start,
end,
allDay,
stepMinutes,
onChange,
showAllDayToggle = false,
onAllDayChange,
compact = false,
showRowLabels = false,
className,
tabIndexBase,
}: {
start: Date
end: Date
allDay: boolean
stepMinutes: AgendaDurationStep
onChange: (start: Date, end: Date) => void
showAllDayToggle?: boolean
onAllDayChange?: (allDay: boolean) => void
compact?: boolean
/** Modale avancée : libellés Début / Fin à gauche des deux lignes. */
showRowLabels?: boolean
className?: string
tabIndexBase?: number
}) {
const startDate = toDateInput(start)
const endDate = toDateInput(end)
const startTime = toTimeInput(start)
const endTime = toTimeInput(end)
const [endDateOpen, setEndDateOpen] = useState(false)
const endDateInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
setEndDateOpen(false)
}, [startDate, endDate, allDay, compact])
useEffect(() => {
if (!endDateOpen) return
const timer = window.setTimeout(() => endDateInputRef.current?.focus(), 0)
return () => window.clearTimeout(timer)
}, [endDateOpen])
const durationMinutes = Math.max(
stepMinutes,
Math.round((end.getTime() - start.getTime()) / 60_000),
)
const showEndDateField = startDate !== endDate || endDateOpen
const apply = (nextStart: Date, nextEnd: Date) => {
if (nextEnd.getTime() <= nextStart.getTime()) {
onChange(nextStart, addHours(nextStart, 1))
return
}
onChange(nextStart, nextEnd)
}
const patchStartDate = (date: string) => {
const nextStart = fromDateAndTime(date, allDay ? "00:00" : startTime) ?? start
let nextEnd = end
if (date > endDate) nextEnd = fromDateAndTime(date, allDay ? "00:00" : endTime) ?? nextStart
apply(nextStart, nextEnd)
}
const patchEndDate = (date: string) => {
const nextEnd = fromDateAndTime(date, allDay ? "00:00" : endTime) ?? end
apply(start, nextEnd)
}
const patchStartTime = (time: string) => {
const nextStart = fromDateAndTime(startDate, time)
if (!nextStart) return
apply(nextStart, end.getTime() <= nextStart.getTime() ? addHours(nextStart, 1) : end)
}
const patchEndTime = (time: string) => {
const nextEnd = fromDateAndTime(endDate, time)
if (!nextEnd) return
apply(start, nextEnd.getTime() <= start.getTime() ? addHours(start, 1) : nextEnd)
}
const shiftStart = (deltaMinutes: number) => {
const nextStart = new Date(start.getTime() + deltaMinutes * 60_000)
const durationMs = end.getTime() - start.getTime()
apply(nextStart, new Date(nextStart.getTime() + durationMs))
}
const shiftEnd = (deltaMinutes: number) => {
const nextEnd = new Date(end.getTime() + deltaMinutes * 60_000)
if (nextEnd.getTime() <= start.getTime()) return
apply(start, nextEnd)
}
const shiftDuration = (deltaMinutes: number) => {
const nextDuration = Math.max(stepMinutes, durationMinutes + deltaMinutes)
apply(start, new Date(start.getTime() + nextDuration * 60_000))
}
const startDateInput = (
<Input
type="date"
value={startDate}
tabIndex={tabAt(tabIndexBase, 0)}
onChange={(e) => patchStartDate(e.target.value)}
className="h-9 w-fit"
/>
)
const startTimeControl = (
<StepAdjustGroup className={AGENDA_STEP_GROUP_CLASS}>
<StepAdjustTimeDecreaseButton
onClick={() => shiftStart(-stepMinutes)}
label={`Reculer le début de ${stepMinutes} minutes`}
className={AGENDA_STEP_BUTTON_CLASS}
/>
<AgendaStepTimeInput
value={startTime}
tabIndex={tabAt(tabIndexBase, 1)}
onChange={patchStartTime}
onKeyDown={(e) =>
handleStepAdjustKeyDown(
e,
() => shiftStart(-stepMinutes),
() => shiftStart(stepMinutes),
)
}
/>
<StepAdjustTimeIncreaseButton
onClick={() => shiftStart(stepMinutes)}
label={`Avancer le début de ${stepMinutes} minutes`}
className={AGENDA_STEP_BUTTON_CLASS}
/>
</StepAdjustGroup>
)
const endTimeControl = (
<StepAdjustGroup className={AGENDA_STEP_GROUP_CLASS}>
<StepAdjustTimeDecreaseButton
onClick={() => shiftEnd(-stepMinutes)}
label={`Reculer la fin de ${stepMinutes} minutes`}
className={AGENDA_STEP_BUTTON_CLASS}
/>
<AgendaStepTimeInput
value={endTime}
tabIndex={tabAt(tabIndexBase, 3)}
onChange={patchEndTime}
onKeyDown={(e) =>
handleStepAdjustKeyDown(
e,
() => shiftEnd(-stepMinutes),
() => shiftEnd(stepMinutes),
)
}
/>
<StepAdjustTimeIncreaseButton
onClick={() => shiftEnd(stepMinutes)}
label={`Avancer la fin de ${stepMinutes} minutes`}
className={AGENDA_STEP_BUTTON_CLASS}
/>
</StepAdjustGroup>
)
const durationControl = (
<FocusableStepValue
tabIndex={tabAt(tabIndexBase, 2)}
value={formatAgendaStepDurationMinutes(durationMinutes)}
ariaLabel="Durée"
onDecrease={() => shiftDuration(-stepMinutes)}
onIncrease={() => shiftDuration(stepMinutes)}
decreaseDisabled={durationMinutes <= stepMinutes}
increaseDisabled={false}
decreaseLabel={`Réduire la durée de ${stepMinutes} minutes`}
increaseLabel={`Augmenter la durée de ${stepMinutes} minutes`}
className={AGENDA_STEP_GROUP_CLASS}
buttonClassName={AGENDA_STEP_BUTTON_CLASS}
valueWrapperClassName={AGENDA_STEP_VALUE_SLOT_CLASS}
valueClassName={AGENDA_DURATION_VALUE_CLASS}
/>
)
const endTimeLabel = (
<span
className="flex h-8 items-center text-xs tabular-nums text-foreground/80"
aria-label={`Fin à ${endTime}`}
>
{endTime}
</span>
)
const endDateControl = showEndDateField ? (
<Input
ref={endDateInputRef}
type="date"
value={endDate}
min={startDate}
tabIndex={tabAt(tabIndexBase, allDay ? 1 : 4)}
onChange={(e) => patchEndDate(e.target.value)}
className="h-9 w-fit"
/>
) : (
<Button
type="button"
variant="outline"
tabIndex={tabAt(tabIndexBase, allDay ? 1 : 4)}
className="h-9 rounded-full px-3 text-sm font-normal"
onClick={() => setEndDateOpen(true)}
>
Ajouter une date de fin
</Button>
)
const allDayToggle =
showAllDayToggle && onAllDayChange ? (
<label className="flex w-fit cursor-pointer items-center gap-2 text-sm text-foreground/80">
<Switch
checked={allDay}
tabIndex={tabAt(tabIndexBase, allDay ? 2 : 5)}
onCheckedChange={onAllDayChange}
/>
Toute la journée
</label>
) : null
if (compact && allDay) {
return (
<div className={cn("flex flex-col items-start gap-2", className)}>
<div className="flex flex-wrap items-center gap-2">
{startDateInput}
{startDate !== endDate ? endDateControl : null}
</div>
<span className="text-sm text-muted-foreground">Toute la journée</span>
</div>
)
}
if (compact) {
return (
<div className={cn("flex flex-col items-start gap-2", className)}>
<div className="flex flex-wrap items-center gap-2">
{startDateInput}
{startTimeControl}
</div>
<div className="flex flex-wrap items-center gap-2">
{durationControl}
{endTimeLabel}
</div>
</div>
)
}
const scheduleRow = (label: string | null, children: ReactNode) =>
showRowLabels && label ? (
<ScheduleLabeledRow label={label}>{children}</ScheduleLabeledRow>
) : (
<div className="flex flex-wrap items-center gap-2">{children}</div>
)
if (allDay) {
return (
<div className={cn("flex flex-col gap-2", className)}>
{scheduleRow("Début", startDateInput)}
{scheduleRow("Fin", endDateControl)}
{allDayToggle}
</div>
)
}
return (
<div className={cn("flex flex-col gap-2", className)}>
{scheduleRow("Début", (
<>
{startDateInput}
{startTimeControl}
</>
))}
{scheduleRow("Fin", (
<>
{endDateControl}
{endTimeControl}
{durationControl}
</>
))}
{allDayToggle}
</div>
)
}