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);
|
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 */
|
/* phase-specific bar colors */
|
||||||
.gantt .bar-wrapper.phase-foundation .bar { fill: oklch(0.7 0.12 240); }
|
.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); }
|
.gantt .bar-wrapper.phase-framing .bar { fill: oklch(0.75 0.14 50); }
|
||||||
|
|||||||
@ -16,17 +16,26 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} 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 { GanttChart } from "./gantt-chart"
|
||||||
import { TaskFormDialog } from "./task-form-dialog"
|
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 { updateTask } from "@/app/actions/schedule"
|
||||||
import { countBusinessDays } from "@/lib/schedule/business-days"
|
import { countBusinessDays } from "@/lib/schedule/business-days"
|
||||||
import type {
|
import type {
|
||||||
ScheduleTaskData,
|
ScheduleTaskData,
|
||||||
TaskDependencyData,
|
TaskDependencyData,
|
||||||
} from "@/lib/schedule/types"
|
} from "@/lib/schedule/types"
|
||||||
import type { FrappeTask } from "@/lib/schedule/gantt-transform"
|
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
@ -46,7 +55,10 @@ export function ScheduleGanttView({
|
|||||||
}: ScheduleGanttViewProps) {
|
}: ScheduleGanttViewProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>("Week")
|
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 [showCriticalPath, setShowCriticalPath] = useState(false)
|
||||||
const [taskFormOpen, setTaskFormOpen] = useState(false)
|
const [taskFormOpen, setTaskFormOpen] = useState(false)
|
||||||
const [editingTask, setEditingTask] = useState<ScheduleTaskData | null>(
|
const [editingTask, setEditingTask] = useState<ScheduleTaskData | null>(
|
||||||
@ -57,10 +69,39 @@ export function ScheduleGanttView({
|
|||||||
? tasks.filter((t) => t.isCriticalPath)
|
? tasks.filter((t) => t.isCriticalPath)
|
||||||
: tasks
|
: 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(
|
const handleDateChange = useCallback(
|
||||||
async (task: FrappeTask, start: Date, end: Date) => {
|
async (task: FrappeTask, start: Date, end: Date) => {
|
||||||
|
if (task.id.startsWith("phase-")) return
|
||||||
const startDate = format(start, "yyyy-MM-dd")
|
const startDate = format(start, "yyyy-MM-dd")
|
||||||
const endDate = format(end, "yyyy-MM-dd")
|
const endDate = format(end, "yyyy-MM-dd")
|
||||||
const workdays = countBusinessDays(startDate, endDate)
|
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-4">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Switch
|
<Switch
|
||||||
checked={showPhases}
|
checked={isGrouped}
|
||||||
onCheckedChange={setShowPhases}
|
onCheckedChange={(checked) => {
|
||||||
|
setPhaseGrouping(checked ? "grouped" : "off")
|
||||||
|
if (!checked) setCollapsedPhases(new Set())
|
||||||
|
}}
|
||||||
className="scale-75"
|
className="scale-75"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
@ -129,6 +173,14 @@ export function ScheduleGanttView({
|
|||||||
Critical Path
|
Critical Path
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -152,40 +204,77 @@ export function ScheduleGanttView({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredTasks.map((task) => (
|
{displayItems.map((item) => {
|
||||||
<TableRow key={task.id}>
|
if (item.type === "phase-header") {
|
||||||
<TableCell className="text-xs py-1.5 truncate max-w-[140px]">
|
const { phase, group, collapsed } = item
|
||||||
<span
|
return (
|
||||||
className={
|
<TableRow
|
||||||
showPhases
|
key={`phase-${phase}`}
|
||||||
? "border-l-2 pl-1.5 border-primary"
|
className="bg-muted/40 cursor-pointer hover:bg-muted/60"
|
||||||
: ""
|
onClick={() => togglePhase(phase)}
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{task.title}
|
<TableCell
|
||||||
</span>
|
colSpan={collapsed ? 4 : 1}
|
||||||
</TableCell>
|
className="text-xs py-1.5 font-medium"
|
||||||
<TableCell className="text-xs py-1.5 text-muted-foreground">
|
>
|
||||||
{task.startDate.slice(5)}
|
<span className="flex items-center gap-1">
|
||||||
</TableCell>
|
{collapsed
|
||||||
<TableCell className="text-xs py-1.5">
|
? <IconChevronRight className="size-3.5" />
|
||||||
{task.workdays}
|
: <IconChevronDown className="size-3.5" />}
|
||||||
</TableCell>
|
{group.label}
|
||||||
<TableCell className="py-1.5">
|
<span className="text-muted-foreground font-normal ml-1">
|
||||||
<Button
|
({group.tasks.length})
|
||||||
variant="ghost"
|
</span>
|
||||||
size="icon"
|
{collapsed && (
|
||||||
className="size-6"
|
<span className="text-muted-foreground font-normal ml-auto text-[10px]">
|
||||||
onClick={() => {
|
{group.startDate.slice(5)} – {group.endDate.slice(5)}
|
||||||
setEditingTask(task)
|
</span>
|
||||||
setTaskFormOpen(true)
|
)}
|
||||||
}}
|
</span>
|
||||||
>
|
</TableCell>
|
||||||
<IconPencil className="size-3" />
|
{!collapsed && (
|
||||||
</Button>
|
<>
|
||||||
</TableCell>
|
<TableCell className="text-xs py-1.5 text-muted-foreground">
|
||||||
</TableRow>
|
{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>
|
<TableRow>
|
||||||
<TableCell colSpan={4} className="py-1">
|
<TableCell colSpan={4} className="py-1">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -39,25 +39,15 @@ import { Switch } from "@/components/ui/switch"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { createTask, updateTask } from "@/app/actions/schedule"
|
import { createTask, updateTask } from "@/app/actions/schedule"
|
||||||
import { calculateEndDate } from "@/lib/schedule/business-days"
|
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 { useRouter } from "next/navigation"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
const phases: { value: ConstructionPhase; label: string }[] = [
|
const phases = PHASE_ORDER.map((value) => ({
|
||||||
{ value: "preconstruction", label: "Preconstruction" },
|
value,
|
||||||
{ value: "sitework", label: "Sitework" },
|
label: PHASE_LABELS[value],
|
||||||
{ 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 taskSchema = z.object({
|
const taskSchema = z.object({
|
||||||
title: z.string().min(1, "Title is required"),
|
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 {
|
export interface FrappeTask {
|
||||||
id: string
|
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"
|
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, {
|
export const phaseColors: Record<ConstructionPhase, {
|
||||||
bg: string
|
bg: string
|
||||||
text: string
|
text: string
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user