Gantt
4 blocksGantt 01
Preview
npx nateui@latest add GanttProjectPlanDark
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 GanttBuildScheduleDark
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 GanttShiftRosterDark
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 GanttLeaveCalendarDark
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>
)
}