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

View File

@ -13,18 +13,12 @@ import {
isToday,
isSameMonth,
isWeekend,
isSameDay,
parseISO,
isWithinInterval,
differenceInCalendarDays,
} from "date-fns"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
IconChevronLeft,
IconChevronRight,
@ -35,14 +29,19 @@ import type {
} from "@/lib/schedule/types"
interface ScheduleCalendarViewProps {
projectId: string
tasks: ScheduleTaskData[]
exceptions: WorkdayExceptionData[]
readonly projectId: string
readonly tasks: readonly ScheduleTaskData[]
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(
date: Date,
exceptions: WorkdayExceptionData[]
exceptions: readonly WorkdayExceptionData[]
): boolean {
return exceptions.some((ex) => {
const start = parseISO(ex.startDate)
@ -52,72 +51,171 @@ function isExceptionDay(
}
function getTaskColor(task: ScheduleTaskData): string {
if (task.status === "COMPLETE") return "bg-green-500"
if (task.status === "IN_PROGRESS") return "bg-blue-500"
if (task.status === "BLOCKED") return "bg-red-500"
if (task.isCriticalPath) return "bg-orange-500"
return "bg-gray-400"
if (task.status === "COMPLETE") return "bg-green-600/90 dark:bg-green-600/80"
if (task.status === "IN_PROGRESS") return "bg-blue-600/90 dark:bg-blue-500/80"
if (task.status === "BLOCKED") return "bg-red-600/90 dark:bg-red-500/80"
if (task.isCriticalPath) return "bg-orange-600/90 dark:bg-orange-500/80"
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({
tasks,
exceptions,
}: ScheduleCalendarViewProps) {
const [currentDate, setCurrentDate] = useState(new Date())
const [expandedCells, setExpandedCells] = useState<Set<string>>(
new Set()
)
const monthStart = startOfMonth(currentDate)
const monthEnd = endOfMonth(currentDate)
const calendarStart = startOfWeek(monthStart)
const calendarEnd = endOfWeek(monthEnd)
const days = useMemo(
const calendarDays = useMemo(
() => eachDayOfInterval({ start: calendarStart, end: calendarEnd }),
[calendarStart.getTime(), calendarEnd.getTime()]
)
const tasksByDate = useMemo(() => {
const map = new Map<string, ScheduleTaskData[]>()
for (const task of 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
})
}
const weekRows = useMemo(
() => buildWeekRows(calendarDays, tasks),
[calendarDays, tasks]
)
return (
<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">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentDate(new Date())}
className="h-9"
>
Today
</Button>
{/* Calendar controls */}
<div className="flex items-center gap-2 mb-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentDate(new Date())}
className="h-8 text-xs"
>
Today
</Button>
<div className="flex items-center">
<Button
variant="ghost"
size="icon"
className="size-9"
className="size-8"
onClick={() => setCurrentDate(subMonths(currentDate, 1))}
>
<IconChevronLeft className="size-4" />
@ -125,106 +223,121 @@ export function ScheduleCalendarView({
<Button
variant="ghost"
size="icon"
className="size-9"
className="size-8"
onClick={() => setCurrentDate(addMonths(currentDate, 1))}
>
<IconChevronRight className="size-4" />
</Button>
<h2 className="text-base sm:text-lg font-medium whitespace-nowrap">
{format(currentDate, "MMMM yyyy")}
</h2>
</div>
<Select defaultValue="month">
<SelectTrigger className="h-9 w-28 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="month">Month</SelectItem>
<SelectItem value="day">Day</SelectItem>
</SelectContent>
</Select>
<h2 className="text-sm font-medium">
{format(currentDate, "MMMM yyyy")}
</h2>
</div>
{/* Calendar grid */}
<div className="border rounded-md overflow-hidden flex flex-col flex-1 min-h-0">
<div className="grid grid-cols-7 border-b">
{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map(
(day) => (
<div
key={day}
className="text-center text-xs font-medium text-muted-foreground py-2 border-r last:border-r-0"
>
{day}
</div>
)
)}
{/* Weekday headers */}
<div className="grid grid-cols-7 border-b bg-muted/30">
{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((day) => (
<div
key={day}
className="text-center text-[11px] font-medium text-muted-foreground py-1.5 border-r last:border-r-0"
>
{day}
</div>
))}
</div>
<div className="grid grid-cols-7 flex-1">
{days.map((day) => {
const dateKey = format(day, "yyyy-MM-dd")
const dayTasks = tasksByDate.get(dateKey) || []
const isNonWork =
isWeekend(day) || isExceptionDay(day, exceptions)
const inMonth = isSameMonth(day, currentDate)
const expanded = expandedCells.has(dateKey)
const visibleTasks = expanded
? dayTasks
: dayTasks.slice(0, MAX_VISIBLE_TASKS)
const overflow = dayTasks.length - MAX_VISIBLE_TASKS
{/* Week rows */}
<div className="flex flex-col flex-1 min-h-0">
{weekRows.map((week, weekIdx) => {
const visibleLanes = Math.min(week.maxLane + 1, MAX_LANES)
const contentHeight = DAY_HEADER_HEIGHT + visibleLanes * LANE_HEIGHT
const hasOverflow = week.overflowByDay.some((n) => n > 0)
const totalHeight = contentHeight + (hasOverflow ? 16 : 0)
return (
<div
key={dateKey}
className={`min-h-[60px] sm:min-h-[80px] border-r border-b last:border-r-0 p-1 sm:p-1.5 ${
!inMonth ? "bg-muted/30" : ""
} ${isNonWork ? "bg-muted/50" : ""}`}
key={weekIdx}
className="relative border-b last:border-b-0 flex-1"
style={{ minHeight: `${Math.max(totalHeight, 60)}px` }}
>
<div className="flex items-start justify-between mb-0.5 min-w-0">
<span
className={`text-xs shrink-0 ${
isToday(day)
? "bg-primary text-primary-foreground rounded-full size-5 sm:size-6 flex items-center justify-center font-bold"
: inMonth
? "text-foreground"
: "text-muted-foreground"
}`}
>
{format(day, "d")}
</span>
{isNonWork && (
<span className="text-[8px] sm:text-[9px] text-muted-foreground truncate ml-1">
<span className="hidden sm:inline">Non-workday</span>
<span className="sm:hidden">Off</span>
</span>
)}
{/* Day cells (background + day numbers) */}
<div className="grid grid-cols-7 absolute inset-0">
{week.days.map((day) => {
const inMonth = isSameMonth(day, currentDate)
const isNonWork =
isWeekend(day) || isExceptionDay(day, exceptions)
return (
<div
key={format(day, "yyyy-MM-dd")}
className={cn(
"border-r last:border-r-0 p-1",
!inMonth && "bg-muted/20",
isNonWork && inMonth && "bg-muted/40",
)}
>
<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 className="space-y-0.5">
{visibleTasks.map((task) => (
{/* Task bars (overlaid) */}
{week.tasks
.filter((wt) => wt.lane < MAX_LANES)
.map((wt) => (
<div
key={task.id}
className={`${getTaskColor(task)} text-white text-[9px] sm:text-[10px] px-1 py-0.5 rounded truncate`}
title={task.title}
key={`${wt.task.id}-${weekIdx}`}
className={cn(
"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>
))}
{!expanded && overflow > 0 && (
<button
className="text-[9px] sm:text-[10px] text-primary hover:underline"
onClick={() => toggleExpand(dateKey)}
>
+{overflow}
</button>
)}
{expanded && dayTasks.length > MAX_VISIBLE_TASKS && (
<button
className="text-[9px] sm:text-[10px] text-primary hover:underline"
onClick={() => toggleExpand(dateKey)}
>
Less
</button>
)}
</div>
{/* Overflow indicators */}
{hasOverflow && (
<div
className="grid grid-cols-7 absolute left-0 right-0"
style={{ top: `${contentHeight}px` }}
>
{week.overflowByDay.map((count, dayIdx) => (
<div
key={dayIdx}
className="text-[10px] text-primary px-1 border-r last:border-r-0"
>
{count > 0 ? `+${count} more` : ""}
</div>
))}
</div>
)}
</div>
)
})}

View File

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

View File

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

View File

@ -1,10 +1,57 @@
"use client"
import { useState, useMemo } from "react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useState, useMemo, useRef } from "react"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { useIsMobile } from "@/hooks/use-mobile"
import { ScheduleToolbar, type TaskFilters } from "./schedule-toolbar"
import { ProjectSwitcher } from "./project-switcher"
import { Input } from "@/components/ui/input"
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 { ScheduleGanttView } from "./schedule-gantt-view"
import { ScheduleCalendarView } from "./schedule-calendar-view"
@ -15,24 +62,30 @@ import { TaskFormDialog } from "./task-form-dialog"
import type {
ScheduleData,
ScheduleBaselineData,
TaskFilters,
TaskStatus,
ConstructionPhase,
} from "@/lib/schedule/types"
import {
EMPTY_FILTERS,
STATUS_OPTIONS,
PHASE_OPTIONS,
} from "@/lib/schedule/types"
type TopTab = "schedule" | "baseline" | "exceptions"
type ScheduleSubTab = "calendar" | "list" | "gantt"
type View = "calendar" | "list" | "gantt"
const DEFAULT_FILTERS: TaskFilters = {
status: [],
phase: [],
assignedTo: "",
search: "",
}
const VIEW_OPTIONS = [
{ value: "calendar" as const, icon: IconCalendar, label: "Calendar" },
{ value: "list" as const, icon: IconList, label: "List" },
{ value: "gantt" as const, icon: IconTimeline, label: "Gantt" },
]
interface ScheduleViewProps {
projectId: string
projectName: string
initialData: ScheduleData
baselines: ScheduleBaselineData[]
allProjects?: { id: string; name: string }[]
readonly projectId: string
readonly projectName: string
readonly initialData: ScheduleData
readonly baselines: ScheduleBaselineData[]
readonly allProjects?: readonly { id: string; name: string }[]
}
export function ScheduleView({
@ -40,13 +93,16 @@ export function ScheduleView({
projectName,
initialData,
baselines,
allProjects = [],
}: ScheduleViewProps) {
const isMobile = useIsMobile()
const [topTab, setTopTab] = useState<TopTab>("schedule")
const [subTab, setSubTab] = useState<ScheduleSubTab>("calendar")
const [view, setView] = useState<View>("calendar")
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(() => {
let tasks = initialData.tasks
@ -54,147 +110,512 @@ export function ScheduleView({
if (filters.status.length > 0) {
tasks = tasks.filter((t) => filters.status.includes(t.status))
}
if (filters.phase.length > 0) {
tasks = tasks.filter((t) => filters.phase.includes(t.phase as never))
}
if (filters.assignedTo) {
const search = filters.assignedTo.toLowerCase()
tasks = tasks.filter(
(t) => t.assignedTo?.toLowerCase().includes(search)
tasks = tasks.filter((t) =>
filters.phase.includes(t.phase as ConstructionPhase)
)
}
if (filters.assignedTo) {
const search = filters.assignedTo.toLowerCase()
tasks = tasks.filter((t) =>
t.assignedTo?.toLowerCase().includes(search)
)
}
if (filters.search) {
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
}, [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 (
<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">
<div className="flex items-center gap-3">
<ProjectSwitcher
projects={allProjects}
currentProjectId={projectId}
currentProjectName={projectName}
/>
<span className="text-muted-foreground">- Schedule</span>
{/* Header: breadcrumb + view toggle + new task */}
<div className="flex items-center gap-3 mb-3">
<nav className="flex items-center gap-1.5 text-sm min-w-0">
<Link
href={`/dashboard/projects/${projectId}`}
className="text-muted-foreground hover:text-foreground truncate transition-colors"
>
{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>
<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>
<Tabs
value={topTab}
onValueChange={(v) => setTopTab(v as TopTab)}
className="flex flex-col flex-1 min-h-0"
>
<TabsContent value="schedule" className="mt-0 flex flex-col flex-1 min-h-0">
<ScheduleToolbar
onNewItem={() => setTaskFormOpen(true)}
filters={filters}
onFiltersChange={setFilters}
projectName={projectName}
tasksCount={filteredTasks.length}
tasks={filteredTasks}
{/* Action bar: search, filters, overflow */}
<div className="flex items-center gap-2 mb-3 print:hidden">
{/* Search */}
<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" />
<Input
placeholder="Search tasks..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="h-8 pl-8 text-sm"
/>
</div>
<Tabs
value={subTab}
onValueChange={(v) => setSubTab(v as ScheduleSubTab)}
className="flex flex-col flex-1 min-h-0"
>
<TabsList className="bg-transparent border-b rounded-none h-auto p-0 gap-4">
<TabsTrigger
value="calendar"
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"
>
Calendar
</TabsTrigger>
<TabsTrigger
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}
/>
{/* Filter popover */}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 shrink-0">
<IconFilter className="size-3.5" />
<span className="hidden sm:inline ml-1.5">Filters</span>
{activeFilterCount > 0 && (
<Badge
variant="secondary"
className="ml-1.5 h-4 min-w-4 px-1 text-[10px] rounded-full"
>
{activeFilterCount}
</Badge>
)}
</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>
<ScheduleListView
projectId={projectId}
tasks={filteredTasks}
dependencies={initialData.dependencies}
/>
</TabsContent>
{/* Active filter chips */}
<div className="hidden sm:flex items-center gap-1 overflow-x-auto min-w-0">
{filters.status.map((s) => (
<Badge
key={s}
variant="outline"
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>
<ScheduleGanttView
projectId={projectId}
tasks={filteredTasks}
dependencies={initialData.dependencies}
/>
</TabsContent>
</Tabs>
</TabsContent>
<div className="ml-auto flex items-center gap-2 shrink-0">
<span className="text-xs text-muted-foreground hidden sm:inline tabular-nums">
{filteredTasks.length} task{filteredTasks.length !== 1 ? "s" : ""}
</span>
<TabsContent value="baseline" className="mt-2">
<ScheduleBaselineView
{/* Overflow menu */}
<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}
baselines={baselines}
currentTasks={initialData.tasks}
tasks={filteredTasks}
dependencies={initialData.dependencies}
/>
</TabsContent>
<TabsContent value="exceptions" className="mt-2">
<WorkdayExceptionsView
)}
{view === "gantt" && (
<ScheduleGanttView
projectId={projectId}
exceptions={initialData.exceptions}
tasks={filteredTasks}
dependencies={initialData.dependencies}
/>
</TabsContent>
</Tabs>
)}
</div>
{/* New task dialog */}
<TaskFormDialog
open={taskFormOpen}
onOpenChange={setTaskFormOpen}
projectId={projectId}
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>
)
}

View File

@ -1,15 +1,16 @@
"use client"
import * as React from "react"
import { useEffect, useMemo } from "react"
import { useEffect, useMemo, useState } from "react"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import {
ResponsiveDialog,
ResponsiveDialogBody,
ResponsiveDialogFooter,
} from "@/components/ui/responsive-dialog"
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Form,
FormControl,
@ -19,13 +20,25 @@ import {
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Calendar } from "@/components/ui/calendar"
import {
Popover,
PopoverContent,
PopoverTrigger,
} 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 { Slider } from "@/components/ui/slider"
import {
@ -37,26 +50,46 @@ import {
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
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 type { ScheduleTaskData } from "@/lib/schedule/types"
import { PHASE_ORDER, PHASE_LABELS } from "@/lib/schedule/phase-colors"
import type {
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 { toast } from "sonner"
import { cn } from "@/lib/utils"
const phases = PHASE_ORDER.map((value) => ({
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({
title: z.string().min(1, "Title is required"),
startDate: z.string().min(1, "Start date is required"),
workdays: z.number().min(1, "Must be at least 1 day"),
phase: z.string().min(1, "Phase is required"),
status: z.string(),
isMilestone: z.boolean(),
percentComplete: z.number().min(0).max(100),
assignedTo: z.string(),
notes: z.string(),
})
type TaskFormValues = z.infer<typeof taskSchema>
@ -66,6 +99,14 @@ interface TaskFormDialogProps {
onOpenChange: (open: boolean) => void
projectId: string
editingTask: ScheduleTaskData | null
allTasks?: readonly ScheduleTaskData[]
dependencies?: readonly TaskDependencyData[]
}
interface PendingPredecessor {
taskId: string
type: DependencyType
lagDays: number
}
export function TaskFormDialog({
@ -73,9 +114,24 @@ export function TaskFormDialog({
onOpenChange,
projectId,
editingTask,
allTasks = [],
dependencies = [],
}: TaskFormDialogProps) {
const router = useRouter()
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>({
resolver: zodResolver(taskSchema),
@ -84,9 +140,11 @@ export function TaskFormDialog({
startDate: new Date().toISOString().split("T")[0],
workdays: 5,
phase: "preconstruction",
status: "PENDING",
isMilestone: false,
percentComplete: 0,
assignedTo: "",
notes: "",
},
})
@ -97,25 +155,35 @@ export function TaskFormDialog({
startDate: editingTask.startDate,
workdays: editingTask.workdays,
phase: editingTask.phase,
status: editingTask.status,
isMilestone: editingTask.isMilestone,
percentComplete: editingTask.percentComplete,
assignedTo: editingTask.assignedTo ?? "",
notes: "",
})
// expand details when editing since they likely want to see everything
setDetailsOpen(true)
} else {
form.reset({
title: "",
startDate: new Date().toISOString().split("T")[0],
workdays: 5,
phase: "preconstruction",
status: "PENDING",
isMilestone: false,
percentComplete: 0,
assignedTo: "",
notes: "",
})
setDetailsOpen(false)
}
setPendingPredecessors([])
}, [editingTask, form])
const watchedStart = form.watch("startDate")
const watchedWorkdays = form.watch("workdays")
const watchedPhase = form.watch("phase")
const watchedPercent = form.watch("percentComplete")
const calculatedEnd = useMemo(() => {
if (!watchedStart || !watchedWorkdays || watchedWorkdays < 1) return ""
@ -137,6 +205,17 @@ export function TaskFormDialog({
}
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)
router.refresh()
} else {
@ -144,206 +223,470 @@ export function TaskFormDialog({
}
}
const page1 = (
<>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Title</FormLabel>
<FormControl>
<Input placeholder="Task title" className="h-9" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
const addPendingPredecessor = () => {
setPendingPredecessors((prev) => [
...prev,
{ taskId: "", type: "FS", lagDays: 0 },
])
}
<FormField
control={form.control}
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>
)}
/>
const removePendingPredecessor = (index: number) => {
setPendingPredecessors((prev) => prev.filter((_, i) => i !== index))
}
<FormField
control={form.control}
name="workdays"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Workdays</FormLabel>
<FormControl>
<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 updatePendingPredecessor = (
index: number,
field: keyof PendingPredecessor,
value: string | number
) => {
setPendingPredecessors((prev) =>
prev.map((p, i) => (i === index ? { ...p, [field]: value } : p))
)
}
const page2 = (
<>
<FormField
control={form.control}
name="phase"
render={({ field }) => (
<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>
)}
/>
const handleDeleteExistingDep = async (depId: string) => {
const result = await deleteDependency(depId, projectId)
if (result.success) {
router.refresh()
} else {
toast.error(result.error)
}
}
<FormField
control={form.control}
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>
)}
/>
</>
)
const hasPredecessors =
existingPredecessors.length > 0 || pendingPredecessors.length > 0
return (
<ResponsiveDialog
open={open}
onOpenChange={onOpenChange}
title={isEditing ? "Edit Task" : "New Task"}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0">
<ResponsiveDialogBody pages={[page1, page2, page3]} />
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl max-h-[85vh] flex flex-col overflow-hidden p-0 gap-0">
<DialogHeader className="px-5 pt-4 pb-3 shrink-0">
<DialogTitle className="text-base font-semibold">
{isEditing ? "Edit Task" : "New Task"}
</DialogTitle>
</DialogHeader>
<ResponsiveDialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="h-9"
>
Cancel
</Button>
<Button type="submit" className="h-9">
{isEditing ? "Save" : "Create"}
</Button>
</ResponsiveDialogFooter>
</form>
</Form>
</ResponsiveDialog>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col flex-1 min-h-0"
>
<div className="overflow-y-auto flex-1 min-h-0 px-5 pb-4 space-y-4">
{/* === ESSENTIAL FIELDS === */}
{/* Title */}
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormControl>
<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"
| "skills"
| "feedback"
| "schedule"
interface ToolMeta {
readonly name: string
@ -222,6 +223,54 @@ const TOOL_REGISTRY: ReadonlyArray<ToolMeta> = [
"the user before deleting.",
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
@ -229,6 +278,7 @@ const MINIMAL_CATEGORIES: ReadonlySet<ToolCategory> = new Set([
"data",
"navigation",
"ui",
"schedule",
])
// categories included in demo mode (read-only subset)
@ -236,6 +286,7 @@ const DEMO_CATEGORIES: ReadonlySet<ToolCategory> = new Set([
"data",
"navigation",
"ui",
"schedule",
])
// --- derived state ---
@ -661,6 +712,62 @@ function buildDashboardRules(
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(
mode: PromptMode,
): ReadonlyArray<string> {
@ -749,6 +856,7 @@ export function buildSystemPrompt(ctx: PromptContext): string {
buildGitHubGuidance(state.mode),
buildThemingRules(state.mode),
buildDashboardRules(ctx, state.mode),
buildScheduleGuidance(state.mode),
buildGuidelines(state.mode),
buildPluginSections(ctx.pluginSections, state.mode),
]

View File

@ -24,9 +24,106 @@ import {
} from "@/app/actions/dashboards"
import { THEME_PRESETS, findPreset } from "@/lib/theme/presets"
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 { 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({
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[]
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