diff --git a/src/components/agent/main-content.tsx b/src/components/agent/main-content.tsx index 33b468b..a24dd5f 100755 --- a/src/components/agent/main-content.tsx +++ b/src/components/agent/main-content.tsx @@ -15,6 +15,8 @@ export function MainContent({ const isCollapsed = pathname === "/dashboard" && !hasRenderedUI const isConversations = pathname?.startsWith("/dashboard/conversations") + const isSchedule = pathname?.includes("/schedule") + const needsFixedHeight = isConversations || isSchedule return (
{children}
diff --git a/src/components/schedule/schedule-calendar-view.tsx b/src/components/schedule/schedule-calendar-view.tsx index 93ce13a..a1990ad 100755 --- a/src/components/schedule/schedule-calendar-view.tsx +++ b/src/components/schedule/schedule-calendar-view.tsx @@ -13,18 +13,12 @@ import { isToday, isSameMonth, isWeekend, - isSameDay, parseISO, isWithinInterval, + differenceInCalendarDays, } from "date-fns" +import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" import { IconChevronLeft, IconChevronRight, @@ -35,14 +29,19 @@ import type { } from "@/lib/schedule/types" interface ScheduleCalendarViewProps { - projectId: string - tasks: ScheduleTaskData[] - exceptions: WorkdayExceptionData[] + readonly projectId: string + readonly tasks: readonly ScheduleTaskData[] + readonly exceptions: readonly WorkdayExceptionData[] } +// How many task lanes to show before "+N more" +const MAX_LANES = 3 +const LANE_HEIGHT = 22 +const DAY_HEADER_HEIGHT = 24 + function isExceptionDay( date: Date, - exceptions: WorkdayExceptionData[] + exceptions: readonly WorkdayExceptionData[] ): boolean { return exceptions.some((ex) => { const start = parseISO(ex.startDate) @@ -52,72 +51,171 @@ function isExceptionDay( } function getTaskColor(task: ScheduleTaskData): string { - if (task.status === "COMPLETE") return "bg-green-500" - if (task.status === "IN_PROGRESS") return "bg-blue-500" - if (task.status === "BLOCKED") return "bg-red-500" - if (task.isCriticalPath) return "bg-orange-500" - return "bg-gray-400" + if (task.status === "COMPLETE") return "bg-green-600/90 dark:bg-green-600/80" + if (task.status === "IN_PROGRESS") return "bg-blue-600/90 dark:bg-blue-500/80" + if (task.status === "BLOCKED") return "bg-red-600/90 dark:bg-red-500/80" + if (task.isCriticalPath) return "bg-orange-600/90 dark:bg-orange-500/80" + return "bg-muted-foreground/70" } -const MAX_VISIBLE_TASKS = 3 +interface WeekTask { + readonly task: ScheduleTaskData + readonly startCol: number + readonly span: number + readonly lane: number + readonly isStart: boolean + readonly isEnd: boolean +} + +interface WeekRow { + readonly days: readonly Date[] + readonly tasks: WeekTask[] + readonly maxLane: number + readonly overflowByDay: readonly number[] +} + +function buildWeekRows( + calendarDays: readonly Date[], + tasks: readonly ScheduleTaskData[] +): WeekRow[] { + const weeks: { + days: Date[] + tasks: { + task: ScheduleTaskData + startCol: number + span: number + lane: number + isStart: boolean + isEnd: boolean + }[] + }[] = [] + + for (let i = 0; i < calendarDays.length; i += 7) { + weeks.push({ + days: calendarDays.slice(i, i + 7) as Date[], + tasks: [], + }) + } + + // Place each task into the weeks it overlaps + for (const task of tasks) { + const taskStart = parseISO(task.startDate) + const taskEnd = parseISO(task.endDateCalculated) + + for (const week of weeks) { + const weekStart = week.days[0] + const weekEnd = week.days[6] + + // Check overlap: task must start on or before weekEnd, + // and end on or after weekStart + if (taskStart > weekEnd || taskEnd < weekStart) continue + + const startCol = Math.max( + 0, + differenceInCalendarDays(taskStart, weekStart) + ) + const endCol = Math.min( + 6, + differenceInCalendarDays(taskEnd, weekStart) + ) + const span = endCol - startCol + 1 + + week.tasks.push({ + task, + startCol, + span, + lane: 0, + isStart: taskStart >= weekStart, + isEnd: taskEnd <= weekEnd, + }) + } + } + + // Assign lanes using first-fit + return weeks.map((week) => { + // Sort: earlier start first, then longer tasks first (they anchor better) + week.tasks.sort( + (a, b) => a.startCol - b.startCol || b.span - a.span + ) + + const lanes: boolean[][] = [] + for (const wt of week.tasks) { + let lane = 0 + while (true) { + if (!lanes[lane]) lanes[lane] = Array(7).fill(false) as boolean[] + const cols = lanes[lane].slice(wt.startCol, wt.startCol + wt.span) + if (cols.every((occupied) => !occupied)) break + lane++ + } + wt.lane = lane + if (!lanes[lane]) lanes[lane] = Array(7).fill(false) as boolean[] + for (let c = wt.startCol; c < wt.startCol + wt.span; c++) { + lanes[lane][c] = true + } + } + + // Count overflow per day (tasks in lanes >= MAX_LANES) + const overflowByDay = Array(7).fill(0) as number[] + for (const wt of week.tasks) { + if (wt.lane >= MAX_LANES) { + for (let c = wt.startCol; c < wt.startCol + wt.span; c++) { + overflowByDay[c]++ + } + } + } + + const maxLane = week.tasks.reduce( + (max, wt) => Math.max(max, wt.lane), + -1 + ) + + return { + days: week.days, + tasks: week.tasks, + maxLane: Math.min(maxLane, MAX_LANES - 1), + overflowByDay, + } + }) +} export function ScheduleCalendarView({ tasks, exceptions, }: ScheduleCalendarViewProps) { const [currentDate, setCurrentDate] = useState(new Date()) - const [expandedCells, setExpandedCells] = useState>( - new Set() - ) const monthStart = startOfMonth(currentDate) const monthEnd = endOfMonth(currentDate) const calendarStart = startOfWeek(monthStart) const calendarEnd = endOfWeek(monthEnd) - const days = useMemo( + const calendarDays = useMemo( () => eachDayOfInterval({ start: calendarStart, end: calendarEnd }), [calendarStart.getTime(), calendarEnd.getTime()] ) - const tasksByDate = useMemo(() => { - const map = new Map() - for (const task of tasks) { - const key = task.startDate - if (!map.has(key)) map.set(key, []) - map.get(key)!.push(task) - } - return map - }, [tasks]) - - const toggleExpand = (dateKey: string) => { - setExpandedCells((prev) => { - const next = new Set(prev) - if (next.has(dateKey)) { - next.delete(dateKey) - } else { - next.add(dateKey) - } - return next - }) - } + const weekRows = useMemo( + () => buildWeekRows(calendarDays, tasks), + [calendarDays, tasks] + ) return (
-
-
- + {/* Calendar controls */} +
+ +
-

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

- +

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

+ {/* Calendar grid */}
-
- {["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map( - (day) => ( -
- {day} -
- ) - )} + {/* Weekday headers */} +
+ {["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((day) => ( +
+ {day} +
+ ))}
-
- {days.map((day) => { - const dateKey = format(day, "yyyy-MM-dd") - const dayTasks = tasksByDate.get(dateKey) || [] - const isNonWork = - isWeekend(day) || isExceptionDay(day, exceptions) - const inMonth = isSameMonth(day, currentDate) - const expanded = expandedCells.has(dateKey) - const visibleTasks = expanded - ? dayTasks - : dayTasks.slice(0, MAX_VISIBLE_TASKS) - const overflow = dayTasks.length - MAX_VISIBLE_TASKS + {/* Week rows */} +
+ {weekRows.map((week, weekIdx) => { + const visibleLanes = Math.min(week.maxLane + 1, MAX_LANES) + const contentHeight = DAY_HEADER_HEIGHT + visibleLanes * LANE_HEIGHT + const hasOverflow = week.overflowByDay.some((n) => n > 0) + const totalHeight = contentHeight + (hasOverflow ? 16 : 0) return (
-
- - {format(day, "d")} - - {isNonWork && ( - - Non-workday - Off - - )} + {/* Day cells (background + day numbers) */} +
+ {week.days.map((day) => { + const inMonth = isSameMonth(day, currentDate) + const isNonWork = + isWeekend(day) || isExceptionDay(day, exceptions) + + return ( +
+ + {format(day, "d")} + +
+ ) + })}
-
- {visibleTasks.map((task) => ( + + {/* Task bars (overlaid) */} + {week.tasks + .filter((wt) => wt.lane < MAX_LANES) + .map((wt) => (
- {task.title.length > 15 ? `${task.title.slice(0, 12)}...` : task.title} + {wt.isStart ? wt.task.title : ""}
))} - {!expanded && overflow > 0 && ( - - )} - {expanded && dayTasks.length > MAX_VISIBLE_TASKS && ( - - )} -
+ + {/* Overflow indicators */} + {hasOverflow && ( +
+ {week.overflowByDay.map((count, dayIdx) => ( +
+ {count > 0 ? `+${count} more` : ""} +
+ ))} +
+ )}
) })} diff --git a/src/components/schedule/schedule-gantt-view.tsx b/src/components/schedule/schedule-gantt-view.tsx index 590ae3c..1fcd6b5 100755 --- a/src/components/schedule/schedule-gantt-view.tsx +++ b/src/components/schedule/schedule-gantt-view.tsx @@ -26,7 +26,7 @@ import { IconPlus, IconChevronRight, IconChevronDown, - IconUsers, + IconSettings, IconZoomIn, IconZoomOut, } from "@tabler/icons-react" @@ -37,6 +37,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { cn } from "@/lib/utils" import { useIsMobile } from "@/hooks/use-mobile" import { GanttChart } from "./gantt-chart" import { TaskFormDialog } from "./task-form-dialog" @@ -58,9 +59,9 @@ import { format } from "date-fns" type ViewMode = "Day" | "Week" | "Month" interface ScheduleGanttViewProps { - projectId: string - tasks: ScheduleTaskData[] - dependencies: TaskDependencyData[] + readonly projectId: string + readonly tasks: readonly ScheduleTaskData[] + readonly dependencies: readonly TaskDependencyData[] } export function ScheduleGanttView({ @@ -71,7 +72,7 @@ export function ScheduleGanttView({ const router = useRouter() const isMobile = useIsMobile() const [viewMode, setViewMode] = useState("Week") - const [phaseGrouping, setPhaseGrouping] = useState<"off" | "grouped">("off") + const [phaseGrouping, setPhaseGrouping] = useState(false) const [collapsedPhases, setCollapsedPhases] = useState>( new Set() ) @@ -81,7 +82,6 @@ export function ScheduleGanttView({ null ) const [mobileView, setMobileView] = useState<"tasks" | "chart">("chart") - const [panMode] = useState(false) const defaultWidths: Record = { @@ -105,13 +105,19 @@ export function ScheduleGanttView({ ? tasks.filter((t) => t.isCriticalPath) : tasks - const isGrouped = phaseGrouping === "grouped" - const { frappeTasks, displayItems } = isGrouped - ? transformWithPhaseGroups(filteredTasks, dependencies, collapsedPhases) + const { frappeTasks, displayItems } = phaseGrouping + ? transformWithPhaseGroups( + filteredTasks as ScheduleTaskData[], + dependencies as TaskDependencyData[], + collapsedPhases, + ) : { - frappeTasks: transformToFrappeTasks(filteredTasks, dependencies), + frappeTasks: transformToFrappeTasks( + filteredTasks as ScheduleTaskData[], + dependencies as TaskDependencyData[], + ), displayItems: filteredTasks.map( - (task): DisplayItem => ({ type: "task", task }) + (task): DisplayItem => ({ type: "task", task: task as ScheduleTaskData }) ), } @@ -125,12 +131,14 @@ export function ScheduleGanttView({ } const toggleClientView = () => { - if (phaseGrouping === "grouped") { - setPhaseGrouping("off") + if (phaseGrouping && collapsedPhases.size > 0) { + setPhaseGrouping(false) setCollapsedPhases(new Set()) } else { - setPhaseGrouping("grouped") - const allPhases = new Set(filteredTasks.map((t) => t.phase || "uncategorized")) + setPhaseGrouping(true) + const allPhases = new Set( + filteredTasks.map((t) => t.phase || "uncategorized") + ) setCollapsedPhases(allPhases) } } @@ -157,22 +165,129 @@ export function ScheduleGanttView({ ) const scrollToToday = () => { - const todayEl = document.querySelector(".gantt-container .today-highlight") + const todayEl = document.querySelector( + ".gantt-container .today-highlight" + ) if (todayEl) { todayEl.scrollIntoView({ behavior: "smooth", inline: "center" }) } } + const taskTable = ( + + + + Title + Start + Days + + + + + {displayItems.map((item) => { + if (item.type === "phase-header") { + const { phase, group, collapsed } = item + return ( + togglePhase(phase)} + > + + + {collapsed + ? + : } + {group.label} + + ({group.tasks.length}) + + {collapsed && ( + + {group.startDate.slice(5)} – {group.endDate.slice(5)} + + )} + + + {!collapsed && ( + <> + + {group.startDate.slice(5)} + + + + + )} + + ) + } + + const { task } = item + return ( + + + + {task.title} + + + + {task.startDate.slice(5)} + + + {task.workdays} + + + + + + ) + })} + + + + + + +
+ ) + return (
-
-
+ {/* Compact controls row */} +
+
{isMobile && ( )} - + + {/* Day / Week / Month */} +
+ {(["Day", "Week", "Month"] as const).map((mode) => ( + + ))} +
+
-
-
- - -
+ +
+ + + - - -
-
- Group by Phases + +
+
+ Group by Phase { - setPhaseGrouping(checked ? "grouped" : "off") - if (!checked) setCollapsedPhases(new Set()) - }} + checked={phaseGrouping} + onCheckedChange={setPhaseGrouping} className="scale-75" />
-
- Show Critical Path +
+ Critical Path
@@ -268,113 +387,12 @@ export function ScheduleGanttView({
+ {/* Main content */} {isMobile ? (
{mobileView === "tasks" ? (
- - - - Title - - Start - - - Days - - - - - - {displayItems.map((item) => { - if (item.type === "phase-header") { - const { phase, group, collapsed } = item - return ( - togglePhase(phase)} - > - - - {collapsed - ? - : } - {group.label} - - ({group.tasks.length}) - - {collapsed && ( - - {group.startDate.slice(5)} – {group.endDate.slice(5)} - - )} - - - {!collapsed && ( - <> - - {group.startDate.slice(5)} - - - - - )} - - ) - } - - const { task } = item - return ( - - - - {task.title} - - - - {task.startDate.slice(5)} - - - {task.workdays} - - - - - - ) - })} - - - - - - -
+ {taskTable}
) : (
@@ -396,109 +414,7 @@ export function ScheduleGanttView({ >
- - - - Title - - Start - - - Days - - - - - - {displayItems.map((item) => { - if (item.type === "phase-header") { - const { phase, group, collapsed } = item - return ( - togglePhase(phase)} - > - - - {collapsed - ? - : } - {group.label} - - ({group.tasks.length}) - - {collapsed && ( - - {group.startDate.slice(5)} – {group.endDate.slice(5)} - - )} - - - {!collapsed && ( - <> - - {group.startDate.slice(5)} - - - - - )} - - ) - } - - const { task } = item - return ( - - - - {task.title} - - - - {task.startDate.slice(5)} - - - {task.workdays} - - - - - - ) - })} - - - - - - -
+ {taskTable}
@@ -524,6 +440,8 @@ export function ScheduleGanttView({ onOpenChange={setTaskFormOpen} projectId={projectId} editingTask={editingTask} + allTasks={tasks} + dependencies={dependencies} />
) diff --git a/src/components/schedule/schedule-list-view.tsx b/src/components/schedule/schedule-list-view.tsx index 168b7be..e8b8eeb 100755 --- a/src/components/schedule/schedule-list-view.tsx +++ b/src/components/schedule/schedule-list-view.tsx @@ -418,6 +418,8 @@ export function ScheduleListView({ onOpenChange={setTaskFormOpen} projectId={projectId} editingTask={editingTask} + allTasks={localTasks} + dependencies={dependencies} /> ("schedule") - const [subTab, setSubTab] = useState("calendar") + const [view, setView] = useState("calendar") const [taskFormOpen, setTaskFormOpen] = useState(false) - const [filters, setFilters] = useState(DEFAULT_FILTERS) + const [filters, setFilters] = useState(EMPTY_FILTERS) + const [baselinesOpen, setBaselinesOpen] = useState(false) + const [exceptionsOpen, setExceptionsOpen] = useState(false) + const [importDialogOpen, setImportDialogOpen] = useState(false) + const [isImporting, setIsImporting] = useState(false) + const fileInputRef = useRef(null) const filteredTasks = useMemo(() => { let tasks = initialData.tasks @@ -54,147 +110,512 @@ export function ScheduleView({ if (filters.status.length > 0) { tasks = tasks.filter((t) => filters.status.includes(t.status)) } - if (filters.phase.length > 0) { - tasks = tasks.filter((t) => filters.phase.includes(t.phase as never)) - } - - if (filters.assignedTo) { - const search = filters.assignedTo.toLowerCase() - tasks = tasks.filter( - (t) => t.assignedTo?.toLowerCase().includes(search) + tasks = tasks.filter((t) => + filters.phase.includes(t.phase as ConstructionPhase) + ) + } + if (filters.assignedTo) { + const search = filters.assignedTo.toLowerCase() + tasks = tasks.filter((t) => + t.assignedTo?.toLowerCase().includes(search) ) } - if (filters.search) { const search = filters.search.toLowerCase() - tasks = tasks.filter((t) => t.title.toLowerCase().includes(search)) + tasks = tasks.filter((t) => + t.title.toLowerCase().includes(search) + ) } - return tasks }, [initialData.tasks, filters]) + const activeFilterCount = + filters.status.length + + filters.phase.length + + (filters.assignedTo ? 1 : 0) + + const toggleStatus = (status: TaskStatus) => { + const current = filters.status + const next = current.includes(status) + ? current.filter((s) => s !== status) + : [...current, status] + setFilters({ ...filters, status: next }) + } + + const togglePhase = (phase: ConstructionPhase) => { + const current = filters.phase + const next = current.includes(phase) + ? current.filter((p) => p !== phase) + : [...current, phase] + setFilters({ ...filters, phase: next }) + } + + const removeStatusChip = (status: TaskStatus) => { + setFilters({ + ...filters, + status: filters.status.filter((s) => s !== status), + }) + } + + const removePhaseChip = (phase: ConstructionPhase) => { + setFilters({ + ...filters, + phase: filters.phase.filter((p) => p !== phase), + }) + } + + const clearFilters = () => setFilters(EMPTY_FILTERS) + + // CSV export + const handleExportCSV = () => { + const headers = [ + "Title", "Phase", "Status", "Start Date", "End Date", + "Duration (days)", "% Complete", "Assigned To", + "Critical Path", "Milestone", + ] + const rows = filteredTasks.map((task) => [ + task.title, task.phase, task.status, task.startDate, + task.endDateCalculated, task.workdays.toString(), + task.percentComplete.toString(), task.assignedTo ?? "", + task.isCriticalPath ? "Yes" : "No", + task.isMilestone ? "Yes" : "No", + ]) + + const escapeCSV = (value: string) => { + if (value.includes(",") || value.includes('"') || value.includes("\n")) { + return `"${value.replace(/"/g, '""')}"` + } + return value + } + + const csvContent = [ + headers.join(","), + ...rows.map((row) => row.map(escapeCSV).join(",")), + ].join("\n") + + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }) + const link = document.createElement("a") + link.download = `${projectName.replace(/\s+/g, "-")}-schedule-${new Date().toISOString().split("T")[0]}.csv` + link.href = URL.createObjectURL(blob) + link.click() + URL.revokeObjectURL(link.href) + } + + // CSV import + const handleFileSelect = async ( + e: React.ChangeEvent + ) => { + const file = e.target.files?.[0] + if (!file) return + + setIsImporting(true) + try { + const text = await file.text() + const lines = text.split("\n") + const headers = lines[0].split(",").map((h) => h.trim().toLowerCase()) + + const titleIdx = headers.findIndex( + (h) => h.includes("title") || h.includes("task") + ) + const startIdx = headers.findIndex((h) => h.includes("start")) + const durationIdx = headers.findIndex( + (h) => h.includes("duration") || h.includes("days") + ) + const phaseIdx = headers.findIndex((h) => h.includes("phase")) + const assignedIdx = headers.findIndex( + (h) => h.includes("assigned") || h.includes("owner") + ) + + const parsed: Record[] = [] + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim() + if (!line) continue + + const values = line.split(",").map((v) => v.trim()) + const task: Record = {} + + if (titleIdx >= 0 && values[titleIdx]) task.title = values[titleIdx] + if (startIdx >= 0 && values[startIdx]) task.startDate = values[startIdx] + if (durationIdx >= 0 && values[durationIdx]) { + task.workdays = parseInt(values[durationIdx]) || 1 + } + if (phaseIdx >= 0 && values[phaseIdx]) task.phase = values[phaseIdx] + if (assignedIdx >= 0 && values[assignedIdx]) { + task.assignedTo = values[assignedIdx] + } + + if (task.title) { + task.status = "PENDING" + task.percentComplete = 0 + parsed.push(task) + } + } + + if (parsed.length > 0) { + const blob = new Blob( + [JSON.stringify(parsed, null, 2)], + { type: "application/json" } + ) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `imported-tasks-${Date.now()}.json` + link.click() + URL.revokeObjectURL(url) + alert(`Parsed ${parsed.length} tasks from CSV. Downloaded as JSON for review.`) + } else { + alert("No valid tasks found in the CSV file.") + } + } catch (error) { + console.error("Import failed:", error) + alert("Failed to parse CSV file. Please check the format.") + } finally { + setIsImporting(false) + setImportDialogOpen(false) + if (fileInputRef.current) { + fileInputRef.current.value = "" + } + } + } + return (
-
-
- - - Schedule + {/* Header: breadcrumb + view toggle + new task */} +
+ + +
+ {/* View switcher */} +
+ {VIEW_OPTIONS.map(({ value, icon: Icon, label }) => ( + + ))} +
+ +
- setTopTab(v as TopTab)} - > - - Schedule - Baseline - - Workday Exceptions - - -
- setTopTab(v as TopTab)} - className="flex flex-col flex-1 min-h-0" - > - - setTaskFormOpen(true)} - filters={filters} - onFiltersChange={setFilters} - projectName={projectName} - tasksCount={filteredTasks.length} - tasks={filteredTasks} + {/* Action bar: search, filters, overflow */} +
+ {/* Search */} +
+ + setFilters({ ...filters, search: e.target.value })} + className="h-8 pl-8 text-sm" /> +
- setSubTab(v as ScheduleSubTab)} - className="flex flex-col flex-1 min-h-0" - > - - - Calendar - - - List - - - Gantt - - - - - {isMobile ? ( - - ) : ( - + {/* Filter popover */} + + + + + +
+
+ +
+ {STATUS_OPTIONS.map((opt) => ( + + ))} +
+
+
+ +
+ {PHASE_OPTIONS.map((opt) => ( + + ))} +
+
+
+ + + setFilters({ ...filters, assignedTo: e.target.value }) + } + className="mt-1.5 h-8 text-sm" + /> +
+ {activeFilterCount > 0 && ( + + )} +
+
+
- - - + {/* Active filter chips */} +
+ {filters.status.map((s) => ( + removeStatusChip(s)} + > + {STATUS_OPTIONS.find((o) => o.value === s)?.label ?? s} + + + ))} + {filters.phase.map((p) => ( + removePhaseChip(p)} + > + {p} + + + ))} + {filters.assignedTo && ( + setFilters({ ...filters, assignedTo: "" })} + > + {filters.assignedTo} + + + )} +
- - - -
- +
+ + {filteredTasks.length} task{filteredTasks.length !== 1 ? "s" : ""} + - - + + + + + + + Export CSV + + setImportDialogOpen(true)}> + + Import CSV + + window.print()}> + + Print + + + setBaselinesOpen(true)}> + + Baselines + + setExceptionsOpen(true)}> + + Workday Exceptions + + + +
+
+ + {/* View content */} +
+ {view === "calendar" && ( + isMobile ? ( + + ) : ( + + ) + )} + {view === "list" && ( + - - - - - - + )} +
+ {/* New task dialog */} + + {/* Import dialog */} + + + + Import Schedule + + Upload a CSV file with columns for title, start date, duration, + phase, and assigned to. + + +
+ + +

+ Supported format: CSV with headers +

+
+
+
+ + {/* Baselines sheet */} + + + + Baselines + + Save and compare schedule snapshots. + + +
+ +
+
+
+ + {/* Exceptions sheet */} + + + + Workday Exceptions + + Holidays, vacation days, and other non-working days. + + +
+ +
+
+
) } diff --git a/src/components/schedule/task-form-dialog.tsx b/src/components/schedule/task-form-dialog.tsx index 4b74383..4cf9d79 100755 --- a/src/components/schedule/task-form-dialog.tsx +++ b/src/components/schedule/task-form-dialog.tsx @@ -1,15 +1,16 @@ "use client" import * as React from "react" -import { useEffect, useMemo } from "react" +import { useEffect, useMemo, useState } from "react" import { useForm } from "react-hook-form" import { z } from "zod" import { zodResolver } from "@hookform/resolvers/zod" import { - ResponsiveDialog, - ResponsiveDialogBody, - ResponsiveDialogFooter, -} from "@/components/ui/responsive-dialog" + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" import { Form, FormControl, @@ -19,13 +20,25 @@ import { FormMessage, } from "@/components/ui/form" import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" import { Calendar } from "@/components/ui/calendar" import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover" -import { IconCalendar } from "@tabler/icons-react" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" +import { + IconCalendar, + IconPlus, + IconTrash, + IconChevronDown, + IconChevronRight, +} from "@tabler/icons-react" import { format, parseISO } from "date-fns" import { Slider } from "@/components/ui/slider" import { @@ -37,26 +50,46 @@ import { } from "@/components/ui/select" import { Switch } from "@/components/ui/switch" import { Button } from "@/components/ui/button" -import { createTask, updateTask } from "@/app/actions/schedule" +import { + createTask, + updateTask, + createDependency, + deleteDependency, +} from "@/app/actions/schedule" import { calculateEndDate } from "@/lib/schedule/business-days" -import type { ScheduleTaskData } from "@/lib/schedule/types" -import { PHASE_ORDER, PHASE_LABELS } from "@/lib/schedule/phase-colors" +import type { + ScheduleTaskData, + TaskDependencyData, + DependencyType, +} from "@/lib/schedule/types" +import { PHASE_ORDER, PHASE_LABELS, getPhaseColor } from "@/lib/schedule/phase-colors" +import { STATUS_OPTIONS } from "@/lib/schedule/types" import { useRouter } from "next/navigation" import { toast } from "sonner" +import { cn } from "@/lib/utils" const phases = PHASE_ORDER.map((value) => ({ value, label: PHASE_LABELS[value], })) +const DEPENDENCY_TYPES: readonly { value: DependencyType; label: string }[] = [ + { value: "FS", label: "Finish-to-Start" }, + { value: "SS", label: "Start-to-Start" }, + { value: "FF", label: "Finish-to-Finish" }, + { value: "SF", label: "Start-to-Finish" }, +] + const taskSchema = z.object({ title: z.string().min(1, "Title is required"), startDate: z.string().min(1, "Start date is required"), workdays: z.number().min(1, "Must be at least 1 day"), phase: z.string().min(1, "Phase is required"), + status: z.string(), isMilestone: z.boolean(), percentComplete: z.number().min(0).max(100), assignedTo: z.string(), + notes: z.string(), }) type TaskFormValues = z.infer @@ -66,6 +99,14 @@ interface TaskFormDialogProps { onOpenChange: (open: boolean) => void projectId: string editingTask: ScheduleTaskData | null + allTasks?: readonly ScheduleTaskData[] + dependencies?: readonly TaskDependencyData[] +} + +interface PendingPredecessor { + taskId: string + type: DependencyType + lagDays: number } export function TaskFormDialog({ @@ -73,9 +114,24 @@ export function TaskFormDialog({ onOpenChange, projectId, editingTask, + allTasks = [], + dependencies = [], }: TaskFormDialogProps) { const router = useRouter() const isEditing = !!editingTask + const [detailsOpen, setDetailsOpen] = useState(false) + const [pendingPredecessors, setPendingPredecessors] = useState< + PendingPredecessor[] + >([]) + + const existingPredecessors = useMemo(() => { + if (!editingTask) return [] + return dependencies.filter((d) => d.successorId === editingTask.id) + }, [editingTask, dependencies]) + + const availableTasks = useMemo(() => { + return allTasks.filter((t) => t.id !== editingTask?.id) + }, [allTasks, editingTask]) const form = useForm({ resolver: zodResolver(taskSchema), @@ -84,9 +140,11 @@ export function TaskFormDialog({ startDate: new Date().toISOString().split("T")[0], workdays: 5, phase: "preconstruction", + status: "PENDING", isMilestone: false, percentComplete: 0, assignedTo: "", + notes: "", }, }) @@ -97,25 +155,35 @@ export function TaskFormDialog({ startDate: editingTask.startDate, workdays: editingTask.workdays, phase: editingTask.phase, + status: editingTask.status, isMilestone: editingTask.isMilestone, percentComplete: editingTask.percentComplete, assignedTo: editingTask.assignedTo ?? "", + notes: "", }) + // expand details when editing since they likely want to see everything + setDetailsOpen(true) } else { form.reset({ title: "", startDate: new Date().toISOString().split("T")[0], workdays: 5, phase: "preconstruction", + status: "PENDING", isMilestone: false, percentComplete: 0, assignedTo: "", + notes: "", }) + setDetailsOpen(false) } + setPendingPredecessors([]) }, [editingTask, form]) const watchedStart = form.watch("startDate") const watchedWorkdays = form.watch("workdays") + const watchedPhase = form.watch("phase") + const watchedPercent = form.watch("percentComplete") const calculatedEnd = useMemo(() => { if (!watchedStart || !watchedWorkdays || watchedWorkdays < 1) return "" @@ -137,6 +205,17 @@ export function TaskFormDialog({ } if (result.success) { + for (const pred of pendingPredecessors) { + if (pred.taskId) { + await createDependency({ + predecessorId: pred.taskId, + successorId: editingTask?.id ?? "", + type: pred.type, + lagDays: pred.lagDays, + projectId, + }) + } + } onOpenChange(false) router.refresh() } else { @@ -144,206 +223,470 @@ export function TaskFormDialog({ } } - const page1 = ( - <> - ( - - Title - - - - - - )} - /> + const addPendingPredecessor = () => { + setPendingPredecessors((prev) => [ + ...prev, + { taskId: "", type: "FS", lagDays: 0 }, + ]) + } - ( - - Start Date - - - - - - - - { - if (date) { - field.onChange(format(date, "yyyy-MM-dd")) - } - }} - initialFocus - /> - - - - - )} - /> + const removePendingPredecessor = (index: number) => { + setPendingPredecessors((prev) => prev.filter((_, i) => i !== index)) + } - ( - - Workdays - - - field.onChange(Number(e.target.value) || 0) - } - onBlur={field.onBlur} - ref={field.ref} - name={field.name} - /> - - - - )} - /> - - ) + const updatePendingPredecessor = ( + index: number, + field: keyof PendingPredecessor, + value: string | number + ) => { + setPendingPredecessors((prev) => + prev.map((p, i) => (i === index ? { ...p, [field]: value } : p)) + ) + } - const page2 = ( - <> - ( - - Phase - - - - )} - /> + const handleDeleteExistingDep = async (depId: string) => { + const result = await deleteDependency(depId, projectId) + if (result.success) { + router.refresh() + } else { + toast.error(result.error) + } + } - ( - - Assigned To - - - - - - )} - /> - - ) - - const page3 = ( - <> - {calculatedEnd && ( -

- End date: {calculatedEnd} -

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

+ No other tasks to link as predecessors. +

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