Gantt

4 blocks

Gantt 01

Preview
npx nateui@latest add GanttProjectPlan
Dark
import { useCallback, useMemo, useState } from 'react'
import {
    PiCalendarBlank,
    PiCopy,
    PiFlag,
    PiFunnel,
    PiLink,
    PiPencilSimple,
    PiPlus,
    PiTrash,
    PiX,
} from 'react-icons/pi'
import Gantt, {
    ViewMode,
    type BarTask,
    type Columns,
    type Task,
} from '@/components/composites/Gantt'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import Checkbox from '@/components/ui/Checkbox'
import DatePicker from '@/components/ui/DatePicker'
import Dialog from '@/components/ui/Dialog'
import Dropdown from '@/components/ui/Dropdown'
import Input from '@/components/ui/Input'
import Notification from '@/components/ui/Notification'
import PopoverFilter from '@/components/composites/PopoverFilter'
import Progress from '@/components/ui/Progress'
import Select from '@/components/ui/Select'
import toast from '@/components/ui/toast'

const assetBase = 'https://statics.nateui.com/img'
const DAY_IN_MS = 24 * 60 * 60 * 1000

type PlanOwner = {
    alt: string
    src: string
}

type TaskStatus = 'Not started' | 'In progress' | 'Complete'
type TaskPriority = 'Low' | 'Normal' | 'High' | 'Urgent'
type TaskDialogMode = 'add' | 'edit'

type PlanTaskMeta = {
    owners?: PlanOwner[]
    priority?: TaskPriority
}

const owners = {
    one: { alt: 'Alice Yang', src: `${assetBase}/avatars/thumb-1.jpg` },
    two: { alt: 'Bob Lee', src: `${assetBase}/avatars/thumb-2.jpg` },
    three: { alt: 'Charlie Chen', src: `${assetBase}/avatars/thumb-3.jpg` },
} satisfies Record<string, PlanOwner>

const ownerOptions = Object.values(owners).map((owner) => ({
    label: owner.alt,
    value: owner.src,
    owner,
}))

const today = new Date()
today.setHours(0, 0, 0, 0)

const dateFromToday = (offsetDays: number) => {
    const date = new Date(today)
    date.setDate(date.getDate() + offsetDays)
    return date
}

const INITIAL_TASKS: Task<PlanTaskMeta>[] = [
    {
        id: 'discovery',
        name: 'Discovery',
        type: 'project',
        start: dateFromToday(-8),
        end: dateFromToday(6),
        progress: 68,
        hideChildren: false,
        displayOrder: 1,
        styles: {
            progressClass: 'fill-white/20',
            wrapperClass: 'fill-palette-cyan',
        },
    },
    {
        id: 'scope-outline',
        name: 'Scope outline',
        type: 'task',
        project: 'discovery',
        start: dateFromToday(-8),
        end: dateFromToday(-3),
        progress: 100,
        owners: [owners.one, owners.two],
        displayOrder: 2,
        styles: {
            progressClass: 'fill-white/20',
            wrapperClass: 'fill-palette-cyan',
        },
    },
    {
        id: 'stakeholder-interviews',
        name: 'Stakeholder interviews',
        type: 'task',
        project: 'discovery',
        start: dateFromToday(-6),
        end: dateFromToday(0),
        progress: 72,
        owners: [owners.two, owners.three],
        displayOrder: 3,
        styles: {
            progressClass: 'fill-white/20',
            wrapperClass: 'fill-palette-emerald',
        },
    },
    {
        id: 'discovery-checkpoint',
        name: 'Discovery checkpoint',
        type: 'milestone',
        project: 'discovery',
        start: dateFromToday(6),
        end: dateFromToday(6),
        progress: 0,
        displayOrder: 4,
        styles: {
            progressClass: 'fill-white/20',
            wrapperClass: 'fill-palette-orange',
        },
    },
    {
        id: 'design',
        name: 'Design',
        type: 'project',
        start: dateFromToday(0),
        end: dateFromToday(19),
        progress: 46,
        hideChildren: false,
        displayOrder: 5,
        styles: {
            progressClass: 'fill-white/20',
            wrapperClass: 'fill-palette-purple',
        },
    },
    {
        id: 'wireframes',
        name: 'Wireframes',
        type: 'task',
        project: 'design',
        start: dateFromToday(0),
        end: dateFromToday(5),
        progress: 100,
        owners: [owners.one, owners.three],
        displayOrder: 6,
        styles: {
            progressClass: 'fill-white/20',
            wrapperClass: 'fill-palette-blue',
        },
    },
    {
        id: 'interaction-review',
        name: 'Interaction review',
        type: 'task',
        project: 'design',
        start: dateFromToday(2),
        end: dateFromToday(8),
        progress: 38,
        owners: [owners.three],
        displayOrder: 7,
        styles: {
            progressClass: 'fill-white/20',
            wrapperClass: 'fill-palette-red',
        },
    },
    {
        id: 'prototype-handoff',
        name: 'Prototype handoff',
        type: 'task',
        project: 'design',
        start: dateFromToday(6),
        end: dateFromToday(9),
        progress: 18,
        owners: [owners.one, owners.two],
        displayOrder: 8,
        styles: {
            progressClass: 'fill-white/20',
            wrapperClass: 'fill-palette-yellow',
        },
    },
    {
        id: 'delivery',
        name: 'Delivery',
        type: 'project',
        start: dateFromToday(11),
        end: dateFromToday(21),
        progress: 22,
        hideChildren: false,
        displayOrder: 9,
        styles: {
            progressClass: 'fill-white/20',
            wrapperClass: 'fill-palette-orange',
        },
    },
    {
        id: 'build-frontend',
        name: 'Build frontend',
        type: 'task',
        project: 'delivery',
        start: dateFromToday(11),
        end: dateFromToday(17),
        progress: 30,
        owners: [owners.one, owners.two, owners.three],
        displayOrder: 10,
        styles: {
            progressClass: 'fill-white/20',
            wrapperClass: 'fill-palette-cyan',
        },
    },
    {
        id: 'integration-check',
        name: 'Integration check',
        type: 'task',
        project: 'delivery',
        start: dateFromToday(14),
        end: dateFromToday(19),
        progress: 8,
        owners: [owners.two, owners.three],
        displayOrder: 11,
        styles: {
            progressClass: 'fill-white/20',
            wrapperClass: 'fill-palette-purple',
        },
    },
    {
        id: 'quality-review',
        name: 'Quality review',
        type: 'task',
        project: 'delivery',
        start: dateFromToday(18),
        end: dateFromToday(21),
        progress: 0,
        owners: [owners.one, owners.three],
        displayOrder: 12,
        styles: {
            progressClass: 'fill-white/20',
            wrapperClass: 'fill-palette-emerald',
        },
    },
    {
        id: 'release-checkpoint',
        name: 'Release checkpoint',
        type: 'milestone',
        project: 'delivery',
        start: dateFromToday(21),
        end: dateFromToday(21),
        progress: 0,
        displayOrder: 13,
        styles: {
            progressClass: 'fill-white/20',
            wrapperClass: 'fill-palette-red',
        },
    },
]

const BAR_COLORS = [
    {
        label: 'blue',
        dotClass: 'bg-palette-blue',
        ringClass: 'ring-palette-blue',
        progressClass: 'fill-white/20',
        wrapperClass: 'fill-palette-blue',
    },
    {
        label: 'cyan',
        dotClass: 'bg-palette-cyan',
        ringClass: 'ring-palette-cyan',
        progressClass: 'fill-white/20',
        wrapperClass: 'fill-palette-cyan',
    },
    {
        label: 'purple',
        dotClass: 'bg-palette-purple',
        ringClass: 'ring-palette-purple',
        progressClass: 'fill-white/20',
        wrapperClass: 'fill-palette-purple',
    },
    {
        label: 'orange',
        dotClass: 'bg-palette-orange',
        ringClass: 'ring-palette-orange',
        progressClass: 'fill-white/20',
        wrapperClass: 'fill-palette-orange',
    },
    {
        label: 'emerald',
        dotClass: 'bg-palette-emerald',
        ringClass: 'ring-palette-emerald',
        progressClass: 'fill-white/20',
        wrapperClass: 'fill-palette-emerald',
    },
    {
        label: 'red',
        dotClass: 'bg-palette-red',
        ringClass: 'ring-palette-red',
        progressClass: 'fill-white/20',
        wrapperClass: 'fill-palette-red',
    },
    {
        label: 'yellow',
        dotClass: 'bg-palette-yellow',
        ringClass: 'ring-palette-yellow',
        progressClass: 'fill-white/20',
        wrapperClass: 'fill-palette-yellow',
    },
] as const

const projectOptions = INITIAL_TASKS.filter((task) => task.type === 'project').map(
    (task) => ({
        label: task.name,
        value: task.id,
    }),
)

type PhaseFilter = 'discovery' | 'design' | 'delivery'

const phaseOptions: Array<{ label: string; value: PhaseFilter }> = [
    { label: 'Discovery', value: 'discovery' },
    { label: 'Design', value: 'design' },
    { label: 'Delivery', value: 'delivery' },
]
const allPhaseFilters = phaseOptions.map((option) => option.value)
type BarColor = (typeof BAR_COLORS)[number]

type EditTaskDraft = {
    mode: TaskDialogMode
    taskId: string
    projectId: string
    title: string
    status: TaskStatus
    priority: TaskPriority
    assignee: PlanOwner
    start: Date
    end: Date
    color: BarColor
}

type RescheduleAction = 'next-working-day' | 'next-week'

const statusOptions = [
    {
        label: 'Not started',
        value: 'Not started',
        dotClass: 'bg-muted-foreground',
    },
    {
        label: 'In progress',
        value: 'In progress',
        dotClass: 'bg-primary',
    },
    {
        label: 'Complete',
        value: 'Complete',
        dotClass: 'bg-success',
    },
]

const priorityOptions = [
    { label: 'Low', value: 'Low', iconClass: 'text-muted-foreground' },
    { label: 'Normal', value: 'Normal', iconClass: 'text-info' },
    { label: 'High', value: 'High', iconClass: 'text-warning' },
    { label: 'Urgent', value: 'Urgent', iconClass: 'text-destructive' },
]

const getTaskColor = (
    task: Task<PlanTaskMeta>,
    overrides: Record<string, BarColor>,
) =>
    overrides[task.id] ??
    BAR_COLORS.find(
        (color) => color.wrapperClass === task.styles?.wrapperClass,
    ) ??
    BAR_COLORS[0]

const getTaskStatus = (task: Task<PlanTaskMeta>): TaskStatus => {
    if (task.progress === 100) return 'Complete'
    if (task.progress > 0) return 'In progress'
    return 'Not started'
}

const getTaskPriority = (task: Task<PlanTaskMeta>): TaskPriority =>
    task.priority ?? (task.type === 'milestone' ? 'High' : 'Normal')

const getProgressForStatus = (
    status: TaskStatus,
    currentProgress: number,
) => {
    if (status === 'Complete') return 100
    if (status === 'Not started') return 0
    return currentProgress > 0 && currentProgress < 100 ? currentProgress : 50
}

const getNextWorkingDay = (date: Date) => {
    const nextDate = new Date(date)
    nextDate.setDate(nextDate.getDate() + 1)

    while (nextDate.getDay() === 0 || nextDate.getDay() === 6) {
        nextDate.setDate(nextDate.getDate() + 1)
    }

    return nextDate
}

const getDuration = (start: Date, end: Date) =>
    Math.max(1, Math.round((end.getTime() - start.getTime()) / DAY_IN_MS))

type TaskBarContentProps = {
    task: BarTask<PlanTaskMeta>
}

const TaskBarContent = ({ task }: TaskBarContentProps) => {
    return (
        <div className="flex h-full min-w-0 flex-1 items-center gap-1">
            <Avatar.Group chained className="shrink-0">
                {(task.owners ?? []).map((owner) => (
                    <Avatar
                        key={owner.src}
                        alt={owner.alt}
                        size={18}
                        src={owner.src}
                        className="border-card text-xs"
                    />
                ))}
            </Avatar.Group>
            <span className="min-w-0 truncate select-none text-xs font-medium text-primary-foreground">
                {task.name}
            </span>
        </div>
    )
}

export default function GanttProjectPlan() {
    const [viewMode, setViewMode] = useState<ViewMode>(ViewMode.Day)
    const [phaseFilter, setPhaseFilter] =
        useState<PhaseFilter[]>(allPhaseFilters)
    const [items, setItems] = useState(INITIAL_TASKS)
    const [taskColors, setTaskColors] = useState<Record<string, BarColor>>({})
    const [editDraft, setEditDraft] = useState<EditTaskDraft | null>(null)
    const gridColumnsWidth =
        viewMode === ViewMode.Month
            ? 256
            : viewMode === ViewMode.Day
              ? 64
              : 128

    const handleTaskUpdate = useCallback((changedTask: Task<PlanTaskMeta>) => {
        setItems((current) =>
            current.map((task) =>
                task.id === changedTask.id
                    ? {
                          ...task,
                          end: changedTask.end,
                          progress: changedTask.progress,
                          start: changedTask.start,
                      }
                    : task,
            ),
        )
    }, [])

    const handleExpanderClick = useCallback((changedTask: Task<PlanTaskMeta>) => {
        setItems((current) =>
            current.map((task) =>
                task.id === changedTask.id
                    ? { ...task, hideChildren: changedTask.hideChildren }
                    : task,
            ),
        )
    }, [])

    const handleColorChange = useCallback(
        (taskId: string, color: BarColor) => {
            setTaskColors((current) => ({ ...current, [taskId]: color }))
        },
        [],
    )

    const handleCloseEditDialog = useCallback(() => {
        setEditDraft(null)
    }, [])

    const handleAddTask = useCallback(() => {
        setEditDraft({
            mode: 'add',
            taskId: '',
            projectId: projectOptions[0]?.value ?? 'discovery',
            title: '',
            status: 'Not started',
            priority: 'Normal',
            assignee: owners.one,
            start: dateFromToday(0),
            end: dateFromToday(5),
            color: BAR_COLORS[0],
        })
    }, [])

    const handleEditOpen = useCallback(
        (task: Task<PlanTaskMeta>) => {
            const sourceTask =
                items.find((item) => item.id === task.id) ?? task
            const assignee = sourceTask.owners?.[0] ?? owners.one

            setEditDraft({
                mode: 'edit',
                taskId: sourceTask.id,
                projectId: sourceTask.project ?? sourceTask.id,
                title: sourceTask.name,
                status: getTaskStatus(sourceTask),
                priority: getTaskPriority(sourceTask),
                assignee,
                start: new Date(sourceTask.start),
                end: new Date(sourceTask.end),
                color: getTaskColor(sourceTask, taskColors),
            })
        },
        [items, taskColors],
    )

    const handleEditSave = useCallback(() => {
        if (!editDraft) return

        if (editDraft.mode === 'add') {
            const title = editDraft.title.trim()
            const parent = items.find(
                (task) =>
                    task.type === 'project' &&
                    task.id === editDraft.projectId,
            )

            if (!title || !parent) return

            const end =
                editDraft.end.getTime() < editDraft.start.getTime()
                    ? new Date(editDraft.start)
                    : editDraft.end
            let taskNumber = 1
            while (
                items.some(
                    (task) => task.id === `project-plan-task-${taskNumber}`,
                )
            ) {
                taskNumber += 1
            }
            const newTaskId = `project-plan-task-${taskNumber}`
            const newTask: Task<PlanTaskMeta> = {
                id: newTaskId,
                name: title,
                type: 'task',
                project: parent.id,
                start: new Date(editDraft.start),
                end,
                progress: getProgressForStatus(editDraft.status, 0),
                owners: [editDraft.assignee],
                priority: editDraft.priority,
                displayOrder:
                    Math.max(
                        ...items.map((task) => task.displayOrder ?? 0),
                        0,
                    ) + 1,
                styles: {
                    progressClass: editDraft.color.progressClass,
                    wrapperClass: editDraft.color.wrapperClass,
                },
            }

            setItems((current) => [...current, newTask])
            setTaskColors((current) => ({
                ...current,
                [newTaskId]: editDraft.color,
            }))
            setEditDraft(null)
            toast.push(
                <Notification type="success" title="Task added" />,
                { placement: 'top-center' },
            )
            return
        }

        const sourceTask = items.find((item) => item.id === editDraft.taskId)
        if (!sourceTask) return

        const end =
            editDraft.end.getTime() < editDraft.start.getTime()
                ? new Date(editDraft.start)
                : editDraft.end

        setItems((current) =>
            current.map((task) =>
                task.id === editDraft.taskId
                    ? {
                          ...task,
                          name: editDraft.title.trim() || task.name,
                          start: new Date(editDraft.start),
                          end: new Date(end),
                          progress: getProgressForStatus(
                              editDraft.status,
                              task.progress,
                          ),
                          owners: [editDraft.assignee],
                          priority: editDraft.priority,
                      }
                    : task,
            ),
        )
        setTaskColors((current) => ({
            ...current,
            [editDraft.taskId]: editDraft.color,
        }))
        setEditDraft(null)
        toast.push(
            <Notification type="success" title="Task updated" />,
            { placement: 'top-center' },
        )
    }, [editDraft, items])

    const handleReschedule = useCallback(
        (taskId: string, action: RescheduleAction) => {
            const sourceTask = items.find((task) => task.id === taskId)
            if (!sourceTask) return

            const nextStart =
                action === 'next-week'
                    ? new Date(
                          sourceTask.start.getTime() + 7 * DAY_IN_MS,
                      )
                    : getNextWorkingDay(sourceTask.start)
            const shift = nextStart.getTime() - sourceTask.start.getTime()
            const relatedIds = new Set([sourceTask.id])

            if (sourceTask.type === 'project') {
                items.forEach((task) => {
                    if (task.project === sourceTask.id) {
                        relatedIds.add(task.id)
                    }
                })
            }

            setItems((current) =>
                current.map((task) =>
                    relatedIds.has(task.id)
                        ? {
                              ...task,
                              start: new Date(task.start.getTime() + shift),
                              end: new Date(task.end.getTime() + shift),
                          }
                        : task,
                ),
            )
            toast.push(
                <Notification
                    type="success"
                    title={
                        action === 'next-week'
                            ? 'Task moved to next week'
                            : 'Task moved to the next working day'
                    }
                />,
                { placement: 'top-center' },
            )
        },
        [items],
    )

    const handleDuplicate = useCallback(
        (task: Task<PlanTaskMeta>) => {
            const sourceIndex = items.findIndex((item) => item.id === task.id)
            if (sourceIndex === -1) return

            const copyId = `${task.id}-copy-${items.filter((item) => item.id.startsWith(`${task.id}-copy`)).length + 1}`
            const copy = {
                ...items[sourceIndex],
                id: copyId,
                name: `${items[sourceIndex].name} copy`,
            }

            setItems((current) =>
                [...current.slice(0, sourceIndex + 1), copy, ...current.slice(sourceIndex + 1)].map(
                    (item, index) => ({ ...item, displayOrder: index + 1 }),
                ),
            )

            const sourceColor = taskColors[task.id]
            if (sourceColor) {
                setTaskColors((current) => ({
                    ...current,
                    [copyId]: sourceColor,
                }))
            }

            toast.push(
                <Notification type="success" title="Task duplicated" />,
                { placement: 'top-center' },
            )
        },
        [items, taskColors],
    )

    const handleDeleteTask = useCallback(
        (taskId: string) => {
            const sourceTask = items.find((task) => task.id === taskId)
            if (!sourceTask) return

            const deletedIds = new Set([taskId])
            if (sourceTask.type === 'project') {
                items.forEach((task) => {
                    if (task.project === taskId) deletedIds.add(task.id)
                })
            }

            setItems((current) =>
                current.filter((task) => !deletedIds.has(task.id)),
            )
            setTaskColors((current) => {
                const next = { ...current }
                deletedIds.forEach((id) => delete next[id])
                return next
            })
            setEditDraft((current) =>
                current?.taskId === taskId ? null : current,
            )
            toast.push(
                <Notification type="success" title="Task deleted" />,
                { placement: 'top-center' },
            )
        },
        [items],
    )

    const handleCopyLink = useCallback(async (taskId: string) => {
        const link =
            typeof window === 'undefined'
                ? taskId
                : `${window.location.origin}${window.location.pathname}#${taskId}`

        try {
            if (!navigator.clipboard) throw new Error('Clipboard unavailable')
            await navigator.clipboard.writeText(link)
            toast.push(
                <Notification type="success" title="Task link copied" />,
                { placement: 'top-center' },
            )
        } catch {
            toast.push(
                <Notification type="warning" title="Unable to copy task link" />,
                { placement: 'top-center' },
            )
        }
    }, [])

    const renderBarMenu = useCallback(
        (task: Task<PlanTaskMeta>) => {
            const activeColor = getTaskColor(task, taskColors)

            return (
                <>
                    <Dropdown.Item
                        variant="custom"
                        closeOnClick={false}
                        className="px-2 py-1"
                    >
                        <div className="flex items-center gap-1">
                            {BAR_COLORS.map((color) => (
                                <button
                                    key={color.wrapperClass}
                                    aria-label={`Use ${color.label} bar color`}
                                    aria-pressed={
                                        activeColor.wrapperClass ===
                                        color.wrapperClass
                                    }
                                    className={
                                        `flex rounded-full p-1 ${activeColor.wrapperClass === color.wrapperClass ? `ring-2 ring-offset-1 ${color.ringClass}` : ''}`
                                    }
                                    type="button"
                                    onClick={() =>
                                        handleColorChange(task.id, color)
                                    }
                                >
                                    <span
                                        aria-hidden="true"
                                        className={`size-4 rounded-full ${color.dotClass}`}
                                    />
                                </button>
                            ))}
                        </div>
                    </Dropdown.Item>
                    <Dropdown.Item
                        className="flex items-center gap-2"
                        onClick={() => handleEditOpen(task)}
                    >
                        <PiPencilSimple
                            aria-hidden="true"
                            className="text-base"
                        />
                        <span>Edit</span>
                    </Dropdown.Item>
                    <Dropdown.Menu
                        placement="right-start"
                        renderTitle={
                            <span className="flex items-center gap-2">
                                <PiCalendarBlank
                                    aria-hidden="true"
                                    className="text-base"
                                />
                                <span>Reschedule</span>
                            </span>
                        }
                    >
                        <Dropdown.Item
                            onClick={() =>
                                handleReschedule(task.id, 'next-working-day')
                            }
                        >
                            Next working day
                        </Dropdown.Item>
                        <Dropdown.Item
                            onClick={() =>
                                handleReschedule(task.id, 'next-week')
                            }
                        >
                            Next week
                        </Dropdown.Item>
                    </Dropdown.Menu>
                    <Dropdown.Item
                        className="flex items-center gap-2"
                        onClick={() => handleDuplicate(task)}
                    >
                        <PiCopy aria-hidden="true" className="text-base" />
                        <span>Duplicate</span>
                        <span className="ml-auto text-xs text-muted-foreground">
                            ⌘D
                        </span>
                    </Dropdown.Item>
                    <Dropdown.Item
                        className="flex items-center gap-2"
                        onClick={() => void handleCopyLink(task.id)}
                    >
                        <PiLink aria-hidden="true" className="text-base" />
                        <span>Copy Link</span>
                        <span className="ml-auto text-xs text-muted-foreground">
                            ⌘C
                        </span>
                    </Dropdown.Item>
                    <Dropdown.Item variant="divider" />
                    <Dropdown.Item
                        className="flex items-center gap-2 text-destructive hover:bg-destructive-soft hover:text-destructive"
                        onClick={() => handleDeleteTask(task.id)}
                    >
                        <PiTrash aria-hidden="true" className="text-base" />
                        <span>Delete</span>
                    </Dropdown.Item>
                </>
            )
        },
        [
            handleColorChange,
            handleCopyLink,
            handleDeleteTask,
            handleDuplicate,
            handleEditOpen,
            handleReschedule,
            taskColors,
        ],
    )

    const filteredItems = useMemo(
        () =>
            phaseFilter.length === 0
                ? items
                : items.filter(
                      (task) =>
                          phaseFilter.includes(task.id as PhaseFilter) ||
                          Boolean(
                              task.project &&
                                  phaseFilter.includes(
                                      task.project as PhaseFilter,
                                  ),
                          ),
                  ),
        [items, phaseFilter],
    )

    const tasks = useMemo(
        () =>
            filteredItems.map((task) => {
                const color = taskColors[task.id]
                if (!color) return task
                return {
                    ...task,
                    styles: {
                        ...task.styles,
                        progressClass: color.progressClass,
                        wrapperClass: color.wrapperClass,
                    },
                }
            }),
        [filteredItems, taskColors],
    )

    const columns = useMemo<Columns<PlanTaskMeta>[]>(
        () => [
            {
                header: 'Title',
                width: 300,
                cell: (task) => (
                    <div className="flex h-full min-w-0 items-center gap-2 px-4">
                        {task.type === 'project' ? (
                            <Button
                                aria-label={`${task.hideChildren ? 'Expand' : 'Collapse'} ${task.name}`}
                                className="shrink-0"
                                icon={task.expander}
                                onClick={(event) => {
                                    event.stopPropagation()
                                    handleExpanderClick({
                                        ...task,
                                        hideChildren: !task.hideChildren,
                                    })
                                }}
                                shape="circle"
                                size="sm"
                                variant="ghost"
                            />
                        ) : (
                            <span className="flex w-8 shrink-0 items-center justify-center">
                                {task.expander}
                            </span>
                        )}
                        <Checkbox
                            aria-label={`Mark ${task.name} complete`}
                            className="shrink-0"
                            defaultChecked={task.progress === 100}
                        />
                        <span
                            className={`min-w-0 truncate ${task.type === 'project' ? 'font-semibold' : 'font-medium'}`}
                        >
                            {task.name}
                        </span>
                    </div>
                ),
            },
            {
                header: 'Duration',
                width: 100,
                cell: (task) => (
                    <div className="flex h-full items-center px-4 text-sm text-muted-foreground">
                        {getDuration(task.start, task.end)} days
                    </div>
                ),
            },
            {
                header: 'Status',
                width: 180,
                cell: (task) => (
                    <div className="flex h-full items-center gap-2 px-4">
                        <Progress
                            className="min-w-0 flex-1"
                            percent={task.progress}
                            showInfo={false}
                            size="sm"
                        />
                        <span className="w-10 text-right text-xs text-muted-foreground">
                            {task.progress}%
                        </span>
                    </div>
                ),
            },
        ],
        [handleExpanderClick],
    )

    const isAddDialog = editDraft?.mode === 'add'
    const dialogPrefix = isAddDialog
        ? 'gantt-project-plan-add-task'
        : 'gantt-project-plan-edit-task'

    return (
        <section
            aria-labelledby="gantt-project-plan-title"
            className="w-full min-w-0"
        >
            <div className="flex flex-wrap items-center justify-between gap-4 border-b px-4 py-4">
                <h5
                    id="gantt-project-plan-title"
                    className="text-card-foreground"
                >
                    Project plan
                </h5>
                <div className="flex flex-wrap items-center gap-2">
                    <Dropdown
                        activeKey={viewMode}
                        menuClass="min-w-32"
                        onSelect={(value) => setViewMode(value as ViewMode)}
                        title={viewMode}
                    >
                        <Dropdown.Item eventKey={ViewMode.Month}>
                            Month
                        </Dropdown.Item>
                        <Dropdown.Item eventKey={ViewMode.Week}>
                            Week
                        </Dropdown.Item>
                        <Dropdown.Item eventKey={ViewMode.Day}>
                            Day
                        </Dropdown.Item>
                    </Dropdown>
                    <PopoverFilter
                        data={phaseOptions}
                        title="Filter phases"
                        placement="bottom-start"
                        value={phaseFilter}
                        onChange={(selected) =>
                            setPhaseFilter(
                                selected.map(
                                    (option) => option.value as PhaseFilter,
                                ),
                            )
                        }
                        renderTrigger={
                            <Button
                                aria-label="Filter project phases"
                                icon={<PiFunnel aria-hidden="true" />}
                                variant="default"
                            >
                                Filter
                            </Button>
                        }
                    />
                    <Button
                        icon={<PiPlus aria-hidden="true" />}
                        type="button"
                        variant="solid"
                        onClick={handleAddTask}
                    >
                        Add Task
                    </Button>
                </div>
            </div>
            <div className="min-w-0 overflow-x-auto">
                <Gantt<PlanTaskMeta>
                    arrowClass="stroke-muted-foreground"
                    barCornerRadius={6}
                    className="border-b"
                    ganttHeight={580}
                    gridColumnsWidth={gridColumnsWidth}
                    headerHeight={64}
                    onClick={handleEditOpen}
                    onDateChange={handleTaskUpdate}
                    onExpanderClick={handleExpanderClick}
                    onProgressChange={handleTaskUpdate}
                    rowHeight={44}
                    tasks={tasks}
                    columns={columns}
                    customBarContent={(task) => (
                        <TaskBarContent task={task} />
                    )}
                    defaultTaskListWidth={582}
                    resizableTaskList
                    taskListMinWidth={250}
                    taskListMaxWidth={720}
                    renderBarMenu={renderBarMenu}
                    viewMode={viewMode}
                />
            </div>
            {editDraft && (
                <Dialog
                    aria-labelledby={`${dialogPrefix}-title`}
                    className="overflow-hidden p-0"
                    closable={false}
                    isOpen
                    lockScroll
                    width={520}
                    onClose={handleCloseEditDialog}
                >
                    <div className="flex items-start justify-between gap-4 border-b px-4 py-4">
                        <div className="min-w-0">
                            <h5
                                id={`${dialogPrefix}-title`}
                                className="text-lg font-semibold text-foreground"
                            >
                                {isAddDialog ? 'Add task' : 'Edit task'}
                            </h5>
                            <p className="mt-1 text-sm text-muted-foreground">
                                {isAddDialog
                                    ? 'Add a task to the project plan.'
                                    : 'Update the task details below.'}
                            </p>
                        </div>
                        <Button
                            aria-label={`Close ${isAddDialog ? 'add' : 'edit'} task dialog`}
                            icon={<PiX aria-hidden="true" />}
                            size="sm"
                            variant="ghost"
                            onClick={handleCloseEditDialog}
                        />
                    </div>
                    <div className="max-h-[80vh] overflow-y-auto p-4">
                        <div className="space-y-4">
                            <div>
                                <label
                                    className="mb-2 block text-sm font-medium text-foreground"
                                    htmlFor={`${dialogPrefix}-title-input`}
                                >
                                    Title
                                </label>
                                <Input
                                    id={`${dialogPrefix}-title-input`}
                                    value={editDraft.title}
                                    onChange={(event) =>
                                        setEditDraft((current) =>
                                            current
                                                ? {
                                                      ...current,
                                                      title: event.target.value,
                                                  }
                                                : current,
                                        )
                                    }
                                />
                            </div>
                            {isAddDialog ? (
                                <div>
                                    <label
                                        className="mb-2 block text-sm font-medium text-foreground"
                                        htmlFor={`${dialogPrefix}-project`}
                                    >
                                        Project
                                    </label>
                                    <Select<(typeof projectOptions)[number]>
                                        inputId={`${dialogPrefix}-project`}
                                        options={projectOptions}
                                        value={
                                            projectOptions.find(
                                                (option) =>
                                                    option.value ===
                                                    editDraft.projectId,
                                            ) ?? null
                                        }
                                        onChange={(option) =>
                                            option &&
                                            setEditDraft((current) =>
                                                current
                                                    ? {
                                                          ...current,
                                                          projectId: option.value,
                                                      }
                                                    : current,
                                            )
                                        }
                                    />
                                </div>
                            ) : null}
                            <div>
                                <label
                                    className="mb-2 block text-sm font-medium text-foreground"
                                    htmlFor={`${dialogPrefix}-status`}
                                >
                                    Status
                                </label>
                                <Select<(typeof statusOptions)[number]>
                                    inputId={`${dialogPrefix}-status`}
                                    options={statusOptions}
                                    value={
                                        statusOptions.find(
                                            (option) =>
                                                option.value === editDraft.status,
                                        ) ?? null
                                    }
                                    customInputDisplay={(option) =>
                                        option ? (
                                            <span className="flex items-center gap-2">
                                                <span
                                                    aria-hidden="true"
                                                    className={`size-2 rounded-full ${option.dotClass}`}
                                                />
                                                {option.label}
                                            </span>
                                        ) : null
                                    }
                                    onChange={(option) =>
                                        setEditDraft((current) =>
                                            current
                                                ? {
                                                      ...current,
                                                      status: option.value as TaskStatus,
                                                  }
                                                : current,
                                        )
                                    }
                                />
                            </div>
                            <div>
                                <label
                                    className="mb-2 block text-sm font-medium text-foreground"
                                    htmlFor={`${dialogPrefix}-priority`}
                                >
                                    Priority
                                </label>
                                <Select<(typeof priorityOptions)[number]>
                                    inputId={`${dialogPrefix}-priority`}
                                    options={priorityOptions}
                                    value={
                                        priorityOptions.find(
                                            (option) =>
                                                option.value === editDraft.priority,
                                        ) ?? null
                                    }
                                    customInputDisplay={(option) =>
                                        option ? (
                                            <span className="flex items-center gap-2">
                                                <PiFlag
                                                    aria-hidden="true"
                                                    className={option.iconClass}
                                                />
                                                {option.label}
                                            </span>
                                        ) : null
                                    }
                                    onChange={(option) =>
                                        setEditDraft((current) =>
                                            current
                                                ? {
                                                      ...current,
                                                      priority: option.value as TaskPriority,
                                                  }
                                                : current,
                                        )
                                    }
                                />
                            </div>
                            <div>
                                <label
                                    className="mb-2 block text-sm font-medium text-foreground"
                                    htmlFor={`${dialogPrefix}-assignee`}
                                >
                                    Assignee
                                </label>
                                <Select<{ owner: PlanOwner }>
                                    inputId={`${dialogPrefix}-assignee`}
                                    options={ownerOptions}
                                    value={
                                        ownerOptions.find(
                                            (option) =>
                                                option.value ===
                                                editDraft.assignee.src,
                                        ) ?? null
                                    }
                                    customInputDisplay={(option) =>
                                        option ? (
                                            <span className="flex items-center gap-2">
                                                <Avatar
                                                    alt={option.owner.alt}
                                                    size={18}
                                                    src={option.owner.src}
                                                />
                                                {option.owner.alt}
                                            </span>
                                        ) : null
                                    }
                                    customOption={({
                                        option,
                                        selected,
                                        CheckIcon,
                                    }) => (
                                        <span className="flex min-w-0 items-center justify-between gap-2">
                                            <span className="flex min-w-0 items-center gap-2">
                                                <Avatar
                                                    alt={option.owner.alt}
                                                    size={20}
                                                    src={option.owner.src}
                                                />
                                                <span className="truncate">
                                                    {option.owner.alt}
                                                </span>
                                            </span>
                                            {selected ? (
                                                <span className="ml-auto shrink-0">
                                                    {CheckIcon}
                                                </span>
                                            ) : null}
                                        </span>
                                    )}
                                    onChange={(option) =>
                                        setEditDraft((current) =>
                                            current
                                                ? {
                                                      ...current,
                                                      assignee: option.owner,
                                                  }
                                                : current,
                                        )
                                    }
                                />
                            </div>
                            <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
                                <div>
                                    <span
                                        className="mb-2 block text-sm font-medium text-foreground"
                                        id={`${dialogPrefix}-start-label`}
                                    >
                                        Start
                                    </span>
                                    <DatePicker
                                        aria-labelledby={`${dialogPrefix}-start-label`}
                                        clearable={false}
                                        inputFormat="MMM D, YYYY"
                                        name={`${dialogPrefix}-start`}
                                        value={editDraft.start}
                                        onChange={(date) =>
                                            date &&
                                            setEditDraft((current) =>
                                                current
                                                    ? {
                                                          ...current,
                                                          start: date,
                                                      }
                                                    : current,
                                            )
                                        }
                                    />
                                </div>
                                <div>
                                    <span
                                        className="mb-2 block text-sm font-medium text-foreground"
                                        id={`${dialogPrefix}-due-label`}
                                    >
                                        Due
                                    </span>
                                    <DatePicker
                                        aria-labelledby={`${dialogPrefix}-due-label`}
                                        clearable={false}
                                        inputFormat="MMM D, YYYY"
                                        minDate={editDraft.start}
                                        name={`${dialogPrefix}-due`}
                                        value={editDraft.end}
                                        onChange={(date) =>
                                            date &&
                                            setEditDraft((current) =>
                                                current
                                                    ? {
                                                          ...current,
                                                          end: date,
                                                      }
                                                    : current,
                                            )
                                        }
                                    />
                                </div>
                            </div>
                            <div>
                                <span className="mb-2 block text-sm font-medium text-foreground">
                                    Color
                                </span>
                                <div className="flex flex-wrap items-center gap-2">
                                    {BAR_COLORS.map((color) => (
                                        <button
                                            key={color.wrapperClass}
                                            aria-label={`Use ${color.label} bar color`}
                                            aria-pressed={
                                                editDraft.color.wrapperClass ===
                                                color.wrapperClass
                                            }
                                            className={`flex rounded-full p-1 ${editDraft.color.wrapperClass === color.wrapperClass ? `ring-2 ring-offset-0.5 ${color.ringClass}` : ''}`}
                                            type="button"
                                            onClick={() =>
                                                setEditDraft((current) =>
                                                    current
                                                        ? {
                                                              ...current,
                                                              color,
                                                          }
                                                        : current,
                                                )
                                            }
                                        >
                                            <span
                                                aria-hidden="true"
                                                className={`size-4 rounded-full ${color.dotClass}`}
                                            />
                                        </button>
                                    ))}
                                </div>
                            </div>
                        </div>
                    </div>
                    <div
                        className={`flex flex-wrap items-center gap-4 border-t p-4 ${isAddDialog ? 'justify-end' : 'justify-between'}`}
                    >
                        {isAddDialog ? null : (
                            <Button
                                destructive
                                type="button"
                                onClick={() =>
                                    handleDeleteTask(editDraft.taskId)
                                }
                            >
                                Delete task
                            </Button>
                        )}
                        <div className="flex items-center gap-2">
                            <Button
                                type="button"
                                onClick={handleCloseEditDialog}
                            >
                                Cancel
                            </Button>
                            <Button
                                disabled={isAddDialog && !editDraft.title.trim()}
                                type="button"
                                variant="solid"
                                onClick={handleEditSave}
                            >
                                {isAddDialog ? 'Add task' : 'Save'}
                            </Button>
                        </div>
                    </div>
                </Dialog>
            )}
        </section>
    )
}

Gantt 02

Preview
npx nateui@latest add GanttBuildSchedule
Dark
import { useCallback, useMemo, useState } from 'react'
import {
    PiCaretDown,
    PiCheck,
    PiMagnifyingGlass,
    PiPlus,
    PiSlidersHorizontal,
} from 'react-icons/pi'
import Gantt, {
    ViewMode,
    type BarTask,
    type Columns,
    type Task,
} from '@/components/composites/Gantt'
import PopoverFilter from '@/components/composites/PopoverFilter'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import DatePicker from '@/components/ui/DatePicker'
import Dialog from '@/components/ui/Dialog'
import Dropdown from '@/components/ui/Dropdown'
import Input from '@/components/ui/Input'
import Progress from '@/components/ui/Progress'
import Select from '@/components/ui/Select'

const assetBase = 'https://statics.nateui.com/img'
const DAY_GRID_COLUMNS_WIDTH = 96

type PhaseKey = 'permitting' | 'site-preparation' | 'foundation' | 'framing'
type BuildTaskStatus = 'Not started' | 'In progress' | 'Complete'
type BuildColumnKey = 'phase' | 'status' | 'work-days'

const PHASE_OPTIONS: Array<{ label: string; value: PhaseKey }> = [
    { label: 'Permitting', value: 'permitting' },
    { label: 'Site preparation', value: 'site-preparation' },
    { label: 'Foundation', value: 'foundation' },
    { label: 'Framing and rough-ins', value: 'framing' },
]

const COLUMN_OPTIONS: Array<{ label: string; value: BuildColumnKey }> = [
    { label: 'Phase', value: 'phase' },
    { label: 'Status', value: 'status' },
    { label: 'Work Days', value: 'work-days' },
]

const DEFAULT_VISIBLE_COLUMNS = COLUMN_OPTIONS.map((option) => option.value)

const TASK_STATUS_OPTIONS: Array<{
    label: string
    value: BuildTaskStatus
}> = [
    { label: 'Not started', value: 'Not started' },
    { label: 'In progress', value: 'In progress' },
    { label: 'Complete', value: 'Complete' },
]

type BuildOwner = {
    alt: string
    src: string
}

type BuildTaskMeta = {
    owners: BuildOwner[]
    phase: PhaseKey
    statusPercent: number
    workDays: number
}

const PHASES = {
    permitting: {
        dotClass: 'bg-palette-blue',
        projectProgressClass: 'fill-palette-blue',
        statusClass: 'text-palette-blue',
    },
    'site-preparation': {
        dotClass: 'bg-palette-emerald',
        projectProgressClass: 'fill-palette-emerald',
        statusClass: 'text-palette-emerald',
    },
    foundation: {
        dotClass: 'bg-palette-orange',
        projectProgressClass: 'fill-palette-orange',
        statusClass: 'text-palette-orange',
    },
    framing: {
        dotClass: 'bg-palette-purple',
        projectProgressClass: 'fill-palette-purple',
        statusClass: 'text-palette-purple',
    },
} as const

const owners: BuildOwner[] = [
    { alt: 'Avery Morgan', src: `${assetBase}/avatars/thumb-1.jpg` },
    { alt: 'Jordan Ellis', src: `${assetBase}/avatars/thumb-2.jpg` },
    { alt: 'Morgan Reed', src: `${assetBase}/avatars/thumb-3.jpg` },
    { alt: 'Casey Brooks', src: `${assetBase}/avatars/thumb-4.jpg` },
    { alt: 'Riley Stone', src: `${assetBase}/avatars/thumb-5.jpg` },
    { alt: 'Taylor Quinn', src: `${assetBase}/avatars/thumb-6.jpg` },
    { alt: 'Cameron Blake', src: `${assetBase}/avatars/thumb-7.jpg` },
    { alt: 'Parker Lane', src: `${assetBase}/avatars/thumb-8.jpg` },
]

type BuildOwnerOption = {
    label: string
    value: string
    owner: BuildOwner
}

const OWNER_OPTIONS: BuildOwnerOption[] = owners.map((owner) => ({
    label: owner.alt,
    value: owner.src,
    owner,
}))

const today = new Date()
today.setHours(0, 0, 0, 0)

const dateFromToday = (offsetDays: number) => {
    const date = new Date(today)
    date.setDate(date.getDate() + offsetDays)
    return date
}

const getWorkDays = (start: Date, end: Date) =>
    Math.max(1, Math.round((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)))

const getTaskStatus = (task: Task<BuildTaskMeta>): BuildTaskStatus => {
    if (task.statusPercent === 100) return 'Complete'
    if (task.statusPercent > 0) return 'In progress'
    return 'Not started'
}

const getStatusPercent = (
    status: BuildTaskStatus,
    currentPercent: number,
) => {
    if (status === 'Complete') return 100
    if (status === 'Not started') return 0
    return currentPercent > 0 && currentPercent < 100 ? currentPercent : 50
}

type TaskDialogDraft = {
    mode: 'add' | 'edit'
    taskId?: string
    title: string
    phase: PhaseKey
    status: BuildTaskStatus
    assignee: BuildOwner
    start: Date
    end: Date
}

const createTask = (
    task: Omit<Task<BuildTaskMeta>, 'progress'>,
): Task<BuildTaskMeta> => ({
    ...task,
    progress: task.type === 'project' ? task.statusPercent : 0,
    ...(task.type === 'project'
        ? {
              styles: {
                  progressClass: PHASES[task.phase].projectProgressClass,
              },
          }
        : {}),
})

const INITIAL_TASKS: Task<BuildTaskMeta>[] = [
    createTask({
        id: 'phase-permitting',
        name: 'Permitting',
        type: 'project',
        start: dateFromToday(-3),
        end: dateFromToday(3),
        hideChildren: false,
        displayOrder: 1,
        phase: 'permitting',
        statusPercent: 72,
        workDays: 5,
        owners: [owners[0], owners[1]],
    }),
    createTask({
        id: 'permit-package',
        name: 'Permit package',
        type: 'task',
        project: 'phase-permitting',
        start: dateFromToday(-3),
        end: dateFromToday(0),
        displayOrder: 2,
        phase: 'permitting',
        statusPercent: 64,
        workDays: 3,
        owners: [owners[0], owners[1]],
    }),
    createTask({
        id: 'permit-review',
        name: 'Review approval',
        type: 'task',
        project: 'phase-permitting',
        start: dateFromToday(0),
        end: dateFromToday(3),
        dependencies: ['permit-package'],
        displayOrder: 3,
        phase: 'permitting',
        statusPercent: 80,
        workDays: 2,
        owners: [owners[1], owners[2]],
    }),
    createTask({
        id: 'phase-site-preparation',
        name: 'Site preparation',
        type: 'project',
        start: dateFromToday(3),
        end: dateFromToday(9),
        dependencies: ['permit-review'],
        hideChildren: false,
        displayOrder: 4,
        phase: 'site-preparation',
        statusPercent: 46,
        workDays: 5,
        owners: [owners[2], owners[3]],
    }),
    createTask({
        id: 'site-survey',
        name: 'Site survey',
        type: 'task',
        project: 'phase-site-preparation',
        start: dateFromToday(3),
        end: dateFromToday(5),
        dependencies: ['permit-review'],
        displayOrder: 5,
        phase: 'site-preparation',
        statusPercent: 42,
        workDays: 2,
        owners: [owners[2], owners[3]],
    }),
    createTask({
        id: 'site-groundwork',
        name: 'Ground preparation',
        type: 'task',
        project: 'phase-site-preparation',
        start: dateFromToday(5),
        end: dateFromToday(9),
        dependencies: ['site-survey'],
        displayOrder: 6,
        phase: 'site-preparation',
        statusPercent: 35,
        workDays: 3,
        owners: [owners[3], owners[4]],
    }),
    createTask({
        id: 'phase-foundation',
        name: 'Foundation',
        type: 'project',
        start: dateFromToday(9),
        end: dateFromToday(16),
        dependencies: ['site-groundwork'],
        hideChildren: false,
        displayOrder: 7,
        phase: 'foundation',
        statusPercent: 30,
        workDays: 7,
        owners: [owners[4], owners[5]],
    }),
    createTask({
        id: 'foundation-excavation',
        name: 'Foundation excavation',
        type: 'task',
        project: 'phase-foundation',
        start: dateFromToday(9),
        end: dateFromToday(12),
        dependencies: ['site-groundwork'],
        displayOrder: 8,
        phase: 'foundation',
        statusPercent: 28,
        workDays: 3,
        owners: [owners[4], owners[5]],
    }),
    createTask({
        id: 'foundation-pour',
        name: 'Concrete pour',
        type: 'task',
        project: 'phase-foundation',
        start: dateFromToday(12),
        end: dateFromToday(16),
        dependencies: ['foundation-excavation'],
        displayOrder: 9,
        phase: 'foundation',
        statusPercent: 12,
        workDays: 4,
        owners: [owners[5], owners[6]],
    }),
    createTask({
        id: 'phase-framing',
        name: 'Framing and rough-ins',
        type: 'project',
        start: dateFromToday(16),
        end: dateFromToday(25),
        dependencies: ['foundation-pour'],
        hideChildren: false,
        displayOrder: 10,
        phase: 'framing',
        statusPercent: 18,
        workDays: 8,
        owners: [owners[6], owners[7]],
    }),
    createTask({
        id: 'framing-structure',
        name: 'Frame structure',
        type: 'task',
        project: 'phase-framing',
        start: dateFromToday(16),
        end: dateFromToday(21),
        dependencies: ['foundation-pour'],
        displayOrder: 11,
        phase: 'framing',
        statusPercent: 8,
        workDays: 5,
        owners: [owners[6], owners[7]],
    }),
    createTask({
        id: 'rough-ins',
        name: 'Mechanical rough-ins',
        type: 'task',
        project: 'phase-framing',
        start: dateFromToday(21),
        end: dateFromToday(25),
        dependencies: ['framing-structure'],
        displayOrder: 12,
        phase: 'framing',
        statusPercent: 0,
        workDays: 3,
        owners: [owners[0], owners[7]],
    }),
]

const BuildBarContent = ({ task }: { task: BarTask<BuildTaskMeta> }) => {
    const phase = PHASES[task.phase]

    return (
        <div className="flex h-full min-w-0 flex-1 items-center gap-2 overflow-hidden">
            <span
                aria-hidden="true"
                className={`size-2 shrink-0 rounded-full ${phase.dotClass}`}
            />
            <div className="min-w-0 flex-1 truncate text-xs font-semibold text-card-foreground">
                {task.name}
            </div>
            <Avatar.Group chained className="shrink-0">
                {task.owners.map((owner) => (
                    <Avatar
                        key={owner.src}
                        alt={owner.alt}
                        className="border-card"
                        size={18}
                        src={owner.src}
                    />
                ))}
            </Avatar.Group>
        </div>
    )
}

const getGridColumnsWidth = (viewMode: ViewMode) => {
    if (viewMode === ViewMode.Week) return 64
    if (viewMode === ViewMode.Month) return 256
    if (viewMode === ViewMode.Year) return 128
    return DAY_GRID_COLUMNS_WIDTH
}

export default function GanttBuildSchedule() {
    const [tasks, setTasks] = useState(INITIAL_TASKS)
    const [visibleColumns, setVisibleColumns] = useState<BuildColumnKey[]>(
        DEFAULT_VISIBLE_COLUMNS,
    )
    const [taskDialog, setTaskDialog] =
        useState<TaskDialogDraft | null>(null)
    const [searchQuery, setSearchQuery] = useState('')
    const [viewMode, setViewMode] = useState<ViewMode>(ViewMode.Day)
    const gridColumnsWidth = getGridColumnsWidth(viewMode)

    const updateTaskDialog = <K extends keyof TaskDialogDraft>(
        key: K,
        value: TaskDialogDraft[K],
    ) => {
        setTaskDialog((current) =>
            current ? { ...current, [key]: value } : current,
        )
    }

    const handleAddTask = useCallback(() => {
        const phase = PHASE_OPTIONS[0].value

        setTaskDialog({
            mode: 'add',
            title: '',
            phase,
            status: 'Not started',
            assignee: owners[0],
            start: dateFromToday(0),
            end: dateFromToday(3),
        })
    }, [])

    const handleEditTask = useCallback(
        (task: Task<BuildTaskMeta>) => {
            if (task.type !== 'task') return

            const sourceTask = tasks.find((item) => item.id === task.id) ?? task

            setTaskDialog({
                mode: 'edit',
                taskId: sourceTask.id,
                title: sourceTask.name,
                phase: sourceTask.phase,
                status: getTaskStatus(sourceTask),
                assignee: sourceTask.owners[0] ?? owners[0],
                start: new Date(sourceTask.start),
                end: new Date(sourceTask.end),
            })
        },
        [tasks],
    )

    const handleTaskDialogSave = useCallback(() => {
        if (!taskDialog || !taskDialog.title.trim()) return

        const end =
            taskDialog.end.getTime() < taskDialog.start.getTime()
                ? new Date(taskDialog.start)
                : new Date(taskDialog.end)
        const workDays = getWorkDays(taskDialog.start, end)
        const sourceTask =
            taskDialog.mode === 'edit'
                ? tasks.find((task) => task.id === taskDialog.taskId)
                : undefined
        const statusPercent = getStatusPercent(
            taskDialog.status,
            sourceTask?.statusPercent ?? 0,
        )

        if (taskDialog.mode === 'add') {
            const parent = tasks.find(
                (task) =>
                    task.type === 'project' && task.phase === taskDialog.phase,
            )

            if (!parent) return

            const nextTaskNumber =
                tasks.filter((task) => task.id.startsWith('build-task-')).length + 1
            const nextDisplayOrder =
                Math.max(...tasks.map((task) => task.displayOrder ?? 0), 0) + 1

            setTasks((currentTasks) => [
                ...currentTasks,
                createTask({
                    id: `build-task-${nextTaskNumber}`,
                    name: taskDialog.title.trim(),
                    type: 'task',
                    project: parent.id,
                    start: new Date(taskDialog.start),
                    end,
                    displayOrder: nextDisplayOrder,
                    phase: taskDialog.phase,
                    statusPercent,
                    workDays,
                    owners: [taskDialog.assignee],
                }),
            ])
        } else {
            if (!sourceTask) return

            const parent = tasks.find(
                (task) =>
                    task.type === 'project' && task.phase === taskDialog.phase,
            )

            setTasks((currentTasks) =>
                currentTasks.map((task) =>
                    task.id === sourceTask.id
                        ? {
                              ...task,
                              name: taskDialog.title.trim(),
                              project: parent?.id ?? task.project,
                              start: new Date(taskDialog.start),
                              end,
                              phase: taskDialog.phase,
                              statusPercent,
                              workDays,
                              owners: [taskDialog.assignee],
                          }
                        : task,
                ),
            )
        }

        setTaskDialog(null)
    }, [taskDialog, tasks])

    const handleExpanderClick = useCallback((changedTask: Task<BuildTaskMeta>) => {
        setTasks((currentTasks) =>
            currentTasks.map((task) =>
                task.id === changedTask.id
                    ? { ...task, hideChildren: changedTask.hideChildren }
                    : task,
            ),
        )
    }, [])

    const handleDateChange = useCallback(
        (changedTask: Task<BuildTaskMeta>, children: Task<BuildTaskMeta>[]) => {
            const changedTasks = new Map(
                [changedTask, ...children].map((task) => [task.id, task]),
            )

            setTasks((currentTasks) =>
                currentTasks.map((task) => {
                    const changed = changedTasks.get(task.id)

                    return changed
                        ? {
                              ...task,
                              start: changed.start,
                              end: changed.end,
                              workDays: getWorkDays(changed.start, changed.end),
                          }
                        : task
                }),
            )
        },
        [],
    )

    const filteredTasks = useMemo(() => {
        const query = searchQuery.trim().toLowerCase()

        if (!query) {
            return tasks
        }

        const matchingIds = new Set(
            tasks
                .filter((task) => task.name.toLowerCase().includes(query))
                .map((task) => task.id),
        )

        tasks.forEach((task) => {
            if (task.type === 'task' && task.project && matchingIds.has(task.id)) {
                matchingIds.add(task.project)
            }
        })

        const matchingProjects = new Set(
            tasks
                .filter((task) => task.type === 'project' && matchingIds.has(task.id))
                .map((task) => task.id),
        )

        return tasks
            .filter(
                (task) =>
                    matchingIds.has(task.id) ||
                    (task.type === 'task' &&
                        task.project &&
                        matchingProjects.has(task.project)),
            )
            .map((task) =>
                task.type === 'project' ? { ...task, hideChildren: false } : task,
            )
    }, [searchQuery, tasks])

    const columns = useMemo<Columns<BuildTaskMeta>[]>(
        () => {
            const allColumns: Record<
                BuildColumnKey,
                Columns<BuildTaskMeta>
            > = {
                phase: {
                    header: 'Phase',
                    width: 280,
                    cell: (task) => {
                        const phase = PHASES[task.phase]
                        const isProject = task.type === 'project'

                        return (
                            <div
                                className={`flex h-full min-w-0 items-center gap-2 pr-4 ${isProject ? 'pl-4' : 'pl-12'}`}
                            >
                                {isProject ? (
                                    <Button
                                        aria-label={`${task.hideChildren ? 'Expand' : 'Collapse'} ${task.name}`}
                                        className="shrink-0"
                                        icon={task.expander}
                                        onClick={(event) => {
                                            event.stopPropagation()
                                            handleExpanderClick({
                                                ...task,
                                                hideChildren: !task.hideChildren,
                                            })
                                        }}
                                        shape="circle"
                                        size="sm"
                                        variant="ghost"
                                    />
                                ) : null}
                                {isProject ? (
                                    <span
                                        aria-hidden="true"
                                        className={`size-2 shrink-0 rounded-full ${phase.dotClass}`}
                                    />
                                ) : null}
                                <span
                                    className={`min-w-0 truncate ${isProject ? 'font-semibold' : 'font-medium'}`}
                                >
                                    {task.name}
                                </span>
                            </div>
                        )
                    },
                },
                status: {
                    header: 'Status',
                    width: 110,
                    cell: (task) => (
                        <div className="flex h-full items-center gap-2 px-4">
                            <Progress
                                className="w-auto"
                                variant="circle"
                                strokeWidth={15}
                                width={19}
                                showInfo={false}
                                size="sm"
                                percent={task.statusPercent}
                            />
                            <span>
                                {task.statusPercent}%
                            </span>
                        </div>
                    ),
                },
                'work-days': {
                    header: 'Work Days',
                    width: 105,
                    cell: (task) => (
                        <div className="flex h-full items-center justify-end px-4 text-sm text-muted-foreground px-4">
                            <span className="tabular-nums">{task.workDays}</span>
                        </div>
                    ),
                },
            }

            return COLUMN_OPTIONS.filter((option) =>
                visibleColumns.includes(option.value),
            ).map((option) => allColumns[option.value])
        },
        [handleExpanderClick, visibleColumns],
    )

    return (
        <section aria-labelledby="gantt-build-schedule-title" className="w-full min-w-0">
            <header className="flex min-w-0 flex-wrap items-center justify-between gap-4 border-b px-4 py-4">
                <h5 id="gantt-build-schedule-title">Build schedule</h5>
                <div className="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 lg:w-auto">
                    <label className="sr-only" htmlFor="gantt-build-schedule-search">
                        Search schedule
                    </label>
                    <div className="w-full min-w-0 sm:w-56">
                        <Input
                            id="gantt-build-schedule-search"
                            onChange={(event) => setSearchQuery(event.target.value)}
                            placeholder="Search schedule"
                            prefix={<PiMagnifyingGlass aria-hidden="true" />}
                            value={searchQuery}
                        />
                    </div>
                    <PopoverFilter
                        data={COLUMN_OPTIONS}
                        inputPlaceholder="Search columns"
                        placement="bottom-end"
                        title="Task list columns"
                        value={visibleColumns}
                        onChange={(items) =>
                            setVisibleColumns(
                                items.map(
                                    (item) => item.value as BuildColumnKey,
                                ),
                            )
                        }
                        renderTrigger={
                            <Button
                                active={
                                    visibleColumns.length !==
                                    COLUMN_OPTIONS.length
                                }
                                aria-label="Choose task list columns"
                                icon={<PiSlidersHorizontal aria-hidden="true" />}
                                type="button"
                                variant="default"
                            >
                                Column
                            </Button>
                        }
                    />
                    <Dropdown
                        activeKey={viewMode}
                        aria-label={`View scale: ${viewMode}`}
                        menuClass="min-w-32"
                        placement="bottom-end"
                        onSelect={(eventKey) =>
                            setViewMode(eventKey as ViewMode)
                        }
                        renderTitle={
                            <Button
                                icon={<PiCaretDown aria-hidden="true" />}
                                iconAlignment="end"
                                type="button"
                                variant="default"
                            >
                                {viewMode}
                            </Button>
                        }
                    >
                        <Dropdown.Item
                            active={viewMode === ViewMode.Day}
                            className="flex items-center justify-between gap-2"
                            eventKey={ViewMode.Day}
                        >
                            Day
                            <span
                                aria-hidden="true"
                                className="flex size-4 shrink-0 items-center justify-center"
                            >
                                {viewMode === ViewMode.Day ? <PiCheck /> : null}
                            </span>
                        </Dropdown.Item>
                        <Dropdown.Item
                            active={viewMode === ViewMode.Week}
                            className="flex items-center justify-between gap-2"
                            eventKey={ViewMode.Week}
                        >
                            Week
                            <span
                                aria-hidden="true"
                                className="flex size-4 shrink-0 items-center justify-center"
                            >
                                {viewMode === ViewMode.Week ? <PiCheck /> : null}
                            </span>
                        </Dropdown.Item>
                        <Dropdown.Item
                            active={viewMode === ViewMode.Month}
                            className="flex items-center justify-between gap-2"
                            eventKey={ViewMode.Month}
                        >
                            Month
                            <span
                                aria-hidden="true"
                                className="flex size-4 shrink-0 items-center justify-center"
                            >
                                {viewMode === ViewMode.Month ? <PiCheck /> : null}
                            </span>
                        </Dropdown.Item>
                        <Dropdown.Item
                            active={viewMode === ViewMode.Year}
                            className="flex items-center justify-between gap-2"
                            eventKey={ViewMode.Year}
                        >
                            Year
                            <span
                                aria-hidden="true"
                                className="flex size-4 shrink-0 items-center justify-center"
                            >
                                {viewMode === ViewMode.Year ? <PiCheck /> : null}
                            </span>
                        </Dropdown.Item>
                    </Dropdown>
                    <Button
                        icon={<PiPlus aria-hidden="true" />}
                        onClick={handleAddTask}
                        type="button"
                        variant="solid"
                    >
                        Add New
                    </Button>
                </div>
            </header>
            {filteredTasks.length ? (
                <div className="min-w-0 overflow-hidden">
                    <Gantt<BuildTaskMeta>
                        barCornerRadius={8}
                        barFill={60}
                        barWrapperClass="fill-card stroke-border"
                        className="border-b"
                        columns={columns}
                        customBarContent={(task) =>
                            task.type === 'task' ? (
                                <BuildBarContent task={task} />
                            ) : null
                        }
                        defaultTaskListWidth={497}
                        ganttHeight={576}
                        gridColumnsWidth={gridColumnsWidth}
                        handleWidth={8}
                        headerHeight={72}
                        locale="en-US"
                        onClick={handleEditTask}
                        onDateChange={handleDateChange}
                        onExpanderClick={handleExpanderClick}
                        preStepsCount={0}
                        projectWrapperClass="fill-muted stroke-border"
                        rowHeight={48}
                        taskListMaxWidth={720}
                        taskListMinWidth={440}
                        tasks={filteredTasks}
                        todayColor="var(--nui-primary-soft)"
                        viewDate={dateFromToday(0)}
                        viewMode={viewMode}
                    />
                </div>
            ) : (
                <div
                    aria-live="polite"
                    className="px-4 py-8 text-center text-sm text-muted-foreground"
                    role="status"
                >
                    No schedule rows match this search.
                </div>
            )}
            {taskDialog ? (
                <Dialog
                    aria-describedby="gantt-build-schedule-task-dialog-description"
                    aria-labelledby="gantt-build-schedule-task-dialog-title"
                    className="flex max-h-[calc(100dvh-2rem)] w-full flex-col overflow-hidden p-0"
                    isOpen
                    lockScroll
                    onClose={() => setTaskDialog(null)}
                    width={560}
                >
                    <header className="border-b px-4 py-4 pr-12">
                        <h5
                            className="text-lg font-semibold text-foreground"
                            id="gantt-build-schedule-task-dialog-title"
                        >
                            {taskDialog.mode === 'add' ? 'Add task' : 'Edit task'}
                        </h5>
                        <p
                            className="text-muted-foreground"
                            id="gantt-build-schedule-task-dialog-description"
                        >
                            {taskDialog.mode === 'add'
                                ? 'Create a task for the construction schedule.'
                                : 'Update the task details below.'}
                        </p>
                    </header>
                    <div className="min-h-0 flex-1 p-4">
                        <div className="space-y-4">
                            <div>
                                <label
                                    className="mb-2 block text-sm font-medium text-foreground"
                                    htmlFor="gantt-build-schedule-task-title"
                                >
                                    Title
                                </label>
                                <Input
                                    id="gantt-build-schedule-task-title"
                                    onChange={(event) =>
                                        updateTaskDialog(
                                            'title',
                                            event.target.value,
                                        )
                                    }
                                    placeholder="Enter a task title"
                                    value={taskDialog.title}
                                />
                            </div>
                            <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
                                <div>
                                    <label
                                        className="mb-2 block text-sm font-medium text-foreground"
                                        htmlFor="gantt-build-schedule-task-phase"
                                    >
                                        Phase
                                    </label>
                                    <Select
                                        isDisabled={taskDialog.mode === 'edit'}
                                        inputId="gantt-build-schedule-task-phase"
                                        options={PHASE_OPTIONS}
                                        value={PHASE_OPTIONS.find(
                                            (option) =>
                                                option.value === taskDialog.phase,
                                        )}
                                        onChange={(option) =>
                                            option &&
                                            updateTaskDialog(
                                                'phase',
                                                option.value as PhaseKey,
                                            )
                                        }
                                    />
                                </div>
                                <div>
                                    <label
                                        className="mb-2 block text-sm font-medium text-foreground"
                                        htmlFor="gantt-build-schedule-task-status"
                                    >
                                        Status
                                    </label>
                                    <Select
                                        inputId="gantt-build-schedule-task-status"
                                        options={TASK_STATUS_OPTIONS}
                                        value={TASK_STATUS_OPTIONS.find(
                                            (option) =>
                                                option.value === taskDialog.status,
                                        )}
                                        onChange={(option) =>
                                            option &&
                                            updateTaskDialog(
                                                'status',
                                                option.value as BuildTaskStatus,
                                            )
                                        }
                                    />
                                </div>
                            </div>
                            <div>
                                <label
                                    className="mb-2 block text-sm font-medium text-foreground"
                                    htmlFor="gantt-build-schedule-task-assignee"
                                >
                                    Assignee
                                </label>
                                <Select<BuildOwnerOption>
                                    inputId="gantt-build-schedule-task-assignee"
                                    options={OWNER_OPTIONS}
                                    value={OWNER_OPTIONS.find(
                                        (option) =>
                                            option.value ===
                                            taskDialog.assignee.src,
                                    )}
                                    customInputDisplay={(selected) =>
                                        selected ? (
                                            <span className="flex min-w-0 items-center gap-2">
                                                <Avatar
                                                    alt={selected.owner.alt}
                                                    className="border-card"
                                                    size={18}
                                                    src={selected.owner.src}
                                                />
                                                <span className="truncate">
                                                    {selected.label}
                                                </span>
                                            </span>
                                        ) : null
                                    }
                                    customOption={({
                                        option,
                                        selected,
                                        CheckIcon,
                                    }) => (
                                        <div className="flex min-w-0 items-center gap-2">
                                            <Avatar
                                                alt={option.owner.alt}
                                                className="border-card"
                                                size={18}
                                                src={option.owner.src}
                                            />
                                            <span className="min-w-0 flex-1 truncate">
                                                {option.label}
                                            </span>
                                            {selected ? (
                                                <span className="ml-auto shrink-0 text-primary">
                                                    {CheckIcon}
                                                </span>
                                            ) : null}
                                        </div>
                                    )}
                                    onChange={(option) =>
                                        option &&
                                        updateTaskDialog(
                                            'assignee',
                                            option.owner,
                                        )
                                    }
                                />
                            </div>
                            <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
                                <div>
                                    <label
                                        className="mb-2 block text-sm font-medium text-foreground"
                                        id="gantt-build-schedule-task-start-label"
                                    >
                                        Start
                                    </label>
                                    <DatePicker
                                        aria-labelledby="gantt-build-schedule-task-start-label"
                                        clearable={false}
                                        inputFormat="MMM D, YYYY"
                                        name="gantt-build-schedule-task-start"
                                        value={taskDialog.start}
                                        onChange={(date) =>
                                            date &&
                                            updateTaskDialog('start', date)
                                        }
                                    />
                                </div>
                                <div>
                                    <label
                                        className="mb-2 block text-sm font-medium text-foreground"
                                        id="gantt-build-schedule-task-due-label"
                                    >
                                        Due
                                    </label>
                                    <DatePicker
                                        aria-labelledby="gantt-build-schedule-task-due-label"
                                        clearable={false}
                                        inputFormat="MMM D, YYYY"
                                        minDate={taskDialog.start}
                                        name="gantt-build-schedule-task-due"
                                        value={taskDialog.end}
                                        onChange={(date) =>
                                            date && updateTaskDialog('end', date)
                                        }
                                    />
                                </div>
                            </div>
                        </div>
                    </div>
                    <footer className="flex flex-wrap items-center justify-end gap-2 border-t p-4">
                        <Button
                            type="button"
                            onClick={() => setTaskDialog(null)}
                        >
                            Cancel
                        </Button>
                        <Button
                            disabled={!taskDialog.title.trim()}
                            type="button"
                            variant="solid"
                            onClick={handleTaskDialogSave}
                        >
                            {taskDialog.mode === 'add' ? 'Add task' : 'Save'}
                        </Button>
                    </footer>
                </Dialog>
            ) : null}
        </section>
    )
}

Gantt 03

Preview
npx nateui@latest add GanttShiftRoster
Dark
import { useCallback, useMemo, useState } from 'react'
import { PiCaretLeft, PiCaretRight, PiFunnel } from 'react-icons/pi'
import Gantt, {
    ViewMode,
    type BarTask,
    type Columns,
    type Task,
} from '@/components/composites/Gantt'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'

const assetBase = 'https://statics.nateui.com/img'
const HOUR_IN_MS = 60 * 60 * 1000

type ShiftEmployee = {
    name: string
    avatarSrc?: string
    initials?: string
}

type ShiftFixture = ShiftEmployee & {
    id: string
    startHour: number
    endHour: number
}

type ShiftTaskMeta = {
    employee: ShiftEmployee
}

type ShiftTaskOverride = {
    startHour: number
    endHour: number
}

const SHIFT_FIXTURES: ShiftFixture[] = [
    {
        id: 'shift-avery-morgan',
        name: 'Avery Morgan',
        initials: 'AM',
        startHour: 10,
        endHour: 16,
    },
    {
        id: 'shift-jordan-lee',
        name: 'Jordan Lee',
        avatarSrc: `${assetBase}/avatars/thumb-2.jpg`,
        startHour: 11,
        endHour: 17,
    },
    {
        id: 'shift-riley-chen',
        name: 'Riley Chen',
        avatarSrc: `${assetBase}/avatars/thumb-3.jpg`,
        startHour: 12,
        endHour: 18,
    },
    {
        id: 'shift-casey-patel',
        name: 'Casey Patel',
        avatarSrc: `${assetBase}/avatars/thumb-4.jpg`,
        startHour: 9,
        endHour: 15,
    },
    {
        id: 'shift-morgan-ellis',
        name: 'Morgan Ellis',
        avatarSrc: `${assetBase}/avatars/thumb-5.jpg`,
        startHour: 9,
        endHour: 19,
    },
    {
        id: 'shift-taylor-brooks',
        name: 'Taylor Brooks',
        avatarSrc: `${assetBase}/avatars/thumb-6.jpg`,
        startHour: 11,
        endHour: 15,
    },
    {
        id: 'shift-jamie-rivera',
        name: 'Jamie Rivera',
        avatarSrc: `${assetBase}/avatars/thumb-7.jpg`,
        startHour: 12,
        endHour: 18,
    },
    {
        id: 'shift-cameron-wells',
        name: 'Cameron Wells',
        avatarSrc: `${assetBase}/avatars/thumb-8.jpg`,
        startHour: 10,
        endHour: 16,
    },
    {
        id: 'shift-drew-kim',
        name: 'Drew Kim',
        avatarSrc: `${assetBase}/avatars/thumb-9.jpg`,
        startHour: 10,
        endHour: 16,
    },
    {
        id: 'shift-quinn-parker',
        name: 'Quinn Parker',
        avatarSrc: `${assetBase}/avatars/thumb-10.jpg`,
        startHour: 10,
        endHour: 16,
    },
    {
        id: 'shift-reese-martin',
        name: 'Reese Martin',
        avatarSrc: `${assetBase}/avatars/thumb-11.jpg`,
        startHour: 10,
        endHour: 16,
    },
    {
        id: 'shift-alex-harper',
        name: 'Alex Harper',
        avatarSrc: `${assetBase}/avatars/thumb-12.jpg`,
        startHour: 12,
        endHour: 18,
    },
]

const SHIFT_ROSTER_TWO: ShiftFixture[] = [
    {
        id: 'shift-noah-bennett',
        name: 'Noah Bennett',
        initials: 'NB',
        startHour: 8,
        endHour: 14,
    },
    {
        id: 'shift-mia-alvarez',
        name: 'Mia Alvarez',
        avatarSrc: `${assetBase}/avatars/thumb-1.jpg`,
        startHour: 9,
        endHour: 15,
    },
    {
        id: 'shift-owen-carter',
        name: 'Owen Carter',
        avatarSrc: `${assetBase}/avatars/thumb-2.jpg`,
        startHour: 10,
        endHour: 16,
    },
    {
        id: 'shift-sofia-nguyen',
        name: 'Sofia Nguyen',
        avatarSrc: `${assetBase}/avatars/thumb-3.jpg`,
        startHour: 11,
        endHour: 19,
    },
    {
        id: 'shift-ethan-brooks',
        name: 'Ethan Brooks',
        avatarSrc: `${assetBase}/avatars/thumb-4.jpg`,
        startHour: 12,
        endHour: 18,
    },
    {
        id: 'shift-lena-foster',
        name: 'Lena Foster',
        avatarSrc: `${assetBase}/avatars/thumb-5.jpg`,
        startHour: 8,
        endHour: 14,
    },
    {
        id: 'shift-kai-morgan',
        name: 'Kai Morgan',
        avatarSrc: `${assetBase}/avatars/thumb-6.jpg`,
        startHour: 9,
        endHour: 17,
    },
    {
        id: 'shift-priya-shah',
        name: 'Priya Shah',
        avatarSrc: `${assetBase}/avatars/thumb-7.jpg`,
        startHour: 10,
        endHour: 16,
    },
    {
        id: 'shift-theo-martin',
        name: 'Theo Martin',
        avatarSrc: `${assetBase}/avatars/thumb-8.jpg`,
        startHour: 11,
        endHour: 17,
    },
    {
        id: 'shift-zoe-kim',
        name: 'Zoe Kim',
        avatarSrc: `${assetBase}/avatars/thumb-9.jpg`,
        startHour: 12,
        endHour: 18,
    },
]

const SHIFT_ROSTER_THREE: ShiftFixture[] = [
    {
        id: 'shift-harper-stone',
        name: 'Harper Stone',
        initials: 'HS',
        startHour: 7,
        endHour: 13,
    },
    {
        id: 'shift-elio-park',
        name: 'Elio Park',
        avatarSrc: `${assetBase}/avatars/thumb-2.jpg`,
        startHour: 8,
        endHour: 16,
    },
    {
        id: 'shift-nina-cole',
        name: 'Nina Cole',
        avatarSrc: `${assetBase}/avatars/thumb-3.jpg`,
        startHour: 9,
        endHour: 15,
    },
    {
        id: 'shift-samir-khan',
        name: 'Samir Khan',
        avatarSrc: `${assetBase}/avatars/thumb-4.jpg`,
        startHour: 10,
        endHour: 18,
    },
    {
        id: 'shift-maya-chen',
        name: 'Maya Chen',
        avatarSrc: `${assetBase}/avatars/thumb-5.jpg`,
        startHour: 11,
        endHour: 17,
    },
    {
        id: 'shift-luca-reed',
        name: 'Luca Reed',
        avatarSrc: `${assetBase}/avatars/thumb-6.jpg`,
        startHour: 12,
        endHour: 18,
    },
    {
        id: 'shift-tessa-young',
        name: 'Tessa Young',
        avatarSrc: `${assetBase}/avatars/thumb-7.jpg`,
        startHour: 7,
        endHour: 13,
    },
    {
        id: 'shift-iris-hall',
        name: 'Iris Hall',
        avatarSrc: `${assetBase}/avatars/thumb-8.jpg`,
        startHour: 8,
        endHour: 14,
    },
    {
        id: 'shift-jon-bell',
        name: 'Jon Bell',
        avatarSrc: `${assetBase}/avatars/thumb-9.jpg`,
        startHour: 9,
        endHour: 19,
    },
    {
        id: 'shift-wren-davis',
        name: 'Wren Davis',
        avatarSrc: `${assetBase}/avatars/thumb-10.jpg`,
        startHour: 10,
        endHour: 16,
    },
    {
        id: 'shift-parker-wright',
        name: 'Parker Wright',
        avatarSrc: `${assetBase}/avatars/thumb-11.jpg`,
        startHour: 11,
        endHour: 17,
    },
]

const SHIFT_DATASETS = [
    SHIFT_FIXTURES,
    SHIFT_ROSTER_TWO,
    SHIFT_ROSTER_THREE,
]

const startOfLocalDay = (date: Date) => {
    const day = new Date(date)
    day.setHours(0, 0, 0, 0)
    return day
}

const getDatasetIndex = (date: Date, anchorDate: Date) => {
    const dayOffset = Math.round(
        (startOfLocalDay(date).getTime() - anchorDate.getTime()) /
            (24 * 60 * 60 * 1000),
    )
    return (
        ((dayOffset % SHIFT_DATASETS.length) + SHIFT_DATASETS.length) %
        SHIFT_DATASETS.length
    )
}

const dateAtOffset = (day: Date, hourOffset: number) => {
    const date = new Date(day)
    const wholeHours = Math.trunc(hourOffset)
    const minutes = Math.round((hourOffset - wholeHours) * 60)
    date.setHours(wholeHours, minutes, 0, 0)
    return date
}

const getHourOffset = (date: Date, day: Date) =>
    Math.round(((date.getTime() - day.getTime()) / HOUR_IN_MS) * 12) / 12

const formatSelectedDate = (date: Date) =>
    new Intl.DateTimeFormat('en-US', {
        day: 'numeric',
        month: 'short',
        year: 'numeric',
    }).format(date)

const formatTime = (date: Date) =>
    new Intl.DateTimeFormat('en-US', {
        hour: 'numeric',
        minute: '2-digit',
    }).format(date)

const formatTimeRange = (start: Date, end: Date) =>
    `${formatTime(start)} – ${formatTime(end)}`

const getShiftDuration = (start: Date, end: Date) =>
    Math.round((end.getTime() - start.getTime()) / HOUR_IN_MS)

const ShiftBarContent = ({ task }: { task: BarTask<ShiftTaskMeta> }) => (
    <div className="flex h-full min-w-0 flex-1 items-center justify-between gap-2 text-xs font-medium text-palette-blue-soft-foreground">
        <span className="min-w-0 flex-1 truncate">
            {formatTimeRange(task.start, task.end)}
        </span>
        <span className="min-w-0 shrink truncate text-end">
            {getShiftDuration(task.start, task.end)}h
        </span>
    </div>
)

const ShiftTooltipContent = ({ task }: { task: Task }) => {
    const shiftTask = task as Task<ShiftTaskMeta>

    return (
        <div className="gantt-tooltip-default-container rounded-popover border bg-card p-4">
            <div className="font-medium">{shiftTask.name}</div>
            <div className="mt-2 text-muted-foreground text-xs">
                <p>
                    {formatTimeRange(shiftTask.start, shiftTask.end)}
                </p>
                <p className="mt-1">
                    Duration: {getShiftDuration(shiftTask.start, shiftTask.end)}{' '}
                    hours
                </p>
            </div>
        </div>
    )
}

export default function GanttShiftRoster() {
    const [anchorDate] = useState(() => startOfLocalDay(new Date()))
    const [selectedDate, setSelectedDate] = useState(anchorDate)
    const [taskOverrides, setTaskOverrides] = useState<
        Record<string, ShiftTaskOverride>
    >({})
    const activeDataset =
        SHIFT_DATASETS[getDatasetIndex(selectedDate, anchorDate)]

    const tasks = useMemo<Task<ShiftTaskMeta>[]>(
        () =>
            activeDataset.map((fixture, index) => {
                const employee: ShiftEmployee = {
                    name: fixture.name,
                    avatarSrc: fixture.avatarSrc,
                    initials: fixture.initials,
                }
                const override = taskOverrides[fixture.id]

                return {
                    id: fixture.id,
                    name: fixture.name,
                    type: 'task',
                    start: dateAtOffset(
                        selectedDate,
                        override?.startHour ?? fixture.startHour,
                    ),
                    end: dateAtOffset(
                        selectedDate,
                        override?.endHour ?? fixture.endHour,
                    ),
                    progress: 0,
                    displayOrder: index + 1,
                    employee,
                }
            }),
        [activeDataset, selectedDate, taskOverrides],
    )

    const columns = useMemo<Columns<ShiftTaskMeta>[]>(
        () => [
            {
                header: (
                    <div className="flex min-w-0 items-center justify-between gap-2">
                        <span className="truncate">Employee name</span>
                        <Button
                            aria-label="Filter employees"
                            icon={<PiFunnel aria-hidden="true" />}
                            shape="circle"
                            type="button"
                            variant="ghost"
                        />
                    </div>
                ),
                width: 260,
                cell: (task) => (
                    <div className="flex h-full min-w-0 items-center gap-2 px-4">
                        {task.employee.avatarSrc ? (
                            <Avatar
                                alt={task.employee.name}
                                size={24}
                                src={task.employee.avatarSrc}
                            />
                        ) : (
                            <Avatar
                                aria-label={`${task.employee.name} initials`}
                                role="img"
                                size={24}
                            >
                                {task.employee.initials}
                            </Avatar>
                        )}
                        <span className="min-w-0 truncate font-medium">
                            {task.name}
                        </span>
                    </div>
                ),
            },
        ],
        [],
    )

    const handleDateChange = useCallback(
        (changedTask: Task<ShiftTaskMeta>) => {
            setTaskOverrides((current) => ({
                ...current,
                [changedTask.id]: {
                    startHour: getHourOffset(changedTask.start, selectedDate),
                    endHour: getHourOffset(changedTask.end, selectedDate),
                },
            }))
        },
        [selectedDate],
    )

    const moveDate = (days: number) => {
        setSelectedDate((current) => {
            const next = new Date(current)
            next.setDate(next.getDate() + days)
            return next
        })
    }

    return (
        <section
            aria-labelledby="gantt-shift-roster-title"
            className="w-full min-w-0"
        >
            <div className="flex flex-wrap items-center justify-between gap-4 border-b px-4 py-4">
                <h5 id="gantt-shift-roster-title">Daily shift roster</h5>
                <div className="flex flex-wrap items-center gap-2 md:ml-auto">
                    <Button
                        type="button"
                        onClick={() => setSelectedDate(startOfLocalDay(new Date()))}
                    >
                        Today
                    </Button>
                    <div className="flex h-9 min-w-45 items-center justify-between rounded-control border bg-control px-1">
                        <Button
                            aria-label="Previous day"
                            className="shrink-0 size-6!"
                            icon={
                                <PiCaretLeft
                                    className="text-xs"
                                    aria-hidden="true"
                                />
                            }
                            type="button"
                            variant="ghost"
                            onClick={() => moveDate(-1)}
                        />
                        <span
                            aria-live="polite"
                            className="min-w-0 flex-1 truncate px-2 text-center text-control-foreground"
                        >
                            {formatSelectedDate(selectedDate)}
                        </span>
                        <Button
                            aria-label="Next day"
                            className="shrink-0 size-6!"
                            icon={
                                <PiCaretRight
                                    className="text-xs"
                                    aria-hidden="true"
                                />
                            }
                            type="button"
                            variant="ghost"
                            onClick={() => moveDate(1)}
                        />
                    </div>
                </div>
            </div>
            <div className="min-w-0 overflow-hidden">
                <Gantt<ShiftTaskMeta>
                    barCornerRadius={6}
                    barWrapperClass="fill-[#d8dff1] dark:fill-palette-blue-soft"
                    className="border-b"
                    defaultTaskListWidth={262}
                    ganttHeight={530}
                    gridColumnsWidth={64}
                    headerHeight={52}
                    locale="en-US"
                    onDateChange={handleDateChange}
                    rowHeight={44}
                    taskListMaxWidth={360}
                    taskListMinWidth={220}
                    tasks={tasks}
                    columns={columns}
                    customBarContent={(task) => (
                        <ShiftBarContent task={task} />
                    )}
                    TooltipContent={ShiftTooltipContent}
                    viewDate={selectedDate}
                    viewMode={ViewMode.Hour}
                />
            </div>
        </section>
    )
}

Gantt 04

Preview
npx nateui@latest add GanttLeaveCalendar
Dark
import { useMemo, useState } from 'react'
import {
    PiCaretDown,
    PiCaretLeft,
    PiCaretRight,
    PiCheck,
    PiConfetti,
    PiFirstAidKit,
    PiMagnifyingGlass,
    PiUmbrella,
    PiWarning,
} from 'react-icons/pi'
import Gantt, {
    ViewMode,
    type BarTask,
    type Columns,
    type Task,
} from '@/components/composites/Gantt'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import Dropdown from '@/components/ui/Dropdown'
import Input from '@/components/ui/Input'

const assetBase = 'https://statics.nateui.com/img'
const DAY_IN_MS = 24 * 60 * 60 * 1000

type LeaveTypeKey = 'paid' | 'sick' | 'casual' | 'emergency'
type LeaveTypeFilter = 'all' | LeaveTypeKey
type LeaveWeekOffset = -2 | -1 | 0 | 1 | 2
type TeamFilter =
    | 'all'
    | 'People'
    | 'Facilities'
    | 'Design'
    | 'Engineering'
    | 'Operations'
    | 'Research'
    | 'Support'
    | 'Finance'
type LeaveTypeOption = {
    label: string
    value: LeaveTypeFilter
    leaveType?: LeaveTypeKey
}

const LEAVE_TYPES = {
    paid: {
        label: 'Paid leave',
        icon: PiUmbrella,
        wrapperClass: 'fill-palette-emerald-soft',
        foregroundClass: 'text-palette-emerald-soft-foreground',
    },
    sick: {
        label: 'Sick leave',
        icon: PiFirstAidKit,
        wrapperClass: 'fill-palette-orange-soft',
        foregroundClass: 'text-palette-orange-soft-foreground',
    },
    casual: {
        label: 'Casual leave',
        icon: PiConfetti,
        wrapperClass: 'fill-palette-purple-soft',
        foregroundClass: 'text-palette-purple-soft-foreground',
    },
    emergency: {
        label: 'Emergency leave',
        icon: PiWarning,
        wrapperClass: 'fill-palette-blue-soft',
        foregroundClass: 'text-palette-blue-soft-foreground',
    },
} as const

type LeaveEmployee = {
    id: string
    name: string
    department: Exclude<TeamFilter, 'all'>
    avatarSrc: string
}

type LeaveFixture = {
    id: string
    employeeId: string
    leaveType: LeaveTypeKey
    startOffset: number
    durationDays: number
    note: string
}

type LeaveSeed = readonly [LeaveTypeKey, number, number, string]

type LeaveTaskMeta = {
    employee: LeaveEmployee
    leaveType: LeaveTypeKey
    note: string
}

const EMPLOYEES: LeaveEmployee[] = [
    {
        id: 'employee-avery',
        name: 'Avery Morgan',
        department: 'People',
        avatarSrc: `${assetBase}/avatars/thumb-1.jpg`,
    },
    {
        id: 'employee-jordan',
        name: 'Jordan Ellis',
        department: 'Facilities',
        avatarSrc: `${assetBase}/avatars/thumb-2.jpg`,
    },
    {
        id: 'employee-riley',
        name: 'Riley Chen',
        department: 'Design',
        avatarSrc: `${assetBase}/avatars/thumb-3.jpg`,
    },
    {
        id: 'employee-casey',
        name: 'Casey Patel',
        department: 'Engineering',
        avatarSrc: `${assetBase}/avatars/thumb-4.jpg`,
    },
    {
        id: 'employee-morgan',
        name: 'Morgan Brooks',
        department: 'Operations',
        avatarSrc: `${assetBase}/avatars/thumb-5.jpg`,
    },
    {
        id: 'employee-taylor',
        name: 'Taylor Ward',
        department: 'Research',
        avatarSrc: `${assetBase}/avatars/thumb-6.jpg`,
    },
    {
        id: 'employee-jamie',
        name: 'Jamie Foster',
        department: 'Support',
        avatarSrc: `${assetBase}/avatars/thumb-7.jpg`,
    },
    {
        id: 'employee-quinn',
        name: 'Quinn Harper',
        department: 'Finance',
        avatarSrc: `${assetBase}/avatars/thumb-8.jpg`,
    },
]

const MIN_WEEK_OFFSET: LeaveWeekOffset = -2
const MAX_WEEK_OFFSET: LeaveWeekOffset = 2

const LEAVE_SEEDS_BY_WEEK: Record<number, readonly LeaveSeed[]> = {
    [-2]: [
        ['paid', 0, 1, 'Family appointment.'],
        ['sick', 2, 2, ''],
        ['casual', 4, 2, ''],
        ['emergency', 1, 1, ''],
        ['paid', 5, 1, ''],
        ['sick', 3, 2, ''],
        ['casual', 0, 2, 'Travel day.'],
        ['emergency', 5, 2, ''],
    ],
    [-1]: [
        ['sick', 1, 2, ''],
        ['paid', 4, 1, ''],
        ['emergency', 0, 2, ''],
        ['casual', 3, 2, ''],
        ['sick', 5, 1, ''],
        ['paid', 2, 2, ''],
        ['emergency', 4, 2, ''],
        ['casual', 0, 1, ''],
    ],
    [0]: [
        ['paid', 1, 2, 'Personal appointment.'],
        ['sick', 3, 1, ''],
        ['casual', 5, 2, ''],
        ['sick', 1, 3, ''],
        ['paid', 0, 1, ''],
        ['casual', 2, 2, 'Travel day.'],
        ['emergency', 4, 1, ''],
        ['sick', 5, 2, 'Rest and recovery.'],
    ],
    [1]: [
        ['casual', 0, 2, ''],
        ['emergency', 2, 1, ''],
        ['paid', 4, 2, ''],
        ['sick', 3, 2, 'Rest and recovery.'],
        ['emergency', 5, 1, ''],
        ['paid', 1, 2, ''],
        ['casual', 2, 3, ''],
        ['sick', 0, 1, ''],
    ],
    [2]: [
        ['emergency', 4, 2, ''],
        ['casual', 1, 2, ''],
        ['sick', 3, 2, ''],
        ['paid', 0, 2, ''],
        ['casual', 5, 1, ''],
        ['emergency', 2, 2, ''],
        ['sick', 1, 3, ''],
        ['paid', 4, 2, 'Personal appointment.'],
    ],
}

const buildLeaveFixtures = (weekOffset: LeaveWeekOffset): LeaveFixture[] =>
    LEAVE_SEEDS_BY_WEEK[weekOffset].map(
        ([leaveType, startOffset, durationDays, note], index) => ({
            id: `leave-${weekOffset}-${EMPLOYEES[index].id}`,
            employeeId: EMPLOYEES[index].id,
            leaveType,
            startOffset,
            durationDays,
            note,
        }),
    )

const TEAM_OPTIONS: Array<{ label: string; value: TeamFilter }> = [
    { label: 'All teams', value: 'all' },
    { label: 'People', value: 'People' },
    { label: 'Facilities', value: 'Facilities' },
    { label: 'Design', value: 'Design' },
    { label: 'Engineering', value: 'Engineering' },
    { label: 'Operations', value: 'Operations' },
    { label: 'Research', value: 'Research' },
    { label: 'Support', value: 'Support' },
    { label: 'Finance', value: 'Finance' },
]

const LEAVE_TYPE_OPTIONS: LeaveTypeOption[] = [
    { label: 'All leave types', value: 'all' },
    {
        label: LEAVE_TYPES.paid.label,
        value: 'paid',
        leaveType: 'paid',
    },
    {
        label: LEAVE_TYPES.sick.label,
        value: 'sick',
        leaveType: 'sick',
    },
    {
        label: LEAVE_TYPES.casual.label,
        value: 'casual',
        leaveType: 'casual',
    },
    {
        label: LEAVE_TYPES.emergency.label,
        value: 'emergency',
        leaveType: 'emergency',
    },
]

const startOfWeek = (date: Date) => {
    const weekStart = new Date(date)
    weekStart.setHours(0, 0, 0, 0)
    const day = weekStart.getDay()
    weekStart.setDate(weekStart.getDate() + (day === 0 ? -6 : 1 - day))
    return weekStart
}

const dateAtOffset = (weekStart: Date, offset: number) => {
    const date = new Date(weekStart)
    date.setDate(date.getDate() + offset)
    return date
}

const formatWeekRange = (weekStart: Date) => {
    const weekEnd = new Date(weekStart.getTime() + 6 * DAY_IN_MS)
    const formatter = new Intl.DateTimeFormat('en-US', {
        day: 'numeric',
        month: 'short',
        year: 'numeric',
    })
    const startLabel = formatter.format(weekStart)
    const endLabel = formatter.format(weekEnd)

    return `${startLabel} – ${endLabel}`
}

const LeaveBarContent = ({ task }: { task: BarTask<LeaveTaskMeta> }) => {
    const leaveType = LEAVE_TYPES[task.leaveType]
    const Icon = leaveType.icon

    return (
        <div
            className={`flex h-full min-w-0 flex-1 flex-col justify-center gap-1 overflow-hidden ${leaveType.foregroundClass}`}
        >
            <div className="flex min-w-0 items-center gap-1 leading-tight">
                <Icon aria-hidden="true" className="shrink-0 text-base" />
                <span className="min-w-0 truncate font-semibold">
                    {leaveType.label}
                </span>
            </div>
            <span className="min-h-4 min-w-0 truncate text-xs opacity-80">
                {task.note || '\u00a0'}
            </span>
        </div>
    )
}

export default function GanttLeaveCalendar() {
    const [searchQuery, setSearchQuery] = useState('')
    const [teamFilter, setTeamFilter] = useState<TeamFilter>('all')
    const [leaveTypeFilter, setLeaveTypeFilter] =
        useState<LeaveTypeFilter>('all')
    const [anchorWeekStart] = useState(() => startOfWeek(new Date()))
    const [selectedWeekOffset, setSelectedWeekOffset] =
        useState<LeaveWeekOffset>(0)
    const selectedWeekStart = dateAtOffset(
        anchorWeekStart,
        selectedWeekOffset * 7,
    )

    const tasks = useMemo<Task<LeaveTaskMeta>[]>(() => {
        const normalizedQuery = searchQuery.trim().toLowerCase()
        const leaveFixtures = buildLeaveFixtures(selectedWeekOffset)

        return leaveFixtures.flatMap((fixture, index) => {
            const employee = EMPLOYEES.find(
                (item) => item.id === fixture.employeeId,
            )

            if (!employee) return []
            if (
                normalizedQuery &&
                !employee.name.toLowerCase().includes(normalizedQuery)
            ) {
                return []
            }
            if (teamFilter !== 'all' && employee.department !== teamFilter) {
                return []
            }
            if (
                leaveTypeFilter !== 'all' &&
                fixture.leaveType !== leaveTypeFilter
            ) {
                return []
            }

            return [
                {
                    id: fixture.id,
                    name: employee.name,
                    type: 'task',
                    start: dateAtOffset(
                        selectedWeekStart,
                        fixture.startOffset,
                    ),
                    end: dateAtOffset(
                        selectedWeekStart,
                        fixture.startOffset + fixture.durationDays,
                    ),
                    progress: 0,
                    displayOrder: index + 1,
                    employee,
                    leaveType: fixture.leaveType,
                    note: fixture.note,
                    styles: {
                        wrapperClass:
                            LEAVE_TYPES[fixture.leaveType].wrapperClass,
                    },
                },
            ]
        })
    }, [leaveTypeFilter, searchQuery, selectedWeekOffset, teamFilter])

    const columns = useMemo<Columns<LeaveTaskMeta>[]>(
        () => [
            {
                header: 'Team member',
                width: 224,
                cell: (task) => (
                    <div className="flex h-full min-w-0 items-center gap-2 px-4">
                        <Avatar
                            alt={task.employee.name}
                            size={28}
                            src={task.employee.avatarSrc}
                        />
                        <span className="min-w-0 truncate font-semibold">
                            {task.employee.name}
                        </span>
                    </div>
                ),
            },
            {
                header: 'Department',
                width: 136,
                cell: (task) => (
                    <div className="flex h-full min-w-0 items-center px-4">
                        <span className="min-w-0 truncate">
                            {task.employee.department}
                        </span>
                    </div>
                ),
            },
        ],
        [],
    )

    const moveWeek = (weekOffset: number) => {
        setSelectedWeekOffset((current) =>
            Math.max(
                MIN_WEEK_OFFSET,
                Math.min(MAX_WEEK_OFFSET, current + weekOffset),
            ) as LeaveWeekOffset,
        )
    }

    const selectedTeam =
        TEAM_OPTIONS.find((option) => option.value === teamFilter) ??
        TEAM_OPTIONS[0]
    const selectedLeaveType =
        LEAVE_TYPE_OPTIONS.find(
            (option) => option.value === leaveTypeFilter,
        ) ?? LEAVE_TYPE_OPTIONS[0]
    const selectedLeaveTypeMeta = selectedLeaveType.leaveType
        ? LEAVE_TYPES[selectedLeaveType.leaveType]
        : null
    const SelectedLeaveTypeIcon = selectedLeaveTypeMeta?.icon

    return (
        <section
            aria-labelledby="gantt-leave-calendar-title"
            className="w-full min-w-0"
        >
            <div className="space-y-4 border-b px-4 py-4">
                <h5 id="gantt-leave-calendar-title">Leave calendar</h5>
                <div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
                    <div className="min-w-0 w-full lg:w-80">
                        <label
                            className="sr-only"
                            htmlFor="gantt-leave-search"
                        >
                            Search employee
                        </label>
                        <Input
                            id="gantt-leave-search"
                            prefix={
                                <PiMagnifyingGlass aria-hidden="true" />
                            }
                            placeholder="Search employee"
                            value={searchQuery}
                            onChange={(event) =>
                                setSearchQuery(event.target.value)
                            }
                        />
                    </div>
                    <div className="flex w-full min-w-0 flex-wrap gap-2 lg:ml-auto lg:w-auto">
                        <Dropdown
                            activeKey={teamFilter}
                            placement="bottom-end"
                            onSelect={(eventKey) =>
                                setTeamFilter(eventKey as TeamFilter)
                            }
                            renderTitle={
                                <Button
                                    aria-label="Filter by team"
                                    className="justify-between"
                                    icon={
                                        <PiCaretDown aria-hidden="true" />
                                    }
                                    iconAlignment="end"
                                    type="button"
                                >
                                    {selectedTeam.label}
                                </Button>
                            }
                        >
                            {TEAM_OPTIONS.map((option) => (
                                <Dropdown.Item
                                    key={option.value}
                                    active={option.value === teamFilter}
                                    className="flex items-center justify-between gap-2"
                                    eventKey={option.value}
                                >
                                    <span className="truncate">
                                        {option.label}
                                    </span>
                                    <span
                                        aria-hidden="true"
                                        className="flex size-4 shrink-0 items-center justify-center"
                                    >
                                        {option.value === teamFilter ? (
                                            <PiCheck />
                                        ) : null}
                                    </span>
                                </Dropdown.Item>
                            ))}
                        </Dropdown>
                        <Dropdown
                            activeKey={leaveTypeFilter}
                            placement="bottom-end"
                            onSelect={(eventKey) =>
                                setLeaveTypeFilter(
                                    eventKey as LeaveTypeFilter,
                                )
                            }
                            renderTitle={
                                <Button
                                    aria-label="Filter by leave type"
                                    type="button"
                                >
                                    <span className="flex min-w-0 items-center gap-2">
                                        {SelectedLeaveTypeIcon ? (
                                            <SelectedLeaveTypeIcon
                                                aria-hidden="true"
                                                className={`shrink-0 text-base ${selectedLeaveTypeMeta?.foregroundClass ?? ''}`}
                                            />
                                        ) : null}
                                        <span className="truncate">
                                            {selectedLeaveType.label}
                                        </span>
                                        <PiCaretDown
                                            aria-hidden="true"
                                            className="shrink-0"
                                        />
                                    </span>
                                </Button>
                            }
                        >
                            {LEAVE_TYPE_OPTIONS.map((option) => {
                                const leaveType = option.leaveType
                                    ? LEAVE_TYPES[option.leaveType]
                                    : null
                                const Icon = leaveType?.icon

                                return (
                                    <Dropdown.Item
                                        key={option.value}
                                        active={
                                            option.value ===
                                            leaveTypeFilter
                                        }
                                        className="flex items-center justify-between gap-2"
                                        eventKey={option.value}
                                    >
                                        <span className="flex min-w-0 items-center gap-2">
                                            {Icon ? (
                                                <Icon
                                                    aria-hidden="true"
                                                    className={`shrink-0 text-base ${leaveType.foregroundClass}`}
                                                />
                                            ) : null}
                                            <span className="truncate">
                                                {option.label}
                                            </span>
                                        </span>
                                        <span
                                            aria-hidden="true"
                                            className="flex size-4 shrink-0 items-center justify-center"
                                        >
                                            {option.value ===
                                            leaveTypeFilter ? (
                                                <PiCheck />
                                            ) : null}
                                        </span>
                                    </Dropdown.Item>
                                )
                            })}
                        </Dropdown>
                        <div className="flex items-center justify-between rounded-control border bg-control px-1">
                            <Button
                                aria-label="Previous week"
                                className="shrink-0 size-6!"
                                disabled={selectedWeekOffset === MIN_WEEK_OFFSET}
                                icon={<PiCaretLeft className="text-xs" aria-hidden="true" />}
                                type="button"
                                variant="ghost"
                                onClick={() => moveWeek(-1)}
                            />
                            <span
                                aria-live="polite"
                                className="min-w-0 flex-1 truncate px-2 text-center text-control-foreground"
                            >
                                {formatWeekRange(selectedWeekStart)}
                            </span>
                            <Button
                                aria-label="Next week"
                                className="shrink-0 size-6!"
                                disabled={selectedWeekOffset === MAX_WEEK_OFFSET}
                                icon={<PiCaretRight className="text-xs" aria-hidden="true" />}
                                type="button"
                                variant="ghost"
                                onClick={() => moveWeek(1)}
                            />
                        </div>
                    </div>
                </div>
            </div>
            {tasks.length > 0 ? (
                <div className="min-w-0 overflow-hidden">
                    <Gantt<LeaveTaskMeta>
                        barCornerRadius={6}
                        barFill={88}
                        barWrapperClass="fill-muted"
                        className="border-b"
                        defaultTaskListWidth={370}
                        ganttHeight={520}
                        gridColumnsWidth={120}
                        headerHeight={64}
                        locale="en-US"
                        preStepsCount={7}
                        resizableTaskList
                        rowHeight={64}
                        taskListMaxWidth={420}
                        taskListMinWidth={300}
                        tasks={tasks}
                        columns={columns}
                        customBarContent={(task) => (
                            <LeaveBarContent task={task} />
                        )}
                        viewDate={selectedWeekStart}
                        viewMode={ViewMode.Day}
                    />
                </div>
            ) : (
                <div
                    className="flex min-h-48 items-center justify-center border-b px-4 py-8 text-center text-muted-foreground"
                    role="status"
                >
                    No leave matches these filters.
                </div>
            )}
        </section>
    )
}