feat(schedule): add collapsible phase grouping to gantt

Group tasks by construction phase with expand/collapse
support. Collapsed phases render as summary bars spanning
min-to-max dates with averaged progress. Client View
button collapses all phases for clean presentation.
This commit is contained in:
Nicholai Vogel 2026-01-23 21:13:38 -07:00
parent e6c1b7c4a0
commit 5cecffbce4
5 changed files with 360 additions and 57 deletions

View File

@ -148,6 +148,25 @@
fill: oklch(0.85 0.08 170 / 0.15);
}
/* summary bars (collapsed phase bars) */
.gantt .bar-wrapper[data-id^="phase-"] .bar {
rx: 6;
ry: 6;
stroke-width: 1.5;
opacity: 0.85;
}
.gantt .bar-wrapper[data-id^="phase-"] .bar-progress {
opacity: 0.9;
rx: 6;
ry: 6;
}
.gantt .bar-wrapper[data-id^="phase-"] .bar-label {
font-weight: 600;
font-size: 11px;
}
/* phase-specific bar colors */
.gantt .bar-wrapper.phase-foundation .bar { fill: oklch(0.7 0.12 240); }
.gantt .bar-wrapper.phase-framing .bar { fill: oklch(0.75 0.14 50); }

View File

@ -16,17 +16,26 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { IconPencil, IconPlus } from "@tabler/icons-react"
import {
IconPencil,
IconPlus,
IconChevronRight,
IconChevronDown,
IconUsers,
} from "@tabler/icons-react"
import { GanttChart } from "./gantt-chart"
import { TaskFormDialog } from "./task-form-dialog"
import { transformToFrappeTasks } from "@/lib/schedule/gantt-transform"
import {
transformToFrappeTasks,
transformWithPhaseGroups,
} from "@/lib/schedule/gantt-transform"
import type { DisplayItem, FrappeTask } from "@/lib/schedule/gantt-transform"
import { updateTask } from "@/app/actions/schedule"
import { countBusinessDays } from "@/lib/schedule/business-days"
import type {
ScheduleTaskData,
TaskDependencyData,
} from "@/lib/schedule/types"
import type { FrappeTask } from "@/lib/schedule/gantt-transform"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { format } from "date-fns"
@ -46,7 +55,10 @@ export function ScheduleGanttView({
}: ScheduleGanttViewProps) {
const router = useRouter()
const [viewMode, setViewMode] = useState<ViewMode>("Week")
const [showPhases, setShowPhases] = useState(false)
const [phaseGrouping, setPhaseGrouping] = useState<"off" | "grouped">("off")
const [collapsedPhases, setCollapsedPhases] = useState<Set<string>>(
new Set()
)
const [showCriticalPath, setShowCriticalPath] = useState(false)
const [taskFormOpen, setTaskFormOpen] = useState(false)
const [editingTask, setEditingTask] = useState<ScheduleTaskData | null>(
@ -57,10 +69,39 @@ export function ScheduleGanttView({
? tasks.filter((t) => t.isCriticalPath)
: tasks
const frappeTasks = transformToFrappeTasks(filteredTasks, dependencies)
const isGrouped = phaseGrouping === "grouped"
const { frappeTasks, displayItems } = isGrouped
? transformWithPhaseGroups(filteredTasks, dependencies, collapsedPhases)
: {
frappeTasks: transformToFrappeTasks(filteredTasks, dependencies),
displayItems: filteredTasks.map(
(task): DisplayItem => ({ type: "task", task })
),
}
const togglePhase = (phase: string) => {
setCollapsedPhases((prev) => {
const next = new Set(prev)
if (next.has(phase)) next.delete(phase)
else next.add(phase)
return next
})
}
const toggleClientView = () => {
if (phaseGrouping === "grouped") {
setPhaseGrouping("off")
setCollapsedPhases(new Set())
} else {
setPhaseGrouping("grouped")
const allPhases = new Set(filteredTasks.map((t) => t.phase || "uncategorized"))
setCollapsedPhases(allPhases)
}
}
const handleDateChange = useCallback(
async (task: FrappeTask, start: Date, end: Date) => {
if (task.id.startsWith("phase-")) return
const startDate = format(start, "yyyy-MM-dd")
const endDate = format(end, "yyyy-MM-dd")
const workdays = countBusinessDays(startDate, endDate)
@ -111,8 +152,11 @@ export function ScheduleGanttView({
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5">
<Switch
checked={showPhases}
onCheckedChange={setShowPhases}
checked={isGrouped}
onCheckedChange={(checked) => {
setPhaseGrouping(checked ? "grouped" : "off")
if (!checked) setCollapsedPhases(new Set())
}}
className="scale-75"
/>
<span className="text-xs text-muted-foreground">
@ -129,6 +173,14 @@ export function ScheduleGanttView({
Critical Path
</span>
</div>
<Button
variant={phaseGrouping === "grouped" && collapsedPhases.size > 0 ? "default" : "outline"}
size="sm"
onClick={toggleClientView}
>
<IconUsers className="size-3.5 mr-1" />
Client View
</Button>
</div>
</div>
@ -152,40 +204,77 @@ export function ScheduleGanttView({
</TableRow>
</TableHeader>
<TableBody>
{filteredTasks.map((task) => (
<TableRow key={task.id}>
<TableCell className="text-xs py-1.5 truncate max-w-[140px]">
<span
className={
showPhases
? "border-l-2 pl-1.5 border-primary"
: ""
}
{displayItems.map((item) => {
if (item.type === "phase-header") {
const { phase, group, collapsed } = item
return (
<TableRow
key={`phase-${phase}`}
className="bg-muted/40 cursor-pointer hover:bg-muted/60"
onClick={() => togglePhase(phase)}
>
{task.title}
</span>
</TableCell>
<TableCell className="text-xs py-1.5 text-muted-foreground">
{task.startDate.slice(5)}
</TableCell>
<TableCell className="text-xs py-1.5">
{task.workdays}
</TableCell>
<TableCell className="py-1.5">
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => {
setEditingTask(task)
setTaskFormOpen(true)
}}
>
<IconPencil className="size-3" />
</Button>
</TableCell>
</TableRow>
))}
<TableCell
colSpan={collapsed ? 4 : 1}
className="text-xs py-1.5 font-medium"
>
<span className="flex items-center gap-1">
{collapsed
? <IconChevronRight className="size-3.5" />
: <IconChevronDown className="size-3.5" />}
{group.label}
<span className="text-muted-foreground font-normal ml-1">
({group.tasks.length})
</span>
{collapsed && (
<span className="text-muted-foreground font-normal ml-auto text-[10px]">
{group.startDate.slice(5)} {group.endDate.slice(5)}
</span>
)}
</span>
</TableCell>
{!collapsed && (
<>
<TableCell className="text-xs py-1.5 text-muted-foreground">
{group.startDate.slice(5)}
</TableCell>
<TableCell className="text-xs py-1.5" />
<TableCell className="py-1.5" />
</>
)}
</TableRow>
)
}
const { task } = item
return (
<TableRow key={task.id}>
<TableCell className="text-xs py-1.5 truncate max-w-[140px]">
<span className={isGrouped ? "pl-4" : ""}>
{task.title}
</span>
</TableCell>
<TableCell className="text-xs py-1.5 text-muted-foreground">
{task.startDate.slice(5)}
</TableCell>
<TableCell className="text-xs py-1.5">
{task.workdays}
</TableCell>
<TableCell className="py-1.5">
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => {
setEditingTask(task)
setTaskFormOpen(true)
}}
>
<IconPencil className="size-3" />
</Button>
</TableCell>
</TableRow>
)
})}
<TableRow>
<TableCell colSpan={4} className="py-1">
<Button

View File

@ -39,25 +39,15 @@ import { Switch } from "@/components/ui/switch"
import { Button } from "@/components/ui/button"
import { createTask, updateTask } from "@/app/actions/schedule"
import { calculateEndDate } from "@/lib/schedule/business-days"
import type { ScheduleTaskData, ConstructionPhase } from "@/lib/schedule/types"
import type { ScheduleTaskData } from "@/lib/schedule/types"
import { PHASE_ORDER, PHASE_LABELS } from "@/lib/schedule/phase-colors"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
const phases: { value: ConstructionPhase; label: string }[] = [
{ value: "preconstruction", label: "Preconstruction" },
{ value: "sitework", label: "Sitework" },
{ value: "foundation", label: "Foundation" },
{ value: "framing", label: "Framing" },
{ value: "roofing", label: "Roofing" },
{ value: "electrical", label: "Electrical" },
{ value: "plumbing", label: "Plumbing" },
{ value: "hvac", label: "HVAC" },
{ value: "insulation", label: "Insulation" },
{ value: "drywall", label: "Drywall" },
{ value: "finish", label: "Finish" },
{ value: "landscaping", label: "Landscaping" },
{ value: "closeout", label: "Closeout" },
]
const phases = PHASE_ORDER.map((value) => ({
value,
label: PHASE_LABELS[value],
}))
const taskSchema = z.object({
title: z.string().min(1, "Title is required"),

View File

@ -1,4 +1,9 @@
import type { ScheduleTaskData, TaskDependencyData } from "./types"
import type {
ScheduleTaskData,
TaskDependencyData,
ConstructionPhase,
} from "./types"
import { PHASE_ORDER, PHASE_LABELS } from "./phase-colors"
export interface FrappeTask {
id: string
@ -49,3 +54,171 @@ export function transformToFrappeTasks(
}
})
}
export interface PhaseGroup {
phase: string
label: string
tasks: ScheduleTaskData[]
startDate: string
endDate: string
progress: number
isComplete: boolean
}
export type DisplayItem =
| { type: "phase-header"; phase: string; group: PhaseGroup; collapsed: boolean }
| { type: "task"; task: ScheduleTaskData }
export function groupTasksByPhase(
tasks: ScheduleTaskData[]
): PhaseGroup[] {
const byPhase = new Map<string, ScheduleTaskData[]>()
for (const task of tasks) {
const phase = task.phase || "uncategorized"
if (!byPhase.has(phase)) byPhase.set(phase, [])
byPhase.get(phase)!.push(task)
}
const groups: PhaseGroup[] = []
const orderedPhases: string[] = [
...PHASE_ORDER.filter((p) => byPhase.has(p)),
...Array.from(byPhase.keys()).filter(
(p) => !PHASE_ORDER.includes(p as ConstructionPhase)
),
]
for (const phase of orderedPhases) {
const phaseTasks = byPhase.get(phase)!
const starts = phaseTasks.map((t) => t.startDate).sort()
const ends = phaseTasks.map((t) => t.endDateCalculated).sort()
const avgProgress = Math.round(
phaseTasks.reduce((sum, t) => sum + t.percentComplete, 0) /
phaseTasks.length
)
groups.push({
phase,
label: PHASE_LABELS[phase as ConstructionPhase] ?? phase,
tasks: phaseTasks,
startDate: starts[0],
endDate: ends[ends.length - 1],
progress: avgProgress,
isComplete: phaseTasks.every((t) => t.status === "COMPLETE"),
})
}
return groups
}
function derivePhaseDeps(
phase: string,
groups: PhaseGroup[],
dependencies: TaskDependencyData[],
collapsedPhases: Set<string>
): string[] {
const group = groups.find((g) => g.phase === phase)
if (!group) return []
const taskIds = new Set(group.tasks.map((t) => t.id))
const predecessorPhases = new Set<string>()
for (const dep of dependencies) {
if (dep.type !== "FS") continue
if (!taskIds.has(dep.successorId)) continue
if (taskIds.has(dep.predecessorId)) continue
const predGroup = groups.find((g) =>
g.tasks.some((t) => t.id === dep.predecessorId)
)
if (predGroup && collapsedPhases.has(predGroup.phase)) {
predecessorPhases.add(`phase-${predGroup.phase}`)
}
}
return Array.from(predecessorPhases)
}
export interface PhaseTransformResult {
frappeTasks: FrappeTask[]
displayItems: DisplayItem[]
}
export function transformWithPhaseGroups(
tasks: ScheduleTaskData[],
dependencies: TaskDependencyData[],
collapsedPhases: Set<string>
): PhaseTransformResult {
const groups = groupTasksByPhase(tasks)
const frappeTasks: FrappeTask[] = []
const displayItems: DisplayItem[] = []
const predMap = new Map<string, string[]>()
for (const dep of dependencies) {
if (dep.type !== "FS") continue
if (!predMap.has(dep.successorId)) predMap.set(dep.successorId, [])
predMap.get(dep.successorId)!.push(dep.predecessorId)
}
for (const group of groups) {
const collapsed = collapsedPhases.has(group.phase)
displayItems.push({
type: "phase-header",
phase: group.phase,
group,
collapsed,
})
if (collapsed) {
const phaseDeps = derivePhaseDeps(
group.phase, groups, dependencies, collapsedPhases
)
frappeTasks.push({
id: `phase-${group.phase}`,
name: group.label,
start: group.startDate,
end: group.endDate,
progress: group.progress,
dependencies: phaseDeps.join(", "),
custom_class: `phase-${group.phase}`,
})
} else {
for (const task of group.tasks) {
displayItems.push({ type: "task", task })
const rawPreds = predMap.get(task.id) || []
const resolvedPreds = rawPreds.map((predId) => {
const predGroup = groups.find((g) =>
g.tasks.some((t) => t.id === predId)
)
if (predGroup && collapsedPhases.has(predGroup.phase)) {
return `phase-${predGroup.phase}`
}
return predId
})
const depString = [...new Set(resolvedPreds)].join(", ")
let progress = 0
if (task.status === "COMPLETE") progress = 100
else if (task.status === "IN_PROGRESS") progress = 50
let customClass = `phase-${task.phase}`
if (task.isCriticalPath) customClass = "critical-path"
if (task.isMilestone) customClass = "milestone"
frappeTasks.push({
id: task.id,
name: task.title,
start: task.startDate,
end: task.endDateCalculated,
progress,
dependencies: depString,
custom_class: customClass,
})
}
}
}
return { frappeTasks, displayItems }
}

View File

@ -1,5 +1,37 @@
import type { ConstructionPhase } from "./types"
export const PHASE_ORDER: ConstructionPhase[] = [
"preconstruction",
"sitework",
"foundation",
"framing",
"roofing",
"electrical",
"plumbing",
"hvac",
"insulation",
"drywall",
"finish",
"landscaping",
"closeout",
]
export const PHASE_LABELS: Record<ConstructionPhase, string> = {
preconstruction: "Preconstruction",
sitework: "Sitework",
foundation: "Foundation",
framing: "Framing",
roofing: "Roofing",
electrical: "Electrical",
plumbing: "Plumbing",
hvac: "HVAC",
insulation: "Insulation",
drywall: "Drywall",
finish: "Finish",
landscaping: "Landscaping",
closeout: "Closeout",
}
export const phaseColors: Record<ConstructionPhase, {
bg: string
text: string