From 4cebbb73e8160262a30f1a629d2ad5f8d4013ad0 Mon Sep 17 00:00:00 2001 From: Nicholai Date: Mon, 16 Feb 2026 13:58:40 -0700 Subject: [PATCH] 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. --- src/components/agent/main-content.tsx | 6 +- .../schedule/schedule-calendar-view.tsx | 383 +++++---- .../schedule/schedule-gantt-view.tsx | 478 +++++------ .../schedule/schedule-list-view.tsx | 2 + src/components/schedule/schedule-view.tsx | 683 +++++++++++++--- src/components/schedule/task-form-dialog.tsx | 749 +++++++++++++----- src/lib/agent/system-prompt.ts | 108 +++ src/lib/agent/tools.ts | 690 +++++++++++++++- src/lib/schedule/types.ts | 37 + 9 files changed, 2383 insertions(+), 753 deletions(-) diff --git a/src/components/agent/main-content.tsx b/src/components/agent/main-content.tsx index 33b468b..a24dd5f 100755 --- a/src/components/agent/main-content.tsx +++ b/src/components/agent/main-content.tsx @@ -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 (
{children}
diff --git a/src/components/schedule/schedule-calendar-view.tsx b/src/components/schedule/schedule-calendar-view.tsx index 93ce13a..a1990ad 100755 --- a/src/components/schedule/schedule-calendar-view.tsx +++ b/src/components/schedule/schedule-calendar-view.tsx @@ -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>( - 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() - 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 (
-
-
- + {/* Calendar controls */} +
+ +
-

- {format(currentDate, "MMMM yyyy")} -

- +

+ {format(currentDate, "MMMM yyyy")} +

+ {/* Calendar grid */}
-
- {["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map( - (day) => ( -
- {day} -
- ) - )} + {/* Weekday headers */} +
+ {["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((day) => ( +
+ {day} +
+ ))}
-
- {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 */} +
+ {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 (
-
- - {format(day, "d")} - - {isNonWork && ( - - Non-workday - Off - - )} + {/* Day cells (background + day numbers) */} +
+ {week.days.map((day) => { + const inMonth = isSameMonth(day, currentDate) + const isNonWork = + isWeekend(day) || isExceptionDay(day, exceptions) + + return ( +
+ + {format(day, "d")} + +
+ ) + })}
-
- {visibleTasks.map((task) => ( + + {/* Task bars (overlaid) */} + {week.tasks + .filter((wt) => wt.lane < MAX_LANES) + .map((wt) => (
- {task.title.length > 15 ? `${task.title.slice(0, 12)}...` : task.title} + {wt.isStart ? wt.task.title : ""}
))} - {!expanded && overflow > 0 && ( - - )} - {expanded && dayTasks.length > MAX_VISIBLE_TASKS && ( - - )} -
+ + {/* Overflow indicators */} + {hasOverflow && ( +
+ {week.overflowByDay.map((count, dayIdx) => ( +
+ {count > 0 ? `+${count} more` : ""} +
+ ))} +
+ )}
) })} diff --git a/src/components/schedule/schedule-gantt-view.tsx b/src/components/schedule/schedule-gantt-view.tsx index 590ae3c..1fcd6b5 100755 --- a/src/components/schedule/schedule-gantt-view.tsx +++ b/src/components/schedule/schedule-gantt-view.tsx @@ -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("Week") - const [phaseGrouping, setPhaseGrouping] = useState<"off" | "grouped">("off") + const [phaseGrouping, setPhaseGrouping] = useState(false) const [collapsedPhases, setCollapsedPhases] = useState>( new Set() ) @@ -81,7 +82,6 @@ export function ScheduleGanttView({ null ) const [mobileView, setMobileView] = useState<"tasks" | "chart">("chart") - const [panMode] = useState(false) const defaultWidths: Record = { @@ -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 = ( + + + + Title + Start + Days + + + + + {displayItems.map((item) => { + if (item.type === "phase-header") { + const { phase, group, collapsed } = item + return ( + togglePhase(phase)} + > + + + {collapsed + ? + : } + {group.label} + + ({group.tasks.length}) + + {collapsed && ( + + {group.startDate.slice(5)} – {group.endDate.slice(5)} + + )} + + + {!collapsed && ( + <> + + {group.startDate.slice(5)} + + + + + )} + + ) + } + + const { task } = item + return ( + + + + {task.title} + + + + {task.startDate.slice(5)} + + + {task.workdays} + + + + + + ) + })} + + + + + + +
+ ) + return (
-
-
+ {/* Compact controls row */} +
+
{isMobile && ( )} - + + {/* Day / Week / Month */} +
+ {(["Day", "Week", "Month"] as const).map((mode) => ( + + ))} +
+
-
-
- - -
+ +
+ + + - - -
-
- Group by Phases + +
+
+ Group by Phase { - setPhaseGrouping(checked ? "grouped" : "off") - if (!checked) setCollapsedPhases(new Set()) - }} + checked={phaseGrouping} + onCheckedChange={setPhaseGrouping} className="scale-75" />
-
- Show Critical Path +
+ Critical Path
@@ -268,113 +387,12 @@ export function ScheduleGanttView({
+ {/* Main content */} {isMobile ? (
{mobileView === "tasks" ? (
- - - - Title - - Start - - - Days - - - - - - {displayItems.map((item) => { - if (item.type === "phase-header") { - const { phase, group, collapsed } = item - return ( - togglePhase(phase)} - > - - - {collapsed - ? - : } - {group.label} - - ({group.tasks.length}) - - {collapsed && ( - - {group.startDate.slice(5)} – {group.endDate.slice(5)} - - )} - - - {!collapsed && ( - <> - - {group.startDate.slice(5)} - - - - - )} - - ) - } - - const { task } = item - return ( - - - - {task.title} - - - - {task.startDate.slice(5)} - - - {task.workdays} - - - - - - ) - })} - - - - - - -
+ {taskTable}
) : (
@@ -396,109 +414,7 @@ export function ScheduleGanttView({ >
- - - - Title - - Start - - - Days - - - - - - {displayItems.map((item) => { - if (item.type === "phase-header") { - const { phase, group, collapsed } = item - return ( - togglePhase(phase)} - > - - - {collapsed - ? - : } - {group.label} - - ({group.tasks.length}) - - {collapsed && ( - - {group.startDate.slice(5)} – {group.endDate.slice(5)} - - )} - - - {!collapsed && ( - <> - - {group.startDate.slice(5)} - - - - - )} - - ) - } - - const { task } = item - return ( - - - - {task.title} - - - - {task.startDate.slice(5)} - - - {task.workdays} - - - - - - ) - })} - - - - - - -
+ {taskTable}
@@ -524,6 +440,8 @@ export function ScheduleGanttView({ onOpenChange={setTaskFormOpen} projectId={projectId} editingTask={editingTask} + allTasks={tasks} + dependencies={dependencies} />
) diff --git a/src/components/schedule/schedule-list-view.tsx b/src/components/schedule/schedule-list-view.tsx index 168b7be..e8b8eeb 100755 --- a/src/components/schedule/schedule-list-view.tsx +++ b/src/components/schedule/schedule-list-view.tsx @@ -418,6 +418,8 @@ export function ScheduleListView({ onOpenChange={setTaskFormOpen} projectId={projectId} editingTask={editingTask} + allTasks={localTasks} + dependencies={dependencies} /> ("schedule") - const [subTab, setSubTab] = useState("calendar") + const [view, setView] = useState("calendar") const [taskFormOpen, setTaskFormOpen] = useState(false) - const [filters, setFilters] = useState(DEFAULT_FILTERS) + const [filters, setFilters] = useState(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(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 + ) => { + 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[] = [] + 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 = {} + + 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 (
-
-
- - - Schedule + {/* Header: breadcrumb + view toggle + new task */} +
+ + +
+ {/* View switcher */} +
+ {VIEW_OPTIONS.map(({ value, icon: Icon, label }) => ( + + ))} +
+ +
- setTopTab(v as TopTab)} - > - - Schedule - Baseline - - Workday Exceptions - - -
- setTopTab(v as TopTab)} - className="flex flex-col flex-1 min-h-0" - > - - setTaskFormOpen(true)} - filters={filters} - onFiltersChange={setFilters} - projectName={projectName} - tasksCount={filteredTasks.length} - tasks={filteredTasks} + {/* Action bar: search, filters, overflow */} +
+ {/* Search */} +
+ + setFilters({ ...filters, search: e.target.value })} + className="h-8 pl-8 text-sm" /> +
- setSubTab(v as ScheduleSubTab)} - className="flex flex-col flex-1 min-h-0" - > - - - Calendar - - - List - - - Gantt - - - - - {isMobile ? ( - - ) : ( - + {/* Filter popover */} + + + + + +
+
+ +
+ {STATUS_OPTIONS.map((opt) => ( + + ))} +
+
+
+ +
+ {PHASE_OPTIONS.map((opt) => ( + + ))} +
+
+
+ + + setFilters({ ...filters, assignedTo: e.target.value }) + } + className="mt-1.5 h-8 text-sm" + /> +
+ {activeFilterCount > 0 && ( + + )} +
+
+
- - - + {/* Active filter chips */} +
+ {filters.status.map((s) => ( + removeStatusChip(s)} + > + {STATUS_OPTIONS.find((o) => o.value === s)?.label ?? s} + + + ))} + {filters.phase.map((p) => ( + removePhaseChip(p)} + > + {p} + + + ))} + {filters.assignedTo && ( + setFilters({ ...filters, assignedTo: "" })} + > + {filters.assignedTo} + + + )} +
- - - -
- +
+ + {filteredTasks.length} task{filteredTasks.length !== 1 ? "s" : ""} + - - + + + + + + + Export CSV + + setImportDialogOpen(true)}> + + Import CSV + + window.print()}> + + Print + + + setBaselinesOpen(true)}> + + Baselines + + setExceptionsOpen(true)}> + + Workday Exceptions + + + +
+
+ + {/* View content */} +
+ {view === "calendar" && ( + isMobile ? ( + + ) : ( + + ) + )} + {view === "list" && ( + - - - - - - + )} +
+ {/* New task dialog */} + + {/* Import dialog */} + + + + Import Schedule + + Upload a CSV file with columns for title, start date, duration, + phase, and assigned to. + + +
+ + +

+ Supported format: CSV with headers +

+
+
+
+ + {/* Baselines sheet */} + + + + Baselines + + Save and compare schedule snapshots. + + +
+ +
+
+
+ + {/* Exceptions sheet */} + + + + Workday Exceptions + + Holidays, vacation days, and other non-working days. + + +
+ +
+
+
) } diff --git a/src/components/schedule/task-form-dialog.tsx b/src/components/schedule/task-form-dialog.tsx index 4b74383..4cf9d79 100755 --- a/src/components/schedule/task-form-dialog.tsx +++ b/src/components/schedule/task-form-dialog.tsx @@ -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 @@ -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({ 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 = ( - <> - ( - - Title - - - - - - )} - /> + const addPendingPredecessor = () => { + setPendingPredecessors((prev) => [ + ...prev, + { taskId: "", type: "FS", lagDays: 0 }, + ]) + } - ( - - Start Date - - - - - - - - { - if (date) { - field.onChange(format(date, "yyyy-MM-dd")) - } - }} - initialFocus - /> - - - - - )} - /> + const removePendingPredecessor = (index: number) => { + setPendingPredecessors((prev) => prev.filter((_, i) => i !== index)) + } - ( - - Workdays - - - field.onChange(Number(e.target.value) || 0) - } - onBlur={field.onBlur} - ref={field.ref} - name={field.name} - /> - - - - )} - /> - - ) + const updatePendingPredecessor = ( + index: number, + field: keyof PendingPredecessor, + value: string | number + ) => { + setPendingPredecessors((prev) => + prev.map((p, i) => (i === index ? { ...p, [field]: value } : p)) + ) + } - const page2 = ( - <> - ( - - Phase - - - - )} - /> + const handleDeleteExistingDep = async (depId: string) => { + const result = await deleteDependency(depId, projectId) + if (result.success) { + router.refresh() + } else { + toast.error(result.error) + } + } - ( - - Assigned To - - - - - - )} - /> - - ) - - const page3 = ( - <> - {calculatedEnd && ( -

- End date: {calculatedEnd} -

- )} - - ( - - - Complete: {field.value}% - - - field.onChange(val)} - /> - - - - )} - /> - - ( - - - - - Milestone - - )} - /> - - ) + const hasPredecessors = + existingPredecessors.length > 0 || pendingPredecessors.length > 0 return ( - -
- - + + + + + {isEditing ? "Edit Task" : "New Task"} + + - - - - - - -
+
+ +
+ {/* === ESSENTIAL FIELDS === */} + + {/* Title */} + ( + + + + + + + )} + /> + + {/* Phase pills */} +
+ {phases.map((p) => { + const colors = getPhaseColor(p.value) + const isSelected = watchedPhase === p.value + return ( + + ) + })} +
+ + {/* Date row: Start | Duration | End */} +
+ ( + + + Start + + + + + + + + + { + if (date) { + field.onChange(format(date, "yyyy-MM-dd")) + } + }} + initialFocus + /> + + + + + )} + /> + + ( + + + Duration + +
+ + + field.onChange(Number(e.target.value) || 0) + } + onBlur={field.onBlur} + ref={field.ref} + name={field.name} + /> + + + d + +
+ +
+ )} + /> + + + + End + +
+ {calculatedEnd + ? format(parseISO(calculatedEnd), "MMM d, yyyy") + : "\u2014"} +
+
+
+ + {/* === DETAILS (collapsible) === */} + + + + + + + {/* Status + Assignee + Milestone row */} +
+ ( + + + Status + + + + )} + /> + + ( + + + Assignee + + + + + + )} + /> + + ( + + + + + + Milestone + + + )} + /> +
+ + {/* Progress */} + ( + + + Progress + +
+ + field.onChange(val)} + className="flex-1" + /> + + + {watchedPercent}% + +
+
+ )} + /> + + {/* Predecessors */} +
+ + Predecessors + + + {existingPredecessors.map((dep) => { + const predTask = allTasks.find( + (t) => t.id === dep.predecessorId + ) + return ( +
+
+ {predTask?.title ?? "Unknown"} +
+ + {dep.type} + + {dep.lagDays > 0 && ( + + +{dep.lagDays}d + + )} + +
+ ) + })} + + {pendingPredecessors.map((pred, idx) => ( +
+ + + + updatePendingPredecessor( + idx, + "lagDays", + Number(e.target.value) || 0 + ) + } + /> + +
+ ))} + + {availableTasks.length > 0 && ( + + )} + + {availableTasks.length === 0 && + existingPredecessors.length === 0 && ( +

+ No other tasks to link as predecessors. +

+ )} +
+ + {/* Notes */} + ( + + + Notes + + +