diff --git a/bun.lock b/bun.lock
old mode 100755
new mode 100644
diff --git a/src/app/dashboard/projects/[id]/schedule/page.tsx b/src/app/dashboard/projects/[id]/schedule/page.tsx
index dc30809..9b1372c 100755
--- a/src/app/dashboard/projects/[id]/schedule/page.tsx
+++ b/src/app/dashboard/projects/[id]/schedule/page.tsx
@@ -3,7 +3,7 @@ export const dynamic = "force-dynamic"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db"
import { projects } from "@/db/schema"
-import { eq } from "drizzle-orm"
+import { eq, asc } from "drizzle-orm"
import { notFound } from "next/navigation"
import { getSchedule } from "@/app/actions/schedule"
import { getBaselines } from "@/app/actions/baselines"
@@ -26,6 +26,7 @@ export default async function SchedulePage({
let projectName = "Project"
let schedule: ScheduleData = emptySchedule
let baselines: ScheduleBaselineData[] = []
+ let allProjects: { id: string; name: string }[] = []
try {
const { env } = await getCloudflareContext()
@@ -41,9 +42,13 @@ export default async function SchedulePage({
if (!project) notFound()
projectName = project.name
- ;[schedule, baselines] = await Promise.all([
+ ;[schedule, baselines, allProjects] = await Promise.all([
getSchedule(id),
getBaselines(id),
+ db
+ .select({ id: projects.id, name: projects.name })
+ .from(projects)
+ .orderBy(asc(projects.name)),
])
} catch (e: unknown) {
if (e && typeof e === "object" && "digest" in e && e.digest === "NEXT_NOT_FOUND") throw e
@@ -57,6 +62,7 @@ export default async function SchedulePage({
projectName={projectName}
initialData={schedule}
baselines={baselines}
+ allProjects={allProjects}
/>
)
diff --git a/src/components/schedule/project-switcher.tsx b/src/components/schedule/project-switcher.tsx
new file mode 100644
index 0000000..3b4b396
--- /dev/null
+++ b/src/components/schedule/project-switcher.tsx
@@ -0,0 +1,56 @@
+"use client"
+
+import { useRouter } from "next/navigation"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+} from "@/components/ui/select"
+import { IconChevronDown, IconBuilding } from "@tabler/icons-react"
+
+interface ProjectSwitcherProps {
+ projects: { id: string; name: string }[]
+ currentProjectId: string
+ currentProjectName: string
+}
+
+export function ProjectSwitcher({
+ projects,
+ currentProjectId,
+ currentProjectName,
+}: ProjectSwitcherProps) {
+ const router = useRouter()
+
+ const handleProjectChange = (projectId: string) => {
+ if (projectId !== currentProjectId) {
+ router.push(`/dashboard/projects/${projectId}/schedule`)
+ }
+ }
+
+ if (projects.length === 0) {
+ return (
+
+
+ {currentProjectName}
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/schedule/schedule-toolbar.tsx b/src/components/schedule/schedule-toolbar.tsx
index 153f463..7e3cced 100755
--- a/src/components/schedule/schedule-toolbar.tsx
+++ b/src/components/schedule/schedule-toolbar.tsx
@@ -1,8 +1,9 @@
"use client"
-import { useState } from "react"
+import { useState, useRef } from "react"
import { Button } from "@/components/ui/button"
-import { Switch } from "@/components/ui/switch"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
import {
DropdownMenu,
DropdownMenuContent,
@@ -10,79 +11,392 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuLabel,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+ DropdownMenuCheckboxItem,
} from "@/components/ui/dropdown-menu"
import {
- IconSettings,
- IconHistory,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
IconFilter,
IconPlus,
IconDots,
IconDownload,
IconUpload,
IconPrinter,
+ IconX,
+ IconLoader2,
} from "@tabler/icons-react"
+import type { TaskStatus, ConstructionPhase, ScheduleTaskData } from "@/lib/schedule/types"
+
+const STATUSES: { value: TaskStatus; label: string }[] = [
+ { value: "PENDING", label: "Pending" },
+ { value: "IN_PROGRESS", label: "In Progress" },
+ { value: "COMPLETE", label: "Complete" },
+ { value: "BLOCKED", label: "Blocked" },
+]
+
+const PHASES: { value: ConstructionPhase; 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" },
+]
+
+export interface TaskFilters {
+ status: TaskStatus[]
+ phase: ConstructionPhase[]
+ assignedTo: string
+ search: string
+}
interface ScheduleToolbarProps {
onNewItem: () => void
+ filters: TaskFilters
+ onFiltersChange: (filters: TaskFilters) => void
+ projectName: string
+ tasksCount: number
+ tasks: ScheduleTaskData[]
}
-export function ScheduleToolbar({ onNewItem }: ScheduleToolbarProps) {
- const [offlineMode, setOfflineMode] = useState(false)
+export function ScheduleToolbar({
+ onNewItem,
+ filters,
+ onFiltersChange,
+ projectName,
+ tasksCount,
+ tasks,
+}: ScheduleToolbarProps) {
+ const [filterDialogOpen, setFilterDialogOpen] = useState(false)
+ const [importDialogOpen, setImportDialogOpen] = useState(false)
+ const [isImporting, setIsImporting] = useState(false)
+ const fileInputRef = useRef(null)
+
+ const activeFiltersCount =
+ filters.status.length +
+ filters.phase.length +
+ (filters.assignedTo ? 1 : 0) +
+ (filters.search ? 1 : 0)
+
+ const toggleStatus = (status: TaskStatus) => {
+ const newStatus = filters.status.includes(status)
+ ? filters.status.filter((s) => s !== status)
+ : [...filters.status, status]
+ onFiltersChange({ ...filters, status: newStatus })
+ }
+
+ const togglePhase = (phase: ConstructionPhase) => {
+ const newPhase = filters.phase.includes(phase)
+ ? filters.phase.filter((p) => p !== phase)
+ : [...filters.phase, phase]
+ onFiltersChange({ ...filters, phase: newPhase })
+ }
+
+ const clearFilters = () => {
+ onFiltersChange({
+ status: [],
+ phase: [],
+ assignedTo: "",
+ search: "",
+ })
+ }
+
+ const handlePrint = () => {
+ window.print()
+ }
+
+ const handleExportCSV = () => {
+ const headers = ["Title", "Phase", "Status", "Start Date", "End Date", "Duration (days)", "% Complete", "Assigned To", "Critical Path", "Milestone"]
+ const rows = tasks.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)
+ }
+
+ const handleImportClick = () => {
+ setImportDialogOpen(true)
+ }
+
+ 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 tasks: 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
+ tasks.push(task)
+ }
+ }
+
+ if (tasks.length > 0) {
+ const blob = new Blob([JSON.stringify(tasks, 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 ${tasks.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 (
-
-
-
-
-
-
-
-
- Settings
-
-
-
- Version History
-
-
-
- Filter Tasks
-
-
-
- Import & Export
-
-
-
- Export Schedule
-
-
-
- Import Schedule
-
-
-
- Print
-
-
-
- Schedule Offline
-
-
-
-
+ <>
+
+
+
+
+
+
+
+
+
+ Filter Tasks
+ {activeFiltersCount > 0 && (
+
+ {activeFiltersCount}
+
+ )}
+
+
+ Status
+ {STATUSES.map((status) => (
+ toggleStatus(status.value)}
+ >
+ {status.label}
+
+ ))}
+
+ Phase
+ {PHASES.map((phase) => (
+ togglePhase(phase.value)}
+ >
+ {phase.label}
+
+ ))}
+ {activeFiltersCount > 0 && (
+ <>
+
+
+
+ Clear all filters
+
+ >
+ )}
+
+
+
+
+ Import & Export
+
+
+
+ Export Schedule
+
+
+
+ Import Schedule
+
+
+
+ Print
+
+
+
-
-
+
+ {activeFiltersCount > 0 && (
+
+ )}
+
+ {tasksCount} task{tasksCount !== 1 ? "s" : ""}
+
+
+
+
+
+
+
+
+ >
)
}
diff --git a/src/components/schedule/schedule-view.tsx b/src/components/schedule/schedule-view.tsx
index 18847b6..05c2afe 100755
--- a/src/components/schedule/schedule-view.tsx
+++ b/src/components/schedule/schedule-view.tsx
@@ -1,9 +1,10 @@
"use client"
-import { useState } from "react"
+import { useState, useMemo } from "react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useIsMobile } from "@/hooks/use-mobile"
-import { ScheduleToolbar } from "./schedule-toolbar"
+import { ScheduleToolbar, type TaskFilters } from "./schedule-toolbar"
+import { ProjectSwitcher } from "./project-switcher"
import { ScheduleListView } from "./schedule-list-view"
import { ScheduleGanttView } from "./schedule-gantt-view"
import { ScheduleCalendarView } from "./schedule-calendar-view"
@@ -19,11 +20,19 @@ import type {
type TopTab = "schedule" | "baseline" | "exceptions"
type ScheduleSubTab = "calendar" | "list" | "gantt"
+const DEFAULT_FILTERS: TaskFilters = {
+ status: [],
+ phase: [],
+ assignedTo: "",
+ search: "",
+}
+
interface ScheduleViewProps {
projectId: string
projectName: string
initialData: ScheduleData
baselines: ScheduleBaselineData[]
+ allProjects?: { id: string; name: string }[]
}
export function ScheduleView({
@@ -31,18 +40,51 @@ export function ScheduleView({
projectName,
initialData,
baselines,
+ allProjects = [],
}: ScheduleViewProps) {
const isMobile = useIsMobile()
const [topTab, setTopTab] = useState("schedule")
const [subTab, setSubTab] = useState("calendar")
const [taskFormOpen, setTaskFormOpen] = useState(false)
+ const [filters, setFilters] = useState(DEFAULT_FILTERS)
+
+ 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 never))
+ }
+
+ 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])
return (
-
- {projectName} - Schedule
-
+
setTopTab(v as TopTab)}
@@ -63,7 +105,14 @@ export function ScheduleView({
className="flex flex-col flex-1 min-h-0"
>
- setTaskFormOpen(true)} />
+ setTaskFormOpen(true)}
+ filters={filters}
+ onFiltersChange={setFilters}
+ projectName={projectName}
+ tasksCount={filteredTasks.length}
+ tasks={filteredTasks}
+ />
-
+
{isMobile ? (
) : (
)}
-
+
-
+