fix(schedule): make Actions dropdown functional with filtering, export, and project switching (#80)

Co-authored-by: Avery Felts <averyfelts@Averys-MacBook-Air.local>
This commit is contained in:
aaf2tbz 2026-02-15 14:14:34 -07:00 committed by GitHub
parent d5a28ee709
commit d24fb34075
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 499 additions and 74 deletions

0
bun.lock Executable file → Normal file
View File

View File

@ -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}
/>
</div>
)

View File

@ -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 (
<div className="flex items-center gap-2">
<IconBuilding className="size-4 text-muted-foreground" />
<span className="text-lg font-semibold truncate">{currentProjectName}</span>
</div>
)
}
return (
<Select value={currentProjectId} onValueChange={handleProjectChange}>
<SelectTrigger className="h-9 w-auto border border-input bg-background hover:bg-accent hover:text-accent-foreground gap-2 px-3 max-w-[280px]">
<IconBuilding className="size-4 text-muted-foreground shrink-0" />
<span className="truncate font-medium">{currentProjectName}</span>
<IconChevronDown className="size-4 text-muted-foreground shrink-0 ml-auto" />
</SelectTrigger>
<SelectContent>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
)
}

View File

@ -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<HTMLInputElement>(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<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 tasks: 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
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 (
<div className="flex items-center justify-between gap-2 py-2 border-b mb-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9">
<IconDots className="size-4 mr-2" />
Actions
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuItem>
<IconSettings className="size-4 mr-2" />
Settings
</DropdownMenuItem>
<DropdownMenuItem>
<IconHistory className="size-4 mr-2" />
Version History
</DropdownMenuItem>
<DropdownMenuItem>
<IconFilter className="size-4 mr-2" />
Filter Tasks
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground px-2 py-1">
Import & Export
</DropdownMenuLabel>
<DropdownMenuItem>
<IconDownload className="size-4 mr-2" />
Export Schedule
</DropdownMenuItem>
<DropdownMenuItem>
<IconUpload className="size-4 mr-2" />
Import Schedule
</DropdownMenuItem>
<DropdownMenuItem>
<IconPrinter className="size-4 mr-2" />
Print
</DropdownMenuItem>
<DropdownMenuSeparator />
<div className="flex items-center justify-between px-2 py-1.5">
<span className="text-sm">Schedule Offline</span>
<Switch
checked={offlineMode}
onCheckedChange={setOfflineMode}
className="scale-75"
/>
</div>
</DropdownMenuContent>
</DropdownMenu>
<>
<div className="flex items-center justify-between gap-2 py-2 border-b mb-2 print:hidden">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9">
<IconDots className="size-4 mr-2" />
Actions
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<IconFilter className="size-4 mr-2" />
Filter Tasks
{activeFiltersCount > 0 && (
<span className="ml-auto bg-primary text-primary-foreground text-xs px-1.5 py-0.5 rounded">
{activeFiltersCount}
</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-64">
<DropdownMenuLabel className="text-xs">Status</DropdownMenuLabel>
{STATUSES.map((status) => (
<DropdownMenuCheckboxItem
key={status.value}
checked={filters.status.includes(status.value)}
onCheckedChange={() => toggleStatus(status.value)}
>
{status.label}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs">Phase</DropdownMenuLabel>
{PHASES.map((phase) => (
<DropdownMenuCheckboxItem
key={phase.value}
checked={filters.phase.includes(phase.value)}
onCheckedChange={() => togglePhase(phase.value)}
>
{phase.label}
</DropdownMenuCheckboxItem>
))}
{activeFiltersCount > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={clearFilters}>
<IconX className="size-4 mr-2" />
Clear all filters
</DropdownMenuItem>
</>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground px-2 py-1">
Import &amp; Export
</DropdownMenuLabel>
<DropdownMenuItem onClick={handleExportCSV}>
<IconDownload className="size-4 mr-2" />
Export Schedule
</DropdownMenuItem>
<DropdownMenuItem onClick={handleImportClick}>
<IconUpload className="size-4 mr-2" />
Import Schedule
</DropdownMenuItem>
<DropdownMenuItem onClick={handlePrint}>
<IconPrinter className="size-4 mr-2" />
Print
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button size="sm" onClick={onNewItem} className="h-9">
<IconPlus className="size-4 mr-2" />
New Task
</Button>
</div>
<div className="flex items-center gap-2">
{activeFiltersCount > 0 && (
<Button
variant="ghost"
size="sm"
className="h-9 text-muted-foreground"
onClick={clearFilters}
>
<IconX className="size-4 mr-1" />
Clear filters
</Button>
)}
<span className="text-xs text-muted-foreground hidden sm:inline">
{tasksCount} task{tasksCount !== 1 ? "s" : ""}
</span>
<Button size="sm" onClick={onNewItem} className="h-9">
<IconPlus className="size-4 mr-2" />
New Task
</Button>
</div>
</div>
<Dialog open={filterDialogOpen} onOpenChange={setFilterDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Filter Tasks</DialogTitle>
<DialogDescription>
Narrow down tasks by status, phase, or assignee.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Search</Label>
<Input
placeholder="Search by title..."
value={filters.search}
onChange={(e) =>
onFiltersChange({ ...filters, search: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label>Assigned To</Label>
<Input
placeholder="Filter by assignee..."
value={filters.assignedTo}
onChange={(e) =>
onFiltersChange({ ...filters, assignedTo: e.target.value })
}
/>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Import Schedule</DialogTitle>
<DialogDescription>
Upload a CSV file with your schedule data. The file should have columns
for title, start date, duration, phase, and assigned to.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<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>
</div>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -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<TopTab>("schedule")
const [subTab, setSubTab] = useState<ScheduleSubTab>("calendar")
const [taskFormOpen, setTaskFormOpen] = useState(false)
const [filters, setFilters] = useState<TaskFilters>(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 (
<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">
<h1 className="text-lg font-semibold truncate">
{projectName} - Schedule
</h1>
<div className="flex items-center gap-3">
<ProjectSwitcher
projects={allProjects}
currentProjectId={projectId}
currentProjectName={projectName}
/>
<span className="text-muted-foreground">- Schedule</span>
</div>
<Tabs
value={topTab}
onValueChange={(v) => setTopTab(v as TopTab)}
@ -63,7 +105,14 @@ export function ScheduleView({
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)} />
<ScheduleToolbar
onNewItem={() => setTaskFormOpen(true)}
filters={filters}
onFiltersChange={setFilters}
projectName={projectName}
tasksCount={filteredTasks.length}
tasks={filteredTasks}
/>
<Tabs
value={subTab}
@ -91,33 +140,33 @@ export function ScheduleView({
</TabsTrigger>
</TabsList>
<TabsContent value="calendar" className="mt-2 flex flex-col flex-1 min-h-0">
<TabsContent value="calendar" className="mt-2 flex flex-col flex-1 min-h-0" data-schedule-content>
{isMobile ? (
<ScheduleMobileView
tasks={initialData.tasks}
tasks={filteredTasks}
exceptions={initialData.exceptions}
/>
) : (
<ScheduleCalendarView
projectId={projectId}
tasks={initialData.tasks}
tasks={filteredTasks}
exceptions={initialData.exceptions}
/>
)}
</TabsContent>
<TabsContent value="list" className="mt-2 flex flex-col flex-1 min-h-0">
<TabsContent value="list" className="mt-2 flex flex-col flex-1 min-h-0" data-schedule-content>
<ScheduleListView
projectId={projectId}
tasks={initialData.tasks}
tasks={filteredTasks}
dependencies={initialData.dependencies}
/>
</TabsContent>
<TabsContent value="gantt" className="mt-2 flex flex-col flex-1 min-h-0">
<TabsContent value="gantt" className="mt-2 flex flex-col flex-1 min-h-0" data-schedule-content>
<ScheduleGanttView
projectId={projectId}
tasks={initialData.tasks}
tasks={filteredTasks}
dependencies={initialData.dependencies}
/>
</TabsContent>