From 5cecffbce4b87818fbf306fe17dbdd85ac9f2057 Mon Sep 17 00:00:00 2001 From: Nicholai Date: Fri, 23 Jan 2026 21:13:38 -0700 Subject: [PATCH] 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. --- src/components/schedule/gantt.css | 19 ++ .../schedule/schedule-gantt-view.tsx | 169 +++++++++++++---- src/components/schedule/task-form-dialog.tsx | 22 +-- src/lib/schedule/gantt-transform.ts | 175 +++++++++++++++++- src/lib/schedule/phase-colors.ts | 32 ++++ 5 files changed, 360 insertions(+), 57 deletions(-) diff --git a/src/components/schedule/gantt.css b/src/components/schedule/gantt.css index 1a1518c..f682b29 100755 --- a/src/components/schedule/gantt.css +++ b/src/components/schedule/gantt.css @@ -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); } diff --git a/src/components/schedule/schedule-gantt-view.tsx b/src/components/schedule/schedule-gantt-view.tsx index f9d935d..b836d91 100755 --- a/src/components/schedule/schedule-gantt-view.tsx +++ b/src/components/schedule/schedule-gantt-view.tsx @@ -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("Week") - const [showPhases, setShowPhases] = useState(false) + const [phaseGrouping, setPhaseGrouping] = useState<"off" | "grouped">("off") + const [collapsedPhases, setCollapsedPhases] = useState>( + new Set() + ) const [showCriticalPath, setShowCriticalPath] = useState(false) const [taskFormOpen, setTaskFormOpen] = useState(false) const [editingTask, setEditingTask] = useState( @@ -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({
{ + setPhaseGrouping(checked ? "grouped" : "off") + if (!checked) setCollapsedPhases(new Set()) + }} className="scale-75" /> @@ -129,6 +173,14 @@ export function ScheduleGanttView({ Critical Path
+
@@ -152,40 +204,77 @@ export function ScheduleGanttView({ - {filteredTasks.map((task) => ( - - - { + if (item.type === "phase-header") { + const { phase, group, collapsed } = item + return ( + togglePhase(phase)} > - {task.title} - - - - {task.startDate.slice(5)} - - - {task.workdays} - - - - - - ))} + + + {collapsed + ? + : } + {group.label} + + ({group.tasks.length}) + + {collapsed && ( + + {group.startDate.slice(5)} – {group.endDate.slice(5)} + + )} + + + {!collapsed && ( + <> + + {group.startDate.slice(5)} + + + + + )} + + ) + } + + const { task } = item + return ( + + + + {task.title} + + + + {task.startDate.slice(5)} + + + {task.workdays} + + + + + + ) + })}