feat(agent): add schedule tools for AI agent

Add 6 schedule tools (getProjectSchedule, createScheduleTask,
updateScheduleTask, deleteScheduleTask, createScheduleDependency,
deleteScheduleDependency) so the agent can read and write project
schedules directly. Uses direct DB access to avoid server action
context issues with streamText callbacks. Includes system prompt
guidance for schedule workflows, phases, and dependency types.
This commit is contained in:
Nicholai Vogel 2026-02-16 13:58:40 -07:00
parent 5922dd9d3a
commit 4cebbb73e8
9 changed files with 2383 additions and 753 deletions

View File

@ -15,6 +15,8 @@ export function MainContent({
const isCollapsed = const isCollapsed =
pathname === "/dashboard" && !hasRenderedUI pathname === "/dashboard" && !hasRenderedUI
const isConversations = pathname?.startsWith("/dashboard/conversations") const isConversations = pathname?.startsWith("/dashboard/conversations")
const isSchedule = pathname?.includes("/schedule")
const needsFixedHeight = isConversations || isSchedule
return ( return (
<div <div
@ -24,7 +26,7 @@ export function MainContent({
"transition-[flex,opacity] duration-300 ease-in-out", "transition-[flex,opacity] duration-300 ease-in-out",
isCollapsed isCollapsed
? "flex-[0_0_0%] opacity-0 overflow-hidden pointer-events-none" ? "flex-[0_0_0%] opacity-0 overflow-hidden pointer-events-none"
: isConversations : needsFixedHeight
? "flex-1 overflow-hidden" ? "flex-1 overflow-hidden"
: "flex-1 overflow-y-auto pb-14 md:pb-0", : "flex-1 overflow-y-auto pb-14 md:pb-0",
classNameProp classNameProp
@ -32,7 +34,7 @@ export function MainContent({
> >
<div className={cn( <div className={cn(
"@container/main flex flex-1 flex-col min-w-0 min-h-0", "@container/main flex flex-1 flex-col min-w-0 min-h-0",
isConversations && "overflow-hidden" needsFixedHeight && "overflow-hidden"
)}> )}>
{children} {children}
</div> </div>

View File

@ -13,18 +13,12 @@ import {
isToday, isToday,
isSameMonth, isSameMonth,
isWeekend, isWeekend,
isSameDay,
parseISO, parseISO,
isWithinInterval, isWithinInterval,
differenceInCalendarDays,
} from "date-fns" } from "date-fns"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { import {
IconChevronLeft, IconChevronLeft,
IconChevronRight, IconChevronRight,
@ -35,14 +29,19 @@ import type {
} from "@/lib/schedule/types" } from "@/lib/schedule/types"
interface ScheduleCalendarViewProps { interface ScheduleCalendarViewProps {
projectId: string readonly projectId: string
tasks: ScheduleTaskData[] readonly tasks: readonly ScheduleTaskData[]
exceptions: WorkdayExceptionData[] readonly exceptions: readonly WorkdayExceptionData[]
} }
// How many task lanes to show before "+N more"
const MAX_LANES = 3
const LANE_HEIGHT = 22
const DAY_HEADER_HEIGHT = 24
function isExceptionDay( function isExceptionDay(
date: Date, date: Date,
exceptions: WorkdayExceptionData[] exceptions: readonly WorkdayExceptionData[]
): boolean { ): boolean {
return exceptions.some((ex) => { return exceptions.some((ex) => {
const start = parseISO(ex.startDate) const start = parseISO(ex.startDate)
@ -52,72 +51,171 @@ function isExceptionDay(
} }
function getTaskColor(task: ScheduleTaskData): string { function getTaskColor(task: ScheduleTaskData): string {
if (task.status === "COMPLETE") return "bg-green-500" if (task.status === "COMPLETE") return "bg-green-600/90 dark:bg-green-600/80"
if (task.status === "IN_PROGRESS") return "bg-blue-500" if (task.status === "IN_PROGRESS") return "bg-blue-600/90 dark:bg-blue-500/80"
if (task.status === "BLOCKED") return "bg-red-500" if (task.status === "BLOCKED") return "bg-red-600/90 dark:bg-red-500/80"
if (task.isCriticalPath) return "bg-orange-500" if (task.isCriticalPath) return "bg-orange-600/90 dark:bg-orange-500/80"
return "bg-gray-400" return "bg-muted-foreground/70"
} }
const MAX_VISIBLE_TASKS = 3 interface WeekTask {
readonly task: ScheduleTaskData
readonly startCol: number
readonly span: number
readonly lane: number
readonly isStart: boolean
readonly isEnd: boolean
}
interface WeekRow {
readonly days: readonly Date[]
readonly tasks: WeekTask[]
readonly maxLane: number
readonly overflowByDay: readonly number[]
}
function buildWeekRows(
calendarDays: readonly Date[],
tasks: readonly ScheduleTaskData[]
): WeekRow[] {
const weeks: {
days: Date[]
tasks: {
task: ScheduleTaskData
startCol: number
span: number
lane: number
isStart: boolean
isEnd: boolean
}[]
}[] = []
for (let i = 0; i < calendarDays.length; i += 7) {
weeks.push({
days: calendarDays.slice(i, i + 7) as Date[],
tasks: [],
})
}
// Place each task into the weeks it overlaps
for (const task of tasks) {
const taskStart = parseISO(task.startDate)
const taskEnd = parseISO(task.endDateCalculated)
for (const week of weeks) {
const weekStart = week.days[0]
const weekEnd = week.days[6]
// Check overlap: task must start on or before weekEnd,
// and end on or after weekStart
if (taskStart > weekEnd || taskEnd < weekStart) continue
const startCol = Math.max(
0,
differenceInCalendarDays(taskStart, weekStart)
)
const endCol = Math.min(
6,
differenceInCalendarDays(taskEnd, weekStart)
)
const span = endCol - startCol + 1
week.tasks.push({
task,
startCol,
span,
lane: 0,
isStart: taskStart >= weekStart,
isEnd: taskEnd <= weekEnd,
})
}
}
// Assign lanes using first-fit
return weeks.map((week) => {
// Sort: earlier start first, then longer tasks first (they anchor better)
week.tasks.sort(
(a, b) => a.startCol - b.startCol || b.span - a.span
)
const lanes: boolean[][] = []
for (const wt of week.tasks) {
let lane = 0
while (true) {
if (!lanes[lane]) lanes[lane] = Array(7).fill(false) as boolean[]
const cols = lanes[lane].slice(wt.startCol, wt.startCol + wt.span)
if (cols.every((occupied) => !occupied)) break
lane++
}
wt.lane = lane
if (!lanes[lane]) lanes[lane] = Array(7).fill(false) as boolean[]
for (let c = wt.startCol; c < wt.startCol + wt.span; c++) {
lanes[lane][c] = true
}
}
// Count overflow per day (tasks in lanes >= MAX_LANES)
const overflowByDay = Array(7).fill(0) as number[]
for (const wt of week.tasks) {
if (wt.lane >= MAX_LANES) {
for (let c = wt.startCol; c < wt.startCol + wt.span; c++) {
overflowByDay[c]++
}
}
}
const maxLane = week.tasks.reduce(
(max, wt) => Math.max(max, wt.lane),
-1
)
return {
days: week.days,
tasks: week.tasks,
maxLane: Math.min(maxLane, MAX_LANES - 1),
overflowByDay,
}
})
}
export function ScheduleCalendarView({ export function ScheduleCalendarView({
tasks, tasks,
exceptions, exceptions,
}: ScheduleCalendarViewProps) { }: ScheduleCalendarViewProps) {
const [currentDate, setCurrentDate] = useState(new Date()) const [currentDate, setCurrentDate] = useState(new Date())
const [expandedCells, setExpandedCells] = useState<Set<string>>(
new Set()
)
const monthStart = startOfMonth(currentDate) const monthStart = startOfMonth(currentDate)
const monthEnd = endOfMonth(currentDate) const monthEnd = endOfMonth(currentDate)
const calendarStart = startOfWeek(monthStart) const calendarStart = startOfWeek(monthStart)
const calendarEnd = endOfWeek(monthEnd) const calendarEnd = endOfWeek(monthEnd)
const days = useMemo( const calendarDays = useMemo(
() => eachDayOfInterval({ start: calendarStart, end: calendarEnd }), () => eachDayOfInterval({ start: calendarStart, end: calendarEnd }),
[calendarStart.getTime(), calendarEnd.getTime()] [calendarStart.getTime(), calendarEnd.getTime()]
) )
const tasksByDate = useMemo(() => { const weekRows = useMemo(
const map = new Map<string, ScheduleTaskData[]>() () => buildWeekRows(calendarDays, tasks),
for (const task of tasks) { [calendarDays, tasks]
const key = task.startDate )
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(task)
}
return map
}, [tasks])
const toggleExpand = (dateKey: string) => {
setExpandedCells((prev) => {
const next = new Set(prev)
if (next.has(dateKey)) {
next.delete(dateKey)
} else {
next.add(dateKey)
}
return next
})
}
return ( return (
<div className="flex flex-col flex-1 min-h-0"> <div className="flex flex-col flex-1 min-h-0">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-2"> {/* Calendar controls */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 mb-2">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setCurrentDate(new Date())} onClick={() => setCurrentDate(new Date())}
className="h-9" className="h-8 text-xs"
> >
Today Today
</Button> </Button>
<div className="flex items-center">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-9" className="size-8"
onClick={() => setCurrentDate(subMonths(currentDate, 1))} onClick={() => setCurrentDate(subMonths(currentDate, 1))}
> >
<IconChevronLeft className="size-4" /> <IconChevronLeft className="size-4" />
@ -125,106 +223,121 @@ export function ScheduleCalendarView({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-9" className="size-8"
onClick={() => setCurrentDate(addMonths(currentDate, 1))} onClick={() => setCurrentDate(addMonths(currentDate, 1))}
> >
<IconChevronRight className="size-4" /> <IconChevronRight className="size-4" />
</Button> </Button>
<h2 className="text-base sm:text-lg font-medium whitespace-nowrap">
{format(currentDate, "MMMM yyyy")}
</h2>
</div> </div>
<Select defaultValue="month"> <h2 className="text-sm font-medium">
<SelectTrigger className="h-9 w-28 text-sm"> {format(currentDate, "MMMM yyyy")}
<SelectValue /> </h2>
</SelectTrigger>
<SelectContent>
<SelectItem value="month">Month</SelectItem>
<SelectItem value="day">Day</SelectItem>
</SelectContent>
</Select>
</div> </div>
{/* Calendar grid */}
<div className="border rounded-md overflow-hidden flex flex-col flex-1 min-h-0"> <div className="border rounded-md overflow-hidden flex flex-col flex-1 min-h-0">
<div className="grid grid-cols-7 border-b"> {/* Weekday headers */}
{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map( <div className="grid grid-cols-7 border-b bg-muted/30">
(day) => ( {["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((day) => (
<div <div
key={day} key={day}
className="text-center text-xs font-medium text-muted-foreground py-2 border-r last:border-r-0" className="text-center text-[11px] font-medium text-muted-foreground py-1.5 border-r last:border-r-0"
> >
{day} {day}
</div> </div>
) ))}
)}
</div> </div>
<div className="grid grid-cols-7 flex-1"> {/* Week rows */}
{days.map((day) => { <div className="flex flex-col flex-1 min-h-0">
const dateKey = format(day, "yyyy-MM-dd") {weekRows.map((week, weekIdx) => {
const dayTasks = tasksByDate.get(dateKey) || [] const visibleLanes = Math.min(week.maxLane + 1, MAX_LANES)
const isNonWork = const contentHeight = DAY_HEADER_HEIGHT + visibleLanes * LANE_HEIGHT
isWeekend(day) || isExceptionDay(day, exceptions) const hasOverflow = week.overflowByDay.some((n) => n > 0)
const inMonth = isSameMonth(day, currentDate) const totalHeight = contentHeight + (hasOverflow ? 16 : 0)
const expanded = expandedCells.has(dateKey)
const visibleTasks = expanded
? dayTasks
: dayTasks.slice(0, MAX_VISIBLE_TASKS)
const overflow = dayTasks.length - MAX_VISIBLE_TASKS
return ( return (
<div <div
key={dateKey} key={weekIdx}
className={`min-h-[60px] sm:min-h-[80px] border-r border-b last:border-r-0 p-1 sm:p-1.5 ${ className="relative border-b last:border-b-0 flex-1"
!inMonth ? "bg-muted/30" : "" style={{ minHeight: `${Math.max(totalHeight, 60)}px` }}
} ${isNonWork ? "bg-muted/50" : ""}`}
> >
<div className="flex items-start justify-between mb-0.5 min-w-0"> {/* Day cells (background + day numbers) */}
<span <div className="grid grid-cols-7 absolute inset-0">
className={`text-xs shrink-0 ${ {week.days.map((day) => {
isToday(day) const inMonth = isSameMonth(day, currentDate)
? "bg-primary text-primary-foreground rounded-full size-5 sm:size-6 flex items-center justify-center font-bold" const isNonWork =
: inMonth isWeekend(day) || isExceptionDay(day, exceptions)
? "text-foreground"
: "text-muted-foreground" return (
}`} <div
> key={format(day, "yyyy-MM-dd")}
{format(day, "d")} className={cn(
</span> "border-r last:border-r-0 p-1",
{isNonWork && ( !inMonth && "bg-muted/20",
<span className="text-[8px] sm:text-[9px] text-muted-foreground truncate ml-1"> isNonWork && inMonth && "bg-muted/40",
<span className="hidden sm:inline">Non-workday</span> )}
<span className="sm:hidden">Off</span> >
</span> <span
)} className={cn(
"text-[11px] leading-none",
isToday(day)
? "bg-primary text-primary-foreground rounded-full size-5 inline-flex items-center justify-center font-bold"
: inMonth
? "text-foreground/80"
: "text-muted-foreground/50",
)}
>
{format(day, "d")}
</span>
</div>
)
})}
</div> </div>
<div className="space-y-0.5">
{visibleTasks.map((task) => ( {/* Task bars (overlaid) */}
{week.tasks
.filter((wt) => wt.lane < MAX_LANES)
.map((wt) => (
<div <div
key={task.id} key={`${wt.task.id}-${weekIdx}`}
className={`${getTaskColor(task)} text-white text-[9px] sm:text-[10px] px-1 py-0.5 rounded truncate`} className={cn(
title={task.title} "absolute text-[10px] text-white font-medium truncate px-1.5 leading-[20px] cursor-default",
getTaskColor(wt.task),
wt.isStart && wt.isEnd && "rounded",
wt.isStart && !wt.isEnd && "rounded-l",
!wt.isStart && wt.isEnd && "rounded-r",
!wt.isStart && !wt.isEnd && "rounded-none",
)}
style={{
top: `${DAY_HEADER_HEIGHT + wt.lane * LANE_HEIGHT}px`,
left: `${(wt.startCol / 7) * 100}%`,
width: `${(wt.span / 7) * 100}%`,
height: `${LANE_HEIGHT - 2}px`,
paddingLeft: wt.isStart ? "6px" : "2px",
}}
title={`${wt.task.title} (${wt.task.startDate} - ${wt.task.endDateCalculated})`}
> >
{task.title.length > 15 ? `${task.title.slice(0, 12)}...` : task.title} {wt.isStart ? wt.task.title : ""}
</div> </div>
))} ))}
{!expanded && overflow > 0 && (
<button {/* Overflow indicators */}
className="text-[9px] sm:text-[10px] text-primary hover:underline" {hasOverflow && (
onClick={() => toggleExpand(dateKey)} <div
> className="grid grid-cols-7 absolute left-0 right-0"
+{overflow} style={{ top: `${contentHeight}px` }}
</button> >
)} {week.overflowByDay.map((count, dayIdx) => (
{expanded && dayTasks.length > MAX_VISIBLE_TASKS && ( <div
<button key={dayIdx}
className="text-[9px] sm:text-[10px] text-primary hover:underline" className="text-[10px] text-primary px-1 border-r last:border-r-0"
onClick={() => toggleExpand(dateKey)} >
> {count > 0 ? `+${count} more` : ""}
Less </div>
</button> ))}
)} </div>
</div> )}
</div> </div>
) )
})} })}

View File

@ -26,7 +26,7 @@ import {
IconPlus, IconPlus,
IconChevronRight, IconChevronRight,
IconChevronDown, IconChevronDown,
IconUsers, IconSettings,
IconZoomIn, IconZoomIn,
IconZoomOut, IconZoomOut,
} from "@tabler/icons-react" } from "@tabler/icons-react"
@ -37,6 +37,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { cn } from "@/lib/utils"
import { useIsMobile } from "@/hooks/use-mobile" import { useIsMobile } from "@/hooks/use-mobile"
import { GanttChart } from "./gantt-chart" import { GanttChart } from "./gantt-chart"
import { TaskFormDialog } from "./task-form-dialog" import { TaskFormDialog } from "./task-form-dialog"
@ -58,9 +59,9 @@ import { format } from "date-fns"
type ViewMode = "Day" | "Week" | "Month" type ViewMode = "Day" | "Week" | "Month"
interface ScheduleGanttViewProps { interface ScheduleGanttViewProps {
projectId: string readonly projectId: string
tasks: ScheduleTaskData[] readonly tasks: readonly ScheduleTaskData[]
dependencies: TaskDependencyData[] readonly dependencies: readonly TaskDependencyData[]
} }
export function ScheduleGanttView({ export function ScheduleGanttView({
@ -71,7 +72,7 @@ export function ScheduleGanttView({
const router = useRouter() const router = useRouter()
const isMobile = useIsMobile() const isMobile = useIsMobile()
const [viewMode, setViewMode] = useState<ViewMode>("Week") const [viewMode, setViewMode] = useState<ViewMode>("Week")
const [phaseGrouping, setPhaseGrouping] = useState<"off" | "grouped">("off") const [phaseGrouping, setPhaseGrouping] = useState(false)
const [collapsedPhases, setCollapsedPhases] = useState<Set<string>>( const [collapsedPhases, setCollapsedPhases] = useState<Set<string>>(
new Set() new Set()
) )
@ -81,7 +82,6 @@ export function ScheduleGanttView({
null null
) )
const [mobileView, setMobileView] = useState<"tasks" | "chart">("chart") const [mobileView, setMobileView] = useState<"tasks" | "chart">("chart")
const [panMode] = useState(false) const [panMode] = useState(false)
const defaultWidths: Record<ViewMode, number> = { const defaultWidths: Record<ViewMode, number> = {
@ -105,13 +105,19 @@ export function ScheduleGanttView({
? tasks.filter((t) => t.isCriticalPath) ? tasks.filter((t) => t.isCriticalPath)
: tasks : tasks
const isGrouped = phaseGrouping === "grouped" const { frappeTasks, displayItems } = phaseGrouping
const { frappeTasks, displayItems } = isGrouped ? transformWithPhaseGroups(
? transformWithPhaseGroups(filteredTasks, dependencies, collapsedPhases) filteredTasks as ScheduleTaskData[],
dependencies as TaskDependencyData[],
collapsedPhases,
)
: { : {
frappeTasks: transformToFrappeTasks(filteredTasks, dependencies), frappeTasks: transformToFrappeTasks(
filteredTasks as ScheduleTaskData[],
dependencies as TaskDependencyData[],
),
displayItems: filteredTasks.map( displayItems: filteredTasks.map(
(task): DisplayItem => ({ type: "task", task }) (task): DisplayItem => ({ type: "task", task: task as ScheduleTaskData })
), ),
} }
@ -125,12 +131,14 @@ export function ScheduleGanttView({
} }
const toggleClientView = () => { const toggleClientView = () => {
if (phaseGrouping === "grouped") { if (phaseGrouping && collapsedPhases.size > 0) {
setPhaseGrouping("off") setPhaseGrouping(false)
setCollapsedPhases(new Set()) setCollapsedPhases(new Set())
} else { } else {
setPhaseGrouping("grouped") setPhaseGrouping(true)
const allPhases = new Set(filteredTasks.map((t) => t.phase || "uncategorized")) const allPhases = new Set(
filteredTasks.map((t) => t.phase || "uncategorized")
)
setCollapsedPhases(allPhases) setCollapsedPhases(allPhases)
} }
} }
@ -157,22 +165,129 @@ export function ScheduleGanttView({
) )
const scrollToToday = () => { const scrollToToday = () => {
const todayEl = document.querySelector(".gantt-container .today-highlight") const todayEl = document.querySelector(
".gantt-container .today-highlight"
)
if (todayEl) { if (todayEl) {
todayEl.scrollIntoView({ behavior: "smooth", inline: "center" }) todayEl.scrollIntoView({ behavior: "smooth", inline: "center" })
} }
} }
const taskTable = (
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs">Title</TableHead>
<TableHead className="text-xs w-[80px]">Start</TableHead>
<TableHead className="text-xs w-[52px]">Days</TableHead>
<TableHead className="w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{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)}
>
<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={phaseGrouping ? "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
variant="ghost"
size="sm"
className="text-xs w-full justify-start"
onClick={() => {
setEditingTask(null)
setTaskFormOpen(true)
}}
>
<IconPlus className="size-3 mr-1" />
Add Task
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
)
return ( return (
<div className="flex flex-col flex-1 min-h-0"> <div className="flex flex-col flex-1 min-h-0">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-2"> {/* Compact controls row */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 mb-2">
<div className="flex items-center gap-1.5">
{isMobile && ( {isMobile && (
<Select <Select
value={mobileView} value={mobileView}
onValueChange={(val) => setMobileView(val as "tasks" | "chart")} onValueChange={(val) =>
setMobileView(val as "tasks" | "chart")
}
> >
<SelectTrigger className="h-9 w-24"> <SelectTrigger className="h-7 w-20 text-xs">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -181,71 +296,73 @@ export function ScheduleGanttView({
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
<Select
value={viewMode} {/* Day / Week / Month */}
onValueChange={(val) => handleViewModeChange(val as ViewMode)} <div className="flex items-center rounded-md border bg-muted/40 p-0.5">
> {(["Day", "Week", "Month"] as const).map((mode) => (
<SelectTrigger className="h-9 w-24 sm:w-28"> <button
<SelectValue /> key={mode}
</SelectTrigger> onClick={() => handleViewModeChange(mode)}
<SelectContent> className={cn(
<SelectItem value="Day">Day</SelectItem> "px-2 py-1 text-xs font-medium rounded-sm transition-all",
<SelectItem value="Week">Week</SelectItem> viewMode === mode
<SelectItem value="Month">Month</SelectItem> ? "bg-background text-foreground shadow-sm"
</SelectContent> : "text-muted-foreground hover:text-foreground"
</Select> )}
>
{mode}
</button>
))}
</div>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={scrollToToday} onClick={scrollToToday}
className="h-9 px-3" className="h-7 px-2.5 text-xs"
> >
Today Today
</Button> </Button>
</div> </div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1 ml-auto">
<Button <Button
variant="outline" variant="ghost"
size="icon" size="icon"
className="size-9" className="size-7"
onClick={() => handleZoom("out")} onClick={() => handleZoom("out")}
title="Zoom out" title="Zoom out"
> >
<IconZoomOut className="size-4" /> <IconZoomOut className="size-3.5" />
</Button> </Button>
<Button <Button
variant="outline" variant="ghost"
size="icon" size="icon"
className="size-9" className="size-7"
onClick={() => handleZoom("in")} onClick={() => handleZoom("in")}
title="Zoom in" title="Zoom in"
> >
<IconZoomIn className="size-4" /> <IconZoomIn className="size-3.5" />
</Button> </Button>
</div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9"> <Button variant="ghost" size="icon" className="size-7">
<IconUsers className="size-4 sm:mr-2" /> <IconSettings className="size-3.5" />
<span className="hidden sm:inline">Options</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52"> <DropdownMenuContent align="end" className="w-48">
<div className="px-2 py-1.5"> <div className="px-2 py-1.5 space-y-2">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between">
<span className="text-sm">Group by Phases</span> <span className="text-xs">Group by Phase</span>
<Switch <Switch
checked={isGrouped} checked={phaseGrouping}
onCheckedChange={(checked) => { onCheckedChange={setPhaseGrouping}
setPhaseGrouping(checked ? "grouped" : "off")
if (!checked) setCollapsedPhases(new Set())
}}
className="scale-75" className="scale-75"
/> />
</div> </div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between">
<span className="text-sm">Show Critical Path</span> <span className="text-xs">Critical Path</span>
<Switch <Switch
checked={showCriticalPath} checked={showCriticalPath}
onCheckedChange={setShowCriticalPath} onCheckedChange={setShowCriticalPath}
@ -253,14 +370,16 @@ export function ScheduleGanttView({
/> />
</div> </div>
<Button <Button
variant={phaseGrouping === "grouped" && collapsedPhases.size > 0 ? "default" : "outline"} variant={
phaseGrouping && collapsedPhases.size > 0
? "default"
: "outline"
}
size="sm" size="sm"
onClick={toggleClientView} onClick={toggleClientView}
className="w-full mt-2" className="w-full mt-1 text-xs h-7"
> >
<IconUsers className="size-4 mr-2" /> Client View
<span className="hidden sm:inline">Client View</span>
<span className="sm:hidden">Client</span>
</Button> </Button>
</div> </div>
</DropdownMenuContent> </DropdownMenuContent>
@ -268,113 +387,12 @@ export function ScheduleGanttView({
</div> </div>
</div> </div>
{/* Main content */}
{isMobile ? ( {isMobile ? (
<div className="flex flex-col flex-1 min-h-0"> <div className="flex flex-col flex-1 min-h-0">
{mobileView === "tasks" ? ( {mobileView === "tasks" ? (
<div className="border rounded-md flex-1 min-h-0 overflow-auto"> <div className="border rounded-md flex-1 min-h-0 overflow-auto">
<Table> {taskTable}
<TableHeader>
<TableRow>
<TableHead className="text-xs">Title</TableHead>
<TableHead className="text-xs w-[80px]">
Start
</TableHead>
<TableHead className="text-xs w-[60px]">
Days
</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{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)}
>
<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-[120px]">
<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
variant="ghost"
size="sm"
className="text-xs w-full justify-start"
onClick={() => {
setEditingTask(null)
setTaskFormOpen(true)
}}
>
<IconPlus className="size-3 mr-1" />
Add Task
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div> </div>
) : ( ) : (
<div className="border rounded-md flex-1 min-h-0 overflow-hidden p-2"> <div className="border rounded-md flex-1 min-h-0 overflow-hidden p-2">
@ -396,109 +414,7 @@ export function ScheduleGanttView({
> >
<ResizablePanel defaultSize={30} minSize={20}> <ResizablePanel defaultSize={30} minSize={20}>
<div className="h-full overflow-auto"> <div className="h-full overflow-auto">
<Table> {taskTable}
<TableHeader>
<TableRow>
<TableHead className="text-xs">Title</TableHead>
<TableHead className="text-xs w-[80px]">
Start
</TableHead>
<TableHead className="text-xs w-[60px]">
Days
</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{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)}
>
<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
variant="ghost"
size="sm"
className="text-xs w-full justify-start"
onClick={() => {
setEditingTask(null)
setTaskFormOpen(true)
}}
>
<IconPlus className="size-3 mr-1" />
Add Task
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div> </div>
</ResizablePanel> </ResizablePanel>
@ -524,6 +440,8 @@ export function ScheduleGanttView({
onOpenChange={setTaskFormOpen} onOpenChange={setTaskFormOpen}
projectId={projectId} projectId={projectId}
editingTask={editingTask} editingTask={editingTask}
allTasks={tasks}
dependencies={dependencies}
/> />
</div> </div>
) )

View File

@ -418,6 +418,8 @@ export function ScheduleListView({
onOpenChange={setTaskFormOpen} onOpenChange={setTaskFormOpen}
projectId={projectId} projectId={projectId}
editingTask={editingTask} editingTask={editingTask}
allTasks={localTasks}
dependencies={dependencies}
/> />
<DependencyDialog <DependencyDialog

View File

@ -1,10 +1,57 @@
"use client" "use client"
import { useState, useMemo } from "react" import { useState, useMemo, useRef } from "react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import Link from "next/link"
import { cn } from "@/lib/utils"
import { useIsMobile } from "@/hooks/use-mobile" import { useIsMobile } from "@/hooks/use-mobile"
import { ScheduleToolbar, type TaskFilters } from "./schedule-toolbar" import { Input } from "@/components/ui/input"
import { ProjectSwitcher } from "./project-switcher" import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from "@/components/ui/sheet"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
IconSearch,
IconFilter,
IconX,
IconPlus,
IconCalendar,
IconList,
IconTimeline,
IconChevronRight,
IconDots,
IconDownload,
IconUpload,
IconPrinter,
IconHistory,
IconCalendarOff,
IconLoader2,
} from "@tabler/icons-react"
import { ScheduleListView } from "./schedule-list-view" import { ScheduleListView } from "./schedule-list-view"
import { ScheduleGanttView } from "./schedule-gantt-view" import { ScheduleGanttView } from "./schedule-gantt-view"
import { ScheduleCalendarView } from "./schedule-calendar-view" import { ScheduleCalendarView } from "./schedule-calendar-view"
@ -15,24 +62,30 @@ import { TaskFormDialog } from "./task-form-dialog"
import type { import type {
ScheduleData, ScheduleData,
ScheduleBaselineData, ScheduleBaselineData,
TaskFilters,
TaskStatus,
ConstructionPhase,
} from "@/lib/schedule/types"
import {
EMPTY_FILTERS,
STATUS_OPTIONS,
PHASE_OPTIONS,
} from "@/lib/schedule/types" } from "@/lib/schedule/types"
type TopTab = "schedule" | "baseline" | "exceptions" type View = "calendar" | "list" | "gantt"
type ScheduleSubTab = "calendar" | "list" | "gantt"
const DEFAULT_FILTERS: TaskFilters = { const VIEW_OPTIONS = [
status: [], { value: "calendar" as const, icon: IconCalendar, label: "Calendar" },
phase: [], { value: "list" as const, icon: IconList, label: "List" },
assignedTo: "", { value: "gantt" as const, icon: IconTimeline, label: "Gantt" },
search: "", ]
}
interface ScheduleViewProps { interface ScheduleViewProps {
projectId: string readonly projectId: string
projectName: string readonly projectName: string
initialData: ScheduleData readonly initialData: ScheduleData
baselines: ScheduleBaselineData[] readonly baselines: ScheduleBaselineData[]
allProjects?: { id: string; name: string }[] readonly allProjects?: readonly { id: string; name: string }[]
} }
export function ScheduleView({ export function ScheduleView({
@ -40,13 +93,16 @@ export function ScheduleView({
projectName, projectName,
initialData, initialData,
baselines, baselines,
allProjects = [],
}: ScheduleViewProps) { }: ScheduleViewProps) {
const isMobile = useIsMobile() const isMobile = useIsMobile()
const [topTab, setTopTab] = useState<TopTab>("schedule") const [view, setView] = useState<View>("calendar")
const [subTab, setSubTab] = useState<ScheduleSubTab>("calendar")
const [taskFormOpen, setTaskFormOpen] = useState(false) const [taskFormOpen, setTaskFormOpen] = useState(false)
const [filters, setFilters] = useState<TaskFilters>(DEFAULT_FILTERS) const [filters, setFilters] = useState<TaskFilters>(EMPTY_FILTERS)
const [baselinesOpen, setBaselinesOpen] = useState(false)
const [exceptionsOpen, setExceptionsOpen] = useState(false)
const [importDialogOpen, setImportDialogOpen] = useState(false)
const [isImporting, setIsImporting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const filteredTasks = useMemo(() => { const filteredTasks = useMemo(() => {
let tasks = initialData.tasks let tasks = initialData.tasks
@ -54,147 +110,512 @@ export function ScheduleView({
if (filters.status.length > 0) { if (filters.status.length > 0) {
tasks = tasks.filter((t) => filters.status.includes(t.status)) tasks = tasks.filter((t) => filters.status.includes(t.status))
} }
if (filters.phase.length > 0) { if (filters.phase.length > 0) {
tasks = tasks.filter((t) => filters.phase.includes(t.phase as never)) tasks = tasks.filter((t) =>
} filters.phase.includes(t.phase as ConstructionPhase)
)
if (filters.assignedTo) { }
const search = filters.assignedTo.toLowerCase() if (filters.assignedTo) {
tasks = tasks.filter( const search = filters.assignedTo.toLowerCase()
(t) => t.assignedTo?.toLowerCase().includes(search) tasks = tasks.filter((t) =>
t.assignedTo?.toLowerCase().includes(search)
) )
} }
if (filters.search) { if (filters.search) {
const search = filters.search.toLowerCase() const search = filters.search.toLowerCase()
tasks = tasks.filter((t) => t.title.toLowerCase().includes(search)) tasks = tasks.filter((t) =>
t.title.toLowerCase().includes(search)
)
} }
return tasks return tasks
}, [initialData.tasks, filters]) }, [initialData.tasks, filters])
const activeFilterCount =
filters.status.length +
filters.phase.length +
(filters.assignedTo ? 1 : 0)
const toggleStatus = (status: TaskStatus) => {
const current = filters.status
const next = current.includes(status)
? current.filter((s) => s !== status)
: [...current, status]
setFilters({ ...filters, status: next })
}
const togglePhase = (phase: ConstructionPhase) => {
const current = filters.phase
const next = current.includes(phase)
? current.filter((p) => p !== phase)
: [...current, phase]
setFilters({ ...filters, phase: next })
}
const removeStatusChip = (status: TaskStatus) => {
setFilters({
...filters,
status: filters.status.filter((s) => s !== status),
})
}
const removePhaseChip = (phase: ConstructionPhase) => {
setFilters({
...filters,
phase: filters.phase.filter((p) => p !== phase),
})
}
const clearFilters = () => setFilters(EMPTY_FILTERS)
// CSV export
const handleExportCSV = () => {
const headers = [
"Title", "Phase", "Status", "Start Date", "End Date",
"Duration (days)", "% Complete", "Assigned To",
"Critical Path", "Milestone",
]
const rows = filteredTasks.map((task) => [
task.title, task.phase, task.status, task.startDate,
task.endDateCalculated, task.workdays.toString(),
task.percentComplete.toString(), task.assignedTo ?? "",
task.isCriticalPath ? "Yes" : "No",
task.isMilestone ? "Yes" : "No",
])
const escapeCSV = (value: string) => {
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
return `"${value.replace(/"/g, '""')}"`
}
return value
}
const csvContent = [
headers.join(","),
...rows.map((row) => row.map(escapeCSV).join(",")),
].join("\n")
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" })
const link = document.createElement("a")
link.download = `${projectName.replace(/\s+/g, "-")}-schedule-${new Date().toISOString().split("T")[0]}.csv`
link.href = URL.createObjectURL(blob)
link.click()
URL.revokeObjectURL(link.href)
}
// CSV import
const handleFileSelect = async (
e: React.ChangeEvent<HTMLInputElement>
) => {
const file = e.target.files?.[0]
if (!file) return
setIsImporting(true)
try {
const text = await file.text()
const lines = text.split("\n")
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase())
const titleIdx = headers.findIndex(
(h) => h.includes("title") || h.includes("task")
)
const startIdx = headers.findIndex((h) => h.includes("start"))
const durationIdx = headers.findIndex(
(h) => h.includes("duration") || h.includes("days")
)
const phaseIdx = headers.findIndex((h) => h.includes("phase"))
const assignedIdx = headers.findIndex(
(h) => h.includes("assigned") || h.includes("owner")
)
const parsed: Record<string, unknown>[] = []
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const values = line.split(",").map((v) => v.trim())
const task: Record<string, unknown> = {}
if (titleIdx >= 0 && values[titleIdx]) task.title = values[titleIdx]
if (startIdx >= 0 && values[startIdx]) task.startDate = values[startIdx]
if (durationIdx >= 0 && values[durationIdx]) {
task.workdays = parseInt(values[durationIdx]) || 1
}
if (phaseIdx >= 0 && values[phaseIdx]) task.phase = values[phaseIdx]
if (assignedIdx >= 0 && values[assignedIdx]) {
task.assignedTo = values[assignedIdx]
}
if (task.title) {
task.status = "PENDING"
task.percentComplete = 0
parsed.push(task)
}
}
if (parsed.length > 0) {
const blob = new Blob(
[JSON.stringify(parsed, null, 2)],
{ type: "application/json" }
)
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = `imported-tasks-${Date.now()}.json`
link.click()
URL.revokeObjectURL(url)
alert(`Parsed ${parsed.length} tasks from CSV. Downloaded as JSON for review.`)
} else {
alert("No valid tasks found in the CSV file.")
}
} catch (error) {
console.error("Import failed:", error)
alert("Failed to parse CSV file. Please check the format.")
} finally {
setIsImporting(false)
setImportDialogOpen(false)
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
}
return ( return (
<div className="flex flex-col flex-1 min-h-0"> <div className="flex flex-col flex-1 min-h-0">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-2"> {/* Header: breadcrumb + view toggle + new task */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 mb-3">
<ProjectSwitcher <nav className="flex items-center gap-1.5 text-sm min-w-0">
projects={allProjects} <Link
currentProjectId={projectId} href={`/dashboard/projects/${projectId}`}
currentProjectName={projectName} className="text-muted-foreground hover:text-foreground truncate transition-colors"
/> >
<span className="text-muted-foreground">- Schedule</span> {projectName}
</Link>
<IconChevronRight className="size-3.5 text-muted-foreground/60 shrink-0" />
<span className="font-medium">Schedule</span>
</nav>
<div className="ml-auto flex items-center gap-2">
{/* View switcher */}
<div className={cn(
"flex items-center rounded-lg border bg-muted/40 p-0.5",
isMobile ? "gap-0" : "gap-0",
)}>
{VIEW_OPTIONS.map(({ value, icon: Icon, label }) => (
<button
key={value}
onClick={() => setView(value)}
className={cn(
"flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-all",
view === value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="size-3.5" />
{!isMobile && <span>{label}</span>}
</button>
))}
</div>
<Button size="sm" onClick={() => setTaskFormOpen(true)} className="h-8">
<IconPlus className="size-3.5" />
<span className="hidden sm:inline ml-1.5">New Task</span>
</Button>
</div> </div>
<Tabs
value={topTab}
onValueChange={(v) => setTopTab(v as TopTab)}
>
<TabsList>
<TabsTrigger value="schedule">Schedule</TabsTrigger>
<TabsTrigger value="baseline">Baseline</TabsTrigger>
<TabsTrigger value="exceptions">
<span className="hidden sm:inline">Workday </span>Exceptions
</TabsTrigger>
</TabsList>
</Tabs>
</div> </div>
<Tabs {/* Action bar: search, filters, overflow */}
value={topTab} <div className="flex items-center gap-2 mb-3 print:hidden">
onValueChange={(v) => setTopTab(v as TopTab)} {/* Search */}
className="flex flex-col flex-1 min-h-0" <div className="relative flex-1 sm:flex-none sm:w-52">
> <IconSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<TabsContent value="schedule" className="mt-0 flex flex-col flex-1 min-h-0"> <Input
<ScheduleToolbar placeholder="Search tasks..."
onNewItem={() => setTaskFormOpen(true)} value={filters.search}
filters={filters} onChange={(e) => setFilters({ ...filters, search: e.target.value })}
onFiltersChange={setFilters} className="h-8 pl-8 text-sm"
projectName={projectName}
tasksCount={filteredTasks.length}
tasks={filteredTasks}
/> />
</div>
<Tabs {/* Filter popover */}
value={subTab} <Popover>
onValueChange={(v) => setSubTab(v as ScheduleSubTab)} <PopoverTrigger asChild>
className="flex flex-col flex-1 min-h-0" <Button variant="outline" size="sm" className="h-8 shrink-0">
> <IconFilter className="size-3.5" />
<TabsList className="bg-transparent border-b rounded-none h-auto p-0 gap-4"> <span className="hidden sm:inline ml-1.5">Filters</span>
<TabsTrigger {activeFilterCount > 0 && (
value="calendar" <Badge
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-2" variant="secondary"
> className="ml-1.5 h-4 min-w-4 px-1 text-[10px] rounded-full"
Calendar >
</TabsTrigger> {activeFilterCount}
<TabsTrigger </Badge>
value="list"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-2"
>
List
</TabsTrigger>
<TabsTrigger
value="gantt"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-2"
>
Gantt
</TabsTrigger>
</TabsList>
<TabsContent value="calendar" className="mt-2 flex flex-col flex-1 min-h-0" data-schedule-content>
{isMobile ? (
<ScheduleMobileView
tasks={filteredTasks}
exceptions={initialData.exceptions}
/>
) : (
<ScheduleCalendarView
projectId={projectId}
tasks={filteredTasks}
exceptions={initialData.exceptions}
/>
)} )}
</TabsContent> </Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-3">
<div className="space-y-3">
<div>
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Status
</Label>
<div className="mt-1.5 space-y-1">
{STATUS_OPTIONS.map((opt) => (
<label
key={opt.value}
className="flex items-center gap-2 py-0.5 cursor-pointer"
>
<Checkbox
checked={filters.status.includes(opt.value)}
onCheckedChange={() => toggleStatus(opt.value)}
/>
<span className="text-sm">{opt.label}</span>
</label>
))}
</div>
</div>
<div>
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Phase
</Label>
<div className="mt-1.5 space-y-1 max-h-40 overflow-y-auto">
{PHASE_OPTIONS.map((opt) => (
<label
key={opt.value}
className="flex items-center gap-2 py-0.5 cursor-pointer"
>
<Checkbox
checked={filters.phase.includes(opt.value)}
onCheckedChange={() => togglePhase(opt.value)}
/>
<span className="text-sm">{opt.label}</span>
</label>
))}
</div>
</div>
<div>
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Assigned To
</Label>
<Input
placeholder="Filter by name..."
value={filters.assignedTo}
onChange={(e) =>
setFilters({ ...filters, assignedTo: e.target.value })
}
className="mt-1.5 h-8 text-sm"
/>
</div>
{activeFilterCount > 0 && (
<Button
variant="ghost"
size="sm"
className="w-full text-xs"
onClick={clearFilters}
>
<IconX className="size-3 mr-1" />
Clear all filters
</Button>
)}
</div>
</PopoverContent>
</Popover>
<TabsContent value="list" className="mt-2 flex flex-col flex-1 min-h-0" data-schedule-content> {/* Active filter chips */}
<ScheduleListView <div className="hidden sm:flex items-center gap-1 overflow-x-auto min-w-0">
projectId={projectId} {filters.status.map((s) => (
tasks={filteredTasks} <Badge
dependencies={initialData.dependencies} key={s}
/> variant="outline"
</TabsContent> className="gap-1 shrink-0 text-xs py-0 h-6 cursor-pointer hover:bg-accent"
onClick={() => removeStatusChip(s)}
>
{STATUS_OPTIONS.find((o) => o.value === s)?.label ?? s}
<IconX className="size-3" />
</Badge>
))}
{filters.phase.map((p) => (
<Badge
key={p}
variant="outline"
className="gap-1 shrink-0 text-xs py-0 h-6 cursor-pointer hover:bg-accent capitalize"
onClick={() => removePhaseChip(p)}
>
{p}
<IconX className="size-3" />
</Badge>
))}
{filters.assignedTo && (
<Badge
variant="outline"
className="gap-1 shrink-0 text-xs py-0 h-6 cursor-pointer hover:bg-accent"
onClick={() => setFilters({ ...filters, assignedTo: "" })}
>
{filters.assignedTo}
<IconX className="size-3" />
</Badge>
)}
</div>
<TabsContent value="gantt" className="mt-2 flex flex-col flex-1 min-h-0" data-schedule-content> <div className="ml-auto flex items-center gap-2 shrink-0">
<ScheduleGanttView <span className="text-xs text-muted-foreground hidden sm:inline tabular-nums">
projectId={projectId} {filteredTasks.length} task{filteredTasks.length !== 1 ? "s" : ""}
tasks={filteredTasks} </span>
dependencies={initialData.dependencies}
/>
</TabsContent>
</Tabs>
</TabsContent>
<TabsContent value="baseline" className="mt-2"> {/* Overflow menu */}
<ScheduleBaselineView <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<IconDots className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={handleExportCSV}>
<IconDownload className="size-4 mr-2" />
Export CSV
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setImportDialogOpen(true)}>
<IconUpload className="size-4 mr-2" />
Import CSV
</DropdownMenuItem>
<DropdownMenuItem onClick={() => window.print()}>
<IconPrinter className="size-4 mr-2" />
Print
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setBaselinesOpen(true)}>
<IconHistory className="size-4 mr-2" />
Baselines
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setExceptionsOpen(true)}>
<IconCalendarOff className="size-4 mr-2" />
Workday Exceptions
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* View content */}
<div className="flex flex-col flex-1 min-h-0">
{view === "calendar" && (
isMobile ? (
<ScheduleMobileView
tasks={filteredTasks}
exceptions={initialData.exceptions}
/>
) : (
<ScheduleCalendarView
projectId={projectId}
tasks={filteredTasks}
exceptions={initialData.exceptions}
/>
)
)}
{view === "list" && (
<ScheduleListView
projectId={projectId} projectId={projectId}
baselines={baselines} tasks={filteredTasks}
currentTasks={initialData.tasks} dependencies={initialData.dependencies}
/> />
</TabsContent> )}
{view === "gantt" && (
<TabsContent value="exceptions" className="mt-2"> <ScheduleGanttView
<WorkdayExceptionsView
projectId={projectId} projectId={projectId}
exceptions={initialData.exceptions} tasks={filteredTasks}
dependencies={initialData.dependencies}
/> />
</TabsContent> )}
</Tabs> </div>
{/* New task dialog */}
<TaskFormDialog <TaskFormDialog
open={taskFormOpen} open={taskFormOpen}
onOpenChange={setTaskFormOpen} onOpenChange={setTaskFormOpen}
projectId={projectId} projectId={projectId}
editingTask={null} editingTask={null}
allTasks={initialData.tasks}
dependencies={initialData.dependencies}
/> />
{/* Import dialog */}
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Import Schedule</DialogTitle>
<DialogDescription>
Upload a CSV file with columns for title, start date, duration,
phase, and assigned to.
</DialogDescription>
</DialogHeader>
<div className="border-2 border-dashed rounded-lg p-8 text-center">
<Input
ref={fileInputRef}
type="file"
accept=".csv"
className="hidden"
onChange={handleFileSelect}
/>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={isImporting}
>
{isImporting ? (
<>
<IconLoader2 className="size-4 mr-2 animate-spin" />
Importing...
</>
) : (
<>
<IconUpload className="size-4 mr-2" />
Select CSV File
</>
)}
</Button>
<p className="text-xs text-muted-foreground mt-2">
Supported format: CSV with headers
</p>
</div>
</DialogContent>
</Dialog>
{/* Baselines sheet */}
<Sheet open={baselinesOpen} onOpenChange={setBaselinesOpen}>
<SheetContent side="right" className="w-full sm:max-w-lg overflow-y-auto">
<SheetHeader>
<SheetTitle>Baselines</SheetTitle>
<SheetDescription>
Save and compare schedule snapshots.
</SheetDescription>
</SheetHeader>
<div className="mt-4">
<ScheduleBaselineView
projectId={projectId}
baselines={baselines}
currentTasks={initialData.tasks}
/>
</div>
</SheetContent>
</Sheet>
{/* Exceptions sheet */}
<Sheet open={exceptionsOpen} onOpenChange={setExceptionsOpen}>
<SheetContent side="right" className="w-full sm:max-w-lg overflow-y-auto">
<SheetHeader>
<SheetTitle>Workday Exceptions</SheetTitle>
<SheetDescription>
Holidays, vacation days, and other non-working days.
</SheetDescription>
</SheetHeader>
<div className="mt-4">
<WorkdayExceptionsView
projectId={projectId}
exceptions={initialData.exceptions}
/>
</div>
</SheetContent>
</Sheet>
</div> </div>
) )
} }

View File

@ -1,15 +1,16 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { useEffect, useMemo } from "react" import { useEffect, useMemo, useState } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { z } from "zod" import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { import {
ResponsiveDialog, Dialog,
ResponsiveDialogBody, DialogContent,
ResponsiveDialogFooter, DialogHeader,
} from "@/components/ui/responsive-dialog" DialogTitle,
} from "@/components/ui/dialog"
import { import {
Form, Form,
FormControl, FormControl,
@ -19,13 +20,25 @@ import {
FormMessage, FormMessage,
} from "@/components/ui/form" } from "@/components/ui/form"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Calendar } from "@/components/ui/calendar" import { Calendar } from "@/components/ui/calendar"
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover" } from "@/components/ui/popover"
import { IconCalendar } from "@tabler/icons-react" import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
IconCalendar,
IconPlus,
IconTrash,
IconChevronDown,
IconChevronRight,
} from "@tabler/icons-react"
import { format, parseISO } from "date-fns" import { format, parseISO } from "date-fns"
import { Slider } from "@/components/ui/slider" import { Slider } from "@/components/ui/slider"
import { import {
@ -37,26 +50,46 @@ import {
} from "@/components/ui/select" } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch" 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,
createDependency,
deleteDependency,
} from "@/app/actions/schedule"
import { calculateEndDate } from "@/lib/schedule/business-days" import { calculateEndDate } from "@/lib/schedule/business-days"
import type { ScheduleTaskData } from "@/lib/schedule/types" import type {
import { PHASE_ORDER, PHASE_LABELS } from "@/lib/schedule/phase-colors" ScheduleTaskData,
TaskDependencyData,
DependencyType,
} from "@/lib/schedule/types"
import { PHASE_ORDER, PHASE_LABELS, getPhaseColor } from "@/lib/schedule/phase-colors"
import { STATUS_OPTIONS } from "@/lib/schedule/types"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { toast } from "sonner" import { toast } from "sonner"
import { cn } from "@/lib/utils"
const phases = PHASE_ORDER.map((value) => ({ const phases = PHASE_ORDER.map((value) => ({
value, value,
label: PHASE_LABELS[value], label: PHASE_LABELS[value],
})) }))
const DEPENDENCY_TYPES: readonly { value: DependencyType; label: string }[] = [
{ value: "FS", label: "Finish-to-Start" },
{ value: "SS", label: "Start-to-Start" },
{ value: "FF", label: "Finish-to-Finish" },
{ value: "SF", label: "Start-to-Finish" },
]
const taskSchema = z.object({ const taskSchema = z.object({
title: z.string().min(1, "Title is required"), title: z.string().min(1, "Title is required"),
startDate: z.string().min(1, "Start date is required"), startDate: z.string().min(1, "Start date is required"),
workdays: z.number().min(1, "Must be at least 1 day"), workdays: z.number().min(1, "Must be at least 1 day"),
phase: z.string().min(1, "Phase is required"), phase: z.string().min(1, "Phase is required"),
status: z.string(),
isMilestone: z.boolean(), isMilestone: z.boolean(),
percentComplete: z.number().min(0).max(100), percentComplete: z.number().min(0).max(100),
assignedTo: z.string(), assignedTo: z.string(),
notes: z.string(),
}) })
type TaskFormValues = z.infer<typeof taskSchema> type TaskFormValues = z.infer<typeof taskSchema>
@ -66,6 +99,14 @@ interface TaskFormDialogProps {
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
projectId: string projectId: string
editingTask: ScheduleTaskData | null editingTask: ScheduleTaskData | null
allTasks?: readonly ScheduleTaskData[]
dependencies?: readonly TaskDependencyData[]
}
interface PendingPredecessor {
taskId: string
type: DependencyType
lagDays: number
} }
export function TaskFormDialog({ export function TaskFormDialog({
@ -73,9 +114,24 @@ export function TaskFormDialog({
onOpenChange, onOpenChange,
projectId, projectId,
editingTask, editingTask,
allTasks = [],
dependencies = [],
}: TaskFormDialogProps) { }: TaskFormDialogProps) {
const router = useRouter() const router = useRouter()
const isEditing = !!editingTask const isEditing = !!editingTask
const [detailsOpen, setDetailsOpen] = useState(false)
const [pendingPredecessors, setPendingPredecessors] = useState<
PendingPredecessor[]
>([])
const existingPredecessors = useMemo(() => {
if (!editingTask) return []
return dependencies.filter((d) => d.successorId === editingTask.id)
}, [editingTask, dependencies])
const availableTasks = useMemo(() => {
return allTasks.filter((t) => t.id !== editingTask?.id)
}, [allTasks, editingTask])
const form = useForm<TaskFormValues>({ const form = useForm<TaskFormValues>({
resolver: zodResolver(taskSchema), resolver: zodResolver(taskSchema),
@ -84,9 +140,11 @@ export function TaskFormDialog({
startDate: new Date().toISOString().split("T")[0], startDate: new Date().toISOString().split("T")[0],
workdays: 5, workdays: 5,
phase: "preconstruction", phase: "preconstruction",
status: "PENDING",
isMilestone: false, isMilestone: false,
percentComplete: 0, percentComplete: 0,
assignedTo: "", assignedTo: "",
notes: "",
}, },
}) })
@ -97,25 +155,35 @@ export function TaskFormDialog({
startDate: editingTask.startDate, startDate: editingTask.startDate,
workdays: editingTask.workdays, workdays: editingTask.workdays,
phase: editingTask.phase, phase: editingTask.phase,
status: editingTask.status,
isMilestone: editingTask.isMilestone, isMilestone: editingTask.isMilestone,
percentComplete: editingTask.percentComplete, percentComplete: editingTask.percentComplete,
assignedTo: editingTask.assignedTo ?? "", assignedTo: editingTask.assignedTo ?? "",
notes: "",
}) })
// expand details when editing since they likely want to see everything
setDetailsOpen(true)
} else { } else {
form.reset({ form.reset({
title: "", title: "",
startDate: new Date().toISOString().split("T")[0], startDate: new Date().toISOString().split("T")[0],
workdays: 5, workdays: 5,
phase: "preconstruction", phase: "preconstruction",
status: "PENDING",
isMilestone: false, isMilestone: false,
percentComplete: 0, percentComplete: 0,
assignedTo: "", assignedTo: "",
notes: "",
}) })
setDetailsOpen(false)
} }
setPendingPredecessors([])
}, [editingTask, form]) }, [editingTask, form])
const watchedStart = form.watch("startDate") const watchedStart = form.watch("startDate")
const watchedWorkdays = form.watch("workdays") const watchedWorkdays = form.watch("workdays")
const watchedPhase = form.watch("phase")
const watchedPercent = form.watch("percentComplete")
const calculatedEnd = useMemo(() => { const calculatedEnd = useMemo(() => {
if (!watchedStart || !watchedWorkdays || watchedWorkdays < 1) return "" if (!watchedStart || !watchedWorkdays || watchedWorkdays < 1) return ""
@ -137,6 +205,17 @@ export function TaskFormDialog({
} }
if (result.success) { if (result.success) {
for (const pred of pendingPredecessors) {
if (pred.taskId) {
await createDependency({
predecessorId: pred.taskId,
successorId: editingTask?.id ?? "",
type: pred.type,
lagDays: pred.lagDays,
projectId,
})
}
}
onOpenChange(false) onOpenChange(false)
router.refresh() router.refresh()
} else { } else {
@ -144,206 +223,470 @@ export function TaskFormDialog({
} }
} }
const page1 = ( const addPendingPredecessor = () => {
<> setPendingPredecessors((prev) => [
<FormField ...prev,
control={form.control} { taskId: "", type: "FS", lagDays: 0 },
name="title" ])
render={({ field }) => ( }
<FormItem>
<FormLabel className="text-xs">Title</FormLabel>
<FormControl>
<Input placeholder="Task title" className="h-9" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField const removePendingPredecessor = (index: number) => {
control={form.control} setPendingPredecessors((prev) => prev.filter((_, i) => i !== index))
name="startDate" }
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Start Date</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className="w-full h-9 justify-start text-left font-normal text-sm"
>
<IconCalendar className="size-3.5 mr-2 text-muted-foreground" />
{field.value
? format(parseISO(field.value), "MMM d, yyyy")
: "Pick date"}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value ? parseISO(field.value) : undefined}
onSelect={(date) => {
if (date) {
field.onChange(format(date, "yyyy-MM-dd"))
}
}}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField const updatePendingPredecessor = (
control={form.control} index: number,
name="workdays" field: keyof PendingPredecessor,
render={({ field }) => ( value: string | number
<FormItem> ) => {
<FormLabel className="text-xs">Workdays</FormLabel> setPendingPredecessors((prev) =>
<FormControl> prev.map((p, i) => (i === index ? { ...p, [field]: value } : p))
<Input )
type="number" }
min={1}
className="h-9"
value={field.value}
onChange={(e) =>
field.onChange(Number(e.target.value) || 0)
}
onBlur={field.onBlur}
ref={field.ref}
name={field.name}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)
const page2 = ( const handleDeleteExistingDep = async (depId: string) => {
<> const result = await deleteDependency(depId, projectId)
<FormField if (result.success) {
control={form.control} router.refresh()
name="phase" } else {
render={({ field }) => ( toast.error(result.error)
<FormItem> }
<FormLabel className="text-xs">Phase</FormLabel> }
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select phase" />
</SelectTrigger>
</FormControl>
<SelectContent>
{phases.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField const hasPredecessors =
control={form.control} existingPredecessors.length > 0 || pendingPredecessors.length > 0
name="assignedTo"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Assigned To</FormLabel>
<FormControl>
<Input placeholder="Person name" className="h-9" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)
const page3 = (
<>
{calculatedEnd && (
<p className="text-xs text-muted-foreground">
End date: <strong>{calculatedEnd}</strong>
</p>
)}
<FormField
control={form.control}
name="percentComplete"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">
Complete: {field.value}%
</FormLabel>
<FormControl>
<Slider
min={0}
max={100}
step={5}
value={[field.value]}
onValueChange={([val]) => field.onChange(val)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isMilestone"
render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="!mt-0 text-xs">Milestone</FormLabel>
</FormItem>
)}
/>
</>
)
return ( return (
<ResponsiveDialog <Dialog open={open} onOpenChange={onOpenChange}>
open={open} <DialogContent className="sm:max-w-xl max-h-[85vh] flex flex-col overflow-hidden p-0 gap-0">
onOpenChange={onOpenChange} <DialogHeader className="px-5 pt-4 pb-3 shrink-0">
title={isEditing ? "Edit Task" : "New Task"} <DialogTitle className="text-base font-semibold">
> {isEditing ? "Edit Task" : "New Task"}
<Form {...form}> </DialogTitle>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0"> </DialogHeader>
<ResponsiveDialogBody pages={[page1, page2, page3]} />
<ResponsiveDialogFooter> <Form {...form}>
<Button <form
type="button" onSubmit={form.handleSubmit(onSubmit)}
variant="outline" className="flex flex-col flex-1 min-h-0"
onClick={() => onOpenChange(false)} >
className="h-9" <div className="overflow-y-auto flex-1 min-h-0 px-5 pb-4 space-y-4">
> {/* === ESSENTIAL FIELDS === */}
Cancel
</Button> {/* Title */}
<Button type="submit" className="h-9"> <FormField
{isEditing ? "Save" : "Create"} control={form.control}
</Button> name="title"
</ResponsiveDialogFooter> render={({ field }) => (
</form> <FormItem>
</Form> <FormControl>
</ResponsiveDialog> <Input
placeholder="Task name"
className="h-10 text-sm font-medium border-0 border-b rounded-none px-0 focus-visible:ring-0 focus-visible:border-primary"
autoFocus
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Phase pills */}
<div className="flex flex-wrap gap-1.5">
{phases.map((p) => {
const colors = getPhaseColor(p.value)
const isSelected = watchedPhase === p.value
return (
<button
key={p.value}
type="button"
className={cn(
"px-2.5 py-1 rounded-md text-xs font-medium transition-all",
isSelected
? `${colors.badge} ring-1 ring-current/20`
: "bg-muted/50 text-muted-foreground hover:bg-muted"
)}
onClick={() => form.setValue("phase", p.value)}
>
{p.label}
</button>
)
})}
</div>
{/* Date row: Start | Duration | End */}
<div className="grid grid-cols-[1fr_100px_1fr] gap-2 items-end">
<FormField
control={form.control}
name="startDate"
render={({ field }) => (
<FormItem>
<FormLabel className="text-[11px] text-muted-foreground font-medium">
Start
</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className="w-full h-9 justify-start text-left font-normal text-sm"
>
<IconCalendar className="size-3.5 mr-1.5 text-muted-foreground shrink-0" />
{field.value
? format(parseISO(field.value), "MMM d, yyyy")
: "Pick date"}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={
field.value ? parseISO(field.value) : undefined
}
onSelect={(date) => {
if (date) {
field.onChange(format(date, "yyyy-MM-dd"))
}
}}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="workdays"
render={({ field }) => (
<FormItem>
<FormLabel className="text-[11px] text-muted-foreground font-medium">
Duration
</FormLabel>
<div className="flex items-center gap-1">
<FormControl>
<Input
type="number"
min={1}
className="h-9 text-center"
value={field.value}
onChange={(e) =>
field.onChange(Number(e.target.value) || 0)
}
onBlur={field.onBlur}
ref={field.ref}
name={field.name}
/>
</FormControl>
<span className="text-[11px] text-muted-foreground shrink-0">
d
</span>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormItem>
<FormLabel className="text-[11px] text-muted-foreground font-medium">
End
</FormLabel>
<div className="flex items-center h-9 px-3 rounded-md bg-muted/40 text-sm text-muted-foreground tabular-nums">
{calculatedEnd
? format(parseISO(calculatedEnd), "MMM d, yyyy")
: "\u2014"}
</div>
</FormItem>
</div>
{/* === DETAILS (collapsible) === */}
<Collapsible open={detailsOpen} onOpenChange={setDetailsOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors py-1 w-full"
>
{detailsOpen ? (
<IconChevronDown className="size-3.5" />
) : (
<IconChevronRight className="size-3.5" />
)}
Details
{!detailsOpen && (isEditing || hasPredecessors) && (
<span className="text-[10px] text-primary ml-1">
(has data)
</span>
)}
</button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-3">
{/* Status + Assignee + Milestone row */}
<div className="grid grid-cols-[140px_1fr_auto] gap-3 items-end">
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel className="text-[11px] text-muted-foreground font-medium">
Status
</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
)}
/>
<FormField
control={form.control}
name="assignedTo"
render={({ field }) => (
<FormItem>
<FormLabel className="text-[11px] text-muted-foreground font-medium">
Assignee
</FormLabel>
<FormControl>
<Input
placeholder="Name or team"
className="h-9"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="isMilestone"
render={({ field }) => (
<FormItem className="flex items-center gap-2 pb-0.5">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="!mt-0 text-[11px] text-muted-foreground font-medium">
Milestone
</FormLabel>
</FormItem>
)}
/>
</div>
{/* Progress */}
<FormField
control={form.control}
name="percentComplete"
render={({ field }) => (
<FormItem>
<FormLabel className="text-[11px] text-muted-foreground font-medium">
Progress
</FormLabel>
<div className="flex items-center gap-3">
<FormControl>
<Slider
min={0}
max={100}
step={5}
value={[field.value]}
onValueChange={([val]) => field.onChange(val)}
className="flex-1"
/>
</FormControl>
<span className="text-xs text-muted-foreground tabular-nums w-8 text-right">
{watchedPercent}%
</span>
</div>
</FormItem>
)}
/>
{/* Predecessors */}
<div className="space-y-2">
<span className="text-[11px] text-muted-foreground font-medium block">
Predecessors
</span>
{existingPredecessors.map((dep) => {
const predTask = allTasks.find(
(t) => t.id === dep.predecessorId
)
return (
<div
key={dep.id}
className="flex items-center gap-2 text-sm"
>
<div className="flex-1 truncate px-2 py-1.5 rounded bg-muted/40 text-xs">
{predTask?.title ?? "Unknown"}
</div>
<span className="text-[10px] text-muted-foreground shrink-0 w-8 text-center">
{dep.type}
</span>
{dep.lagDays > 0 && (
<span className="text-[10px] text-muted-foreground shrink-0">
+{dep.lagDays}d
</span>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => handleDeleteExistingDep(dep.id)}
>
<IconTrash className="size-3" />
</Button>
</div>
)
})}
{pendingPredecessors.map((pred, idx) => (
<div
key={idx}
className="grid grid-cols-[1fr_90px_60px_28px] gap-1.5 items-center"
>
<Select
value={pred.taskId}
onValueChange={(val) =>
updatePendingPredecessor(idx, "taskId", val)
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Select task" />
</SelectTrigger>
<SelectContent>
{availableTasks.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.title}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={pred.type}
onValueChange={(val) =>
updatePendingPredecessor(idx, "type", val)
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DEPENDENCY_TYPES.map((d) => (
<SelectItem key={d.value} value={d.value}>
{d.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="number"
min={0}
placeholder="Lag"
className="h-8 text-xs text-center"
value={pred.lagDays || ""}
onChange={(e) =>
updatePendingPredecessor(
idx,
"lagDays",
Number(e.target.value) || 0
)
}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => removePendingPredecessor(idx)}
>
<IconTrash className="size-3" />
</Button>
</div>
))}
{availableTasks.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-xs text-muted-foreground h-7 px-2"
onClick={addPendingPredecessor}
>
<IconPlus className="size-3 mr-1" />
Add
</Button>
)}
{availableTasks.length === 0 &&
existingPredecessors.length === 0 && (
<p className="text-[11px] text-muted-foreground/60">
No other tasks to link as predecessors.
</p>
)}
</div>
{/* Notes */}
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel className="text-[11px] text-muted-foreground font-medium">
Notes
</FormLabel>
<FormControl>
<Textarea
placeholder="Add notes..."
className="min-h-[60px] resize-none text-sm"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</CollapsibleContent>
</Collapsible>
</div>
{/* Footer */}
<div className="flex justify-end gap-2 px-5 py-3 border-t shrink-0">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit" size="sm">
{isEditing ? "Save" : "Create"}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
) )
} }

View File

@ -30,6 +30,7 @@ type ToolCategory =
| "github" | "github"
| "skills" | "skills"
| "feedback" | "feedback"
| "schedule"
interface ToolMeta { interface ToolMeta {
readonly name: string readonly name: string
@ -222,6 +223,54 @@ const TOOL_REGISTRY: ReadonlyArray<ToolMeta> = [
"the user before deleting.", "the user before deleting.",
category: "ui", category: "ui",
}, },
{
name: "getProjectSchedule",
summary:
"Get a project's full schedule: tasks, dependencies, " +
"exceptions, and a computed summary (counts, overall %, " +
"critical path). Always call before mutations to resolve " +
"task names to UUIDs.",
category: "schedule",
},
{
name: "createScheduleTask",
summary:
"Create a new task on a project schedule. Provide " +
"projectId, title, startDate (YYYY-MM-DD), workdays, " +
"and phase. Optional: isMilestone, percentComplete, " +
"assignedTo.",
category: "schedule",
},
{
name: "updateScheduleTask",
summary:
"Update a schedule task by ID. Provide only the " +
"fields to change: title, startDate, workdays, phase, " +
"status (PENDING/IN_PROGRESS/COMPLETE/BLOCKED), " +
"isMilestone, percentComplete, assignedTo.",
category: "schedule",
},
{
name: "deleteScheduleTask",
summary:
"Delete a schedule task. Always confirm with the " +
"user before deleting.",
category: "schedule",
},
{
name: "createScheduleDependency",
summary:
"Create a dependency between two tasks. Types: " +
"FS (finish-to-start), SS, FF, SF. Optional lagDays. " +
"Has built-in cycle detection.",
category: "schedule",
},
{
name: "deleteScheduleDependency",
summary:
"Delete a dependency between tasks by its ID.",
category: "schedule",
},
] ]
// categories included in minimal mode // categories included in minimal mode
@ -229,6 +278,7 @@ const MINIMAL_CATEGORIES: ReadonlySet<ToolCategory> = new Set([
"data", "data",
"navigation", "navigation",
"ui", "ui",
"schedule",
]) ])
// categories included in demo mode (read-only subset) // categories included in demo mode (read-only subset)
@ -236,6 +286,7 @@ const DEMO_CATEGORIES: ReadonlySet<ToolCategory> = new Set([
"data", "data",
"navigation", "navigation",
"ui", "ui",
"schedule",
]) ])
// --- derived state --- // --- derived state ---
@ -661,6 +712,62 @@ function buildDashboardRules(
return lines return lines
} }
function buildScheduleGuidance(
mode: PromptMode,
): ReadonlyArray<string> {
if (mode !== "full") return []
return [
"## Schedule Management",
"You can read and modify project schedules directly.",
"",
"**Resolving the projectId:**",
"- If the user is on a project page (URL contains " +
"/dashboard/projects/{id}), extract the projectId " +
"from the currentPage URL.",
"- Otherwise, ask which project or use queryData " +
'(queryType: "projects") to search by name.',
"",
"**Workflow — always read before writing:**",
"1. Call getProjectSchedule to load all tasks and " +
"dependencies.",
"2. Match the user's task name to a task UUID in the " +
"returned list.",
"3. Then call createScheduleTask, updateScheduleTask, " +
"deleteScheduleTask, createScheduleDependency, or " +
"deleteScheduleDependency as needed.",
"",
"**Construction phases:** preconstruction, sitework, " +
"foundation, framing, roofing, electrical, plumbing, " +
"hvac, insulation, drywall, finish, landscaping, closeout.",
"",
"**Task statuses:** PENDING, IN_PROGRESS, COMPLETE, BLOCKED.",
"",
"**Dependency types:**",
"- FS (finish-to-start): successor starts after " +
"predecessor finishes. Most common.",
"- SS (start-to-start): both start together.",
"- FF (finish-to-finish): both finish together.",
"- SF (start-to-finish): predecessor start triggers " +
"successor finish.",
"",
"**When to use getProjectSchedule vs queryData:**",
"- getProjectSchedule: full schedule with dependencies, " +
"critical path, exceptions — use for schedule questions " +
"and before any mutations.",
'- queryData (queryType: "schedule_tasks"): flat search ' +
"across ALL projects — use for cross-project task lookups.",
"",
"**Common patterns:**",
'- "mark X complete" → getProjectSchedule, find task ID, ' +
"updateScheduleTask with status: COMPLETE and " +
"percentComplete: 100.",
'- "what\'s on the critical path?" → getProjectSchedule, ' +
"read summary.criticalPath.",
'- "link X to Y" → getProjectSchedule, find both IDs, ' +
"createScheduleDependency with type FS.",
]
}
function buildGuidelines( function buildGuidelines(
mode: PromptMode, mode: PromptMode,
): ReadonlyArray<string> { ): ReadonlyArray<string> {
@ -749,6 +856,7 @@ export function buildSystemPrompt(ctx: PromptContext): string {
buildGitHubGuidance(state.mode), buildGitHubGuidance(state.mode),
buildThemingRules(state.mode), buildThemingRules(state.mode),
buildDashboardRules(ctx, state.mode), buildDashboardRules(ctx, state.mode),
buildScheduleGuidance(state.mode),
buildGuidelines(state.mode), buildGuidelines(state.mode),
buildPluginSections(ctx.pluginSections, state.mode), buildPluginSections(ctx.pluginSections, state.mode),
] ]

View File

@ -24,9 +24,106 @@ import {
} from "@/app/actions/dashboards" } from "@/app/actions/dashboards"
import { THEME_PRESETS, findPreset } from "@/lib/theme/presets" import { THEME_PRESETS, findPreset } from "@/lib/theme/presets"
import type { ThemeDefinition, ColorMap, ThemeFonts, ThemeTokens, ThemeShadows } from "@/lib/theme/types" import type { ThemeDefinition, ColorMap, ThemeFonts, ThemeTokens, ThemeShadows } from "@/lib/theme/types"
import { projects, scheduleTasks } from "@/db/schema" import { projects, scheduleTasks, taskDependencies, workdayExceptions } from "@/db/schema"
import { invoices, vendorBills } from "@/db/schema-netsuite" import { invoices, vendorBills } from "@/db/schema-netsuite"
import { eq, and, like } from "drizzle-orm" import { eq, and, like, asc } from "drizzle-orm"
import { calculateEndDate } from "@/lib/schedule/business-days"
import { findCriticalPath } from "@/lib/schedule/critical-path"
import { wouldCreateCycle } from "@/lib/schedule/dependency-validation"
import { propagateDates } from "@/lib/schedule/propagate-dates"
import { revalidatePath } from "next/cache"
import { isDemoUser } from "@/lib/demo"
import type {
TaskStatus,
DependencyType,
ExceptionCategory,
ExceptionRecurrence,
WorkdayExceptionData,
} from "@/lib/schedule/types"
// shared auth + project verification for schedule tools.
// uses ID-only lookup (no org check) to match the schedule
// page behavior -- the middleware already restricts access
// to authenticated users.
type ScheduleCtxOk = {
readonly ok: true
readonly db: ReturnType<typeof getDb>
readonly userId: string
}
type ScheduleCtxErr = {
readonly ok: false
readonly error: string
}
type ScheduleCtxResult = ScheduleCtxOk | ScheduleCtxErr
async function requireScheduleCtx(
projectId: string,
writeable?: boolean,
): Promise<ScheduleCtxResult> {
const user = await getCurrentUser()
if (!user) return { ok: false, error: "not authenticated" }
if (writeable && isDemoUser(user.id)) {
return { ok: false, error: "DEMO_READ_ONLY" }
}
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const [project] = await db
.select({ id: projects.id })
.from(projects)
.where(eq(projects.id, projectId))
.limit(1)
if (!project) {
return { ok: false, error: "Project not found" }
}
return { ok: true, db, userId: user.id }
}
// fetch workday exceptions for a project (typed)
async function fetchProjectExceptions(
db: ReturnType<typeof getDb>,
projectId: string,
): Promise<WorkdayExceptionData[]> {
const rows = await db
.select()
.from(workdayExceptions)
.where(eq(workdayExceptions.projectId, projectId))
return rows.map((r) => ({
...r,
category: r.category as ExceptionCategory,
recurrence: r.recurrence as ExceptionRecurrence,
}))
}
// load deps filtered to a project's tasks
async function fetchProjectDeps(
db: ReturnType<typeof getDb>,
projectId: string,
) {
const tasks = await db
.select()
.from(scheduleTasks)
.where(eq(scheduleTasks.projectId, projectId))
const allDeps = await db.select().from(taskDependencies)
const taskIdSet = new Set(tasks.map((t) => t.id))
const deps = allDeps.filter(
(d) =>
taskIdSet.has(d.predecessorId) &&
taskIdSet.has(d.successorId),
)
return {
tasks: tasks.map((t) => ({
...t,
status: t.status as TaskStatus,
})),
deps: deps.map((d) => ({
...d,
type: d.type as DependencyType,
})),
}
}
const queryDataInputSchema = z.object({ const queryDataInputSchema = z.object({
queryType: z.enum([ queryType: z.enum([
@ -900,4 +997,593 @@ export const agentTools = {
} }
}, },
}), }),
getProjectSchedule: tool({
description:
"Get the full schedule for a project including tasks, " +
"dependencies, workday exceptions, and a computed summary " +
"(counts, overall %, critical path). Always call this " +
"before making schedule mutations to resolve task names " +
"to IDs.",
inputSchema: z.object({
projectId: z.string().describe("The project UUID"),
}),
execute: async (input: { projectId: string }) => {
const ctx = await requireScheduleCtx(input.projectId)
if (!ctx.ok) return { error: ctx.error }
const { db } = ctx
const { tasks: typedTasks, deps: typedDeps } =
await fetchProjectDeps(db, input.projectId)
const exceptions = await fetchProjectExceptions(
db,
input.projectId,
)
const total = typedTasks.length
const completed = typedTasks.filter(
(t) => t.status === "COMPLETE",
).length
const inProgress = typedTasks.filter(
(t) => t.status === "IN_PROGRESS",
).length
const blocked = typedTasks.filter(
(t) => t.status === "BLOCKED",
).length
const overallPercent =
total > 0
? Math.round(
typedTasks.reduce(
(sum, t) => sum + t.percentComplete,
0,
) / total,
)
: 0
const criticalPath = typedTasks
.filter((t) => t.isCriticalPath)
.map((t) => ({
id: t.id,
title: t.title,
status: t.status,
startDate: t.startDate,
endDate: t.endDateCalculated,
}))
return {
tasks: typedTasks,
dependencies: typedDeps,
exceptions,
summary: {
total,
completed,
inProgress,
blocked,
pending: total - completed - inProgress - blocked,
overallPercent,
criticalPath,
},
}
},
}),
createScheduleTask: tool({
description:
"Create a new task on a project schedule. Returns a " +
"toast confirmation. Dates are ISO format (YYYY-MM-DD).",
inputSchema: z.object({
projectId: z.string().describe("The project UUID"),
title: z.string().describe("Task title"),
startDate: z
.string()
.describe("Start date in YYYY-MM-DD format"),
workdays: z
.number()
.describe("Duration in working days"),
phase: z
.string()
.describe(
"Construction phase (preconstruction, sitework, " +
"foundation, framing, roofing, electrical, plumbing, " +
"hvac, insulation, drywall, finish, landscaping, closeout)",
),
isMilestone: z
.boolean()
.optional()
.describe("Whether this is a milestone (0 workdays)"),
percentComplete: z
.number()
.min(0)
.max(100)
.optional()
.describe("Initial percent complete (0-100)"),
assignedTo: z
.string()
.optional()
.describe("Name of the person assigned"),
}),
execute: async (input: {
projectId: string
title: string
startDate: string
workdays: number
phase: string
isMilestone?: boolean
percentComplete?: number
assignedTo?: string
}) => {
const ctx = await requireScheduleCtx(
input.projectId,
true,
)
if (!ctx.ok) return { error: ctx.error }
const { db } = ctx
const exceptions = await fetchProjectExceptions(
db,
input.projectId,
)
const endDate = calculateEndDate(
input.startDate,
input.workdays,
exceptions,
)
const now = new Date().toISOString()
const existing = await db
.select({ sortOrder: scheduleTasks.sortOrder })
.from(scheduleTasks)
.where(eq(scheduleTasks.projectId, input.projectId))
.orderBy(asc(scheduleTasks.sortOrder))
const nextOrder =
existing.length > 0
? existing[existing.length - 1].sortOrder + 1
: 0
const id = crypto.randomUUID()
await db.insert(scheduleTasks).values({
id,
projectId: input.projectId,
title: input.title,
startDate: input.startDate,
workdays: input.workdays,
endDateCalculated: endDate,
phase: input.phase,
status: "PENDING",
isCriticalPath: false,
isMilestone: input.isMilestone ?? false,
percentComplete: input.percentComplete ?? 0,
assignedTo: input.assignedTo ?? null,
sortOrder: nextOrder,
createdAt: now,
updatedAt: now,
})
await recalcCriticalPathDirect(db, input.projectId)
revalidatePath(
`/dashboard/projects/${input.projectId}/schedule`,
)
return {
action: "toast" as const,
message: `Task "${input.title}" created`,
type: "success",
}
},
}),
updateScheduleTask: tool({
description:
"Update an existing schedule task. Provide only the " +
"fields to change. Use getProjectSchedule first to " +
"resolve task names to IDs.",
inputSchema: z.object({
taskId: z.string().describe("The task UUID"),
title: z.string().optional().describe("New title"),
startDate: z
.string()
.optional()
.describe("New start date (YYYY-MM-DD)"),
workdays: z
.number()
.optional()
.describe("New duration in working days"),
phase: z.string().optional().describe("New phase"),
status: z
.enum(["PENDING", "IN_PROGRESS", "COMPLETE", "BLOCKED"])
.optional()
.describe("New status"),
isMilestone: z
.boolean()
.optional()
.describe("Set milestone flag"),
percentComplete: z
.number()
.min(0)
.max(100)
.optional()
.describe("New percent complete (0-100)"),
assignedTo: z
.string()
.nullable()
.optional()
.describe("New assignee (null to unassign)"),
}),
execute: async (input: {
taskId: string
title?: string
startDate?: string
workdays?: number
phase?: string
status?: "PENDING" | "IN_PROGRESS" | "COMPLETE" | "BLOCKED"
isMilestone?: boolean
percentComplete?: number
assignedTo?: string | null
}) => {
const user = await getCurrentUser()
if (!user) return { error: "not authenticated" }
if (isDemoUser(user.id)) {
return { error: "DEMO_READ_ONLY" }
}
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const [task] = await db
.select()
.from(scheduleTasks)
.where(eq(scheduleTasks.id, input.taskId))
.limit(1)
if (!task) return { error: "Task not found" }
const { taskId, status, ...fields } = input
const hasFields = Object.keys(fields).length > 0
if (hasFields) {
const exceptions = await fetchProjectExceptions(
db,
task.projectId,
)
const startDate = fields.startDate ?? task.startDate
const workdays = fields.workdays ?? task.workdays
const endDate = calculateEndDate(
startDate,
workdays,
exceptions,
)
await db
.update(scheduleTasks)
.set({
...(fields.title && { title: fields.title }),
startDate,
workdays,
endDateCalculated: endDate,
...(fields.phase && { phase: fields.phase }),
...(fields.isMilestone !== undefined && {
isMilestone: fields.isMilestone,
}),
...(fields.percentComplete !== undefined && {
percentComplete: fields.percentComplete,
}),
...(fields.assignedTo !== undefined && {
assignedTo: fields.assignedTo,
}),
updatedAt: new Date().toISOString(),
})
.where(eq(scheduleTasks.id, taskId))
// propagate date changes to downstream tasks
const allTasks = await db
.select()
.from(scheduleTasks)
.where(eq(scheduleTasks.projectId, task.projectId))
const allDeps = await db
.select()
.from(taskDependencies)
const taskIdSet = new Set(allTasks.map((t) => t.id))
const projectDeps = allDeps
.filter(
(d) =>
taskIdSet.has(d.predecessorId) &&
taskIdSet.has(d.successorId),
)
.map((d) => ({
...d,
type: d.type as DependencyType,
}))
const updatedTask = {
...task,
status: task.status as TaskStatus,
startDate,
workdays,
endDateCalculated: endDate,
}
const typedAll = allTasks.map((t) =>
t.id === taskId
? updatedTask
: { ...t, status: t.status as TaskStatus },
)
const { updatedTasks } = propagateDates(
taskId,
typedAll,
projectDeps,
exceptions,
)
for (const [id, dates] of updatedTasks) {
await db
.update(scheduleTasks)
.set({
startDate: dates.startDate,
endDateCalculated: dates.endDateCalculated,
updatedAt: new Date().toISOString(),
})
.where(eq(scheduleTasks.id, id))
}
}
if (status) {
await db
.update(scheduleTasks)
.set({
status,
updatedAt: new Date().toISOString(),
})
.where(eq(scheduleTasks.id, taskId))
}
if (!hasFields && !status) {
return { error: "No fields provided to update" }
}
await recalcCriticalPathDirect(db, task.projectId)
revalidatePath(
`/dashboard/projects/${task.projectId}/schedule`,
)
return {
action: "toast" as const,
message: "Task updated",
type: "success",
}
},
}),
deleteScheduleTask: tool({
description:
"Delete a schedule task. Always confirm with the user " +
"before deleting. This also removes any dependencies " +
"involving the task.",
inputSchema: z.object({
taskId: z.string().describe("The task UUID to delete"),
}),
execute: async (input: { taskId: string }) => {
const user = await getCurrentUser()
if (!user) return { error: "not authenticated" }
if (isDemoUser(user.id)) {
return { error: "DEMO_READ_ONLY" }
}
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const [task] = await db
.select()
.from(scheduleTasks)
.where(eq(scheduleTasks.id, input.taskId))
.limit(1)
if (!task) return { error: "Task not found" }
await db
.delete(scheduleTasks)
.where(eq(scheduleTasks.id, input.taskId))
await recalcCriticalPathDirect(db, task.projectId)
revalidatePath(
`/dashboard/projects/${task.projectId}/schedule`,
)
return {
action: "toast" as const,
message: "Task deleted",
type: "success",
}
},
}),
createScheduleDependency: tool({
description:
"Create a dependency between two tasks. Has built-in " +
"cycle detection. Use getProjectSchedule first to " +
"resolve task names to IDs.",
inputSchema: z.object({
projectId: z
.string()
.describe("The project UUID"),
predecessorId: z
.string()
.describe("UUID of the predecessor task"),
successorId: z
.string()
.describe("UUID of the successor task"),
type: z
.enum(["FS", "SS", "FF", "SF"])
.describe(
"Dependency type: FS (finish-to-start), " +
"SS (start-to-start), FF (finish-to-finish), " +
"SF (start-to-finish)",
),
lagDays: z
.number()
.optional()
.describe("Lag in working days (default 0)"),
}),
execute: async (input: {
projectId: string
predecessorId: string
successorId: string
type: "FS" | "SS" | "FF" | "SF"
lagDays?: number
}) => {
const ctx = await requireScheduleCtx(
input.projectId,
true,
)
if (!ctx.ok) return { error: ctx.error }
const { db } = ctx
// load schedule for cycle check + propagation
const { tasks: typedTasks, deps: existingDeps } =
await fetchProjectDeps(db, input.projectId)
if (
wouldCreateCycle(
existingDeps,
input.predecessorId,
input.successorId,
)
) {
return {
error: "This dependency would create a cycle",
}
}
const depId = crypto.randomUUID()
await db.insert(taskDependencies).values({
id: depId,
predecessorId: input.predecessorId,
successorId: input.successorId,
type: input.type,
lagDays: input.lagDays ?? 0,
})
// propagate dates
const exceptions = await fetchProjectExceptions(
db,
input.projectId,
)
const updatedDeps = [
...existingDeps,
{
id: depId,
predecessorId: input.predecessorId,
successorId: input.successorId,
type: input.type as DependencyType,
lagDays: input.lagDays ?? 0,
},
]
const { updatedTasks } = propagateDates(
input.predecessorId,
typedTasks,
updatedDeps,
exceptions,
)
for (const [id, dates] of updatedTasks) {
await db
.update(scheduleTasks)
.set({
startDate: dates.startDate,
endDateCalculated: dates.endDateCalculated,
updatedAt: new Date().toISOString(),
})
.where(eq(scheduleTasks.id, id))
}
await recalcCriticalPathDirect(db, input.projectId)
revalidatePath(
`/dashboard/projects/${input.projectId}/schedule`,
)
return {
action: "toast" as const,
message: "Dependency created",
type: "success",
}
},
}),
deleteScheduleDependency: tool({
description:
"Delete a dependency between tasks. Use " +
"getProjectSchedule first to find the dependency ID.",
inputSchema: z.object({
dependencyId: z
.string()
.describe("The dependency UUID to delete"),
projectId: z
.string()
.describe("The project UUID (for revalidation)"),
}),
execute: async (input: {
dependencyId: string
projectId: string
}) => {
const ctx = await requireScheduleCtx(
input.projectId,
true,
)
if (!ctx.ok) return { error: ctx.error }
const { db } = ctx
await db
.delete(taskDependencies)
.where(eq(taskDependencies.id, input.dependencyId))
await recalcCriticalPathDirect(db, input.projectId)
revalidatePath(
`/dashboard/projects/${input.projectId}/schedule`,
)
return {
action: "toast" as const,
message: "Dependency removed",
type: "success",
}
},
}),
}
// recalculates critical path for a project (inline version
// that doesn't depend on server action auth context)
async function recalcCriticalPathDirect(
db: ReturnType<typeof getDb>,
projectId: string,
): Promise<void> {
const tasks = await db
.select()
.from(scheduleTasks)
.where(eq(scheduleTasks.projectId, projectId))
const allDeps = await db.select().from(taskDependencies)
const taskIdSet = new Set(tasks.map((t) => t.id))
const projectDeps = allDeps.filter(
(d) =>
taskIdSet.has(d.predecessorId) &&
taskIdSet.has(d.successorId),
)
const criticalSet = findCriticalPath(
tasks.map((t) => ({
...t,
status: t.status as TaskStatus,
})),
projectDeps.map((d) => ({
...d,
type: d.type as DependencyType,
})),
)
for (const task of tasks) {
const isCritical = criticalSet.has(task.id)
if (task.isCriticalPath !== isCritical) {
await db
.update(scheduleTasks)
.set({ isCriticalPath: isCritical })
.where(eq(scheduleTasks.id, task.id))
}
}
} }

View File

@ -79,3 +79,40 @@ export interface ScheduleData {
dependencies: TaskDependencyData[] dependencies: TaskDependencyData[]
exceptions: WorkdayExceptionData[] exceptions: WorkdayExceptionData[]
} }
export interface TaskFilters {
readonly status: readonly TaskStatus[]
readonly phase: readonly ConstructionPhase[]
readonly assignedTo: string
readonly search: string
}
export const EMPTY_FILTERS: TaskFilters = {
status: [],
phase: [],
assignedTo: "",
search: "",
}
export const STATUS_OPTIONS: readonly { readonly value: TaskStatus; readonly label: string }[] = [
{ value: "PENDING", label: "Pending" },
{ value: "IN_PROGRESS", label: "In Progress" },
{ value: "COMPLETE", label: "Complete" },
{ value: "BLOCKED", label: "Blocked" },
] as const
export const PHASE_OPTIONS: readonly { readonly value: ConstructionPhase; readonly 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" },
] as const