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.
622 lines
20 KiB
TypeScript
Executable File
622 lines
20 KiB
TypeScript
Executable File
"use client"
|
|
|
|
import { useState, useMemo, useRef } from "react"
|
|
import Link from "next/link"
|
|
import { cn } from "@/lib/utils"
|
|
import { useIsMobile } from "@/hooks/use-mobile"
|
|
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"
|
|
import { ScheduleMobileView } from "./schedule-mobile-view"
|
|
import { WorkdayExceptionsView } from "./workday-exceptions-view"
|
|
import { ScheduleBaselineView } from "./schedule-baseline-view"
|
|
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 View = "calendar" | "list" | "gantt"
|
|
|
|
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 {
|
|
readonly projectId: string
|
|
readonly projectName: string
|
|
readonly initialData: ScheduleData
|
|
readonly baselines: ScheduleBaselineData[]
|
|
readonly allProjects?: readonly { id: string; name: string }[]
|
|
}
|
|
|
|
export function ScheduleView({
|
|
projectId,
|
|
projectName,
|
|
initialData,
|
|
baselines,
|
|
}: ScheduleViewProps) {
|
|
const isMobile = useIsMobile()
|
|
const [view, setView] = useState<View>("calendar")
|
|
const [taskFormOpen, setTaskFormOpen] = useState(false)
|
|
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
|
|
|
|
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 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)
|
|
)
|
|
}
|
|
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">
|
|
{/* 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>
|
|
</div>
|
|
|
|
{/* 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>
|
|
|
|
{/* 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>
|
|
)}
|
|
</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>
|
|
|
|
{/* 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>
|
|
|
|
<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>
|
|
|
|
{/* 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}
|
|
tasks={filteredTasks}
|
|
dependencies={initialData.dependencies}
|
|
/>
|
|
)}
|
|
{view === "gantt" && (
|
|
<ScheduleGanttView
|
|
projectId={projectId}
|
|
tasks={filteredTasks}
|
|
dependencies={initialData.dependencies}
|
|
/>
|
|
)}
|
|
</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>
|
|
)
|
|
}
|