From d24fb340750370c773d4318a0d60f7df4649a11d Mon Sep 17 00:00:00 2001 From: aaf2tbz Date: Sun, 15 Feb 2026 14:14:34 -0700 Subject: [PATCH] fix(schedule): make Actions dropdown functional with filtering, export, and project switching (#80) Co-authored-by: Avery Felts --- bun.lock | 0 .../dashboard/projects/[id]/schedule/page.tsx | 10 +- src/components/schedule/project-switcher.tsx | 56 +++ src/components/schedule/schedule-toolbar.tsx | 432 +++++++++++++++--- src/components/schedule/schedule-view.tsx | 75 ++- 5 files changed, 499 insertions(+), 74 deletions(-) mode change 100755 => 100644 bun.lock create mode 100644 src/components/schedule/project-switcher.tsx 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" : ""} + + +
+
+ + + + + Filter Tasks + + Narrow down tasks by status, phase, or assignee. + + +
+
+ + + onFiltersChange({ ...filters, search: e.target.value }) + } + /> +
+
+ + + onFiltersChange({ ...filters, assignedTo: e.target.value }) + } + /> +
+
+
+
+ + + + + Import Schedule + + Upload a CSV file with your schedule data. The file should have columns + for title, start date, duration, phase, and assigned to. + + +
+
+ + +

+ Supported format: CSV with headers +

+
+
+
+
+ ) } 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 -

+
+ + - 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 ? ( ) : ( )} - + - +