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:
parent
e6c1b7c4a0
commit
5cecffbce4
@ -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); }
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user