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:
parent
5922dd9d3a
commit
4cebbb73e8
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -418,6 +418,8 @@ export function ScheduleListView({
|
||||
onOpenChange={setTaskFormOpen}
|
||||
projectId={projectId}
|
||||
editingTask={editingTask}
|
||||
allTasks={localTasks}
|
||||
dependencies={dependencies}
|
||||
/>
|
||||
|
||||
<DependencyDialog
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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),
|
||||
]
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user