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:
parent
d5a28ee709
commit
d24fb34075
@ -3,7 +3,7 @@ export const dynamic = "force-dynamic"
|
|||||||
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
||||||
import { getDb } from "@/db"
|
import { getDb } from "@/db"
|
||||||
import { projects } from "@/db/schema"
|
import { projects } from "@/db/schema"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq, asc } from "drizzle-orm"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import { getSchedule } from "@/app/actions/schedule"
|
import { getSchedule } from "@/app/actions/schedule"
|
||||||
import { getBaselines } from "@/app/actions/baselines"
|
import { getBaselines } from "@/app/actions/baselines"
|
||||||
@ -26,6 +26,7 @@ export default async function SchedulePage({
|
|||||||
let projectName = "Project"
|
let projectName = "Project"
|
||||||
let schedule: ScheduleData = emptySchedule
|
let schedule: ScheduleData = emptySchedule
|
||||||
let baselines: ScheduleBaselineData[] = []
|
let baselines: ScheduleBaselineData[] = []
|
||||||
|
let allProjects: { id: string; name: string }[] = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { env } = await getCloudflareContext()
|
const { env } = await getCloudflareContext()
|
||||||
@ -41,9 +42,13 @@ export default async function SchedulePage({
|
|||||||
if (!project) notFound()
|
if (!project) notFound()
|
||||||
|
|
||||||
projectName = project.name
|
projectName = project.name
|
||||||
;[schedule, baselines] = await Promise.all([
|
;[schedule, baselines, allProjects] = await Promise.all([
|
||||||
getSchedule(id),
|
getSchedule(id),
|
||||||
getBaselines(id),
|
getBaselines(id),
|
||||||
|
db
|
||||||
|
.select({ id: projects.id, name: projects.name })
|
||||||
|
.from(projects)
|
||||||
|
.orderBy(asc(projects.name)),
|
||||||
])
|
])
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (e && typeof e === "object" && "digest" in e && e.digest === "NEXT_NOT_FOUND") throw e
|
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}
|
projectName={projectName}
|
||||||
initialData={schedule}
|
initialData={schedule}
|
||||||
baselines={baselines}
|
baselines={baselines}
|
||||||
|
allProjects={allProjects}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
56
src/components/schedule/project-switcher.tsx
Normal file
56
src/components/schedule/project-switcher.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,8 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState, useRef } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
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 {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@ -10,27 +11,225 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import {
|
import {
|
||||||
IconSettings,
|
Dialog,
|
||||||
IconHistory,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import {
|
||||||
IconFilter,
|
IconFilter,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconDots,
|
IconDots,
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconUpload,
|
IconUpload,
|
||||||
IconPrinter,
|
IconPrinter,
|
||||||
|
IconX,
|
||||||
|
IconLoader2,
|
||||||
} from "@tabler/icons-react"
|
} 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 {
|
interface ScheduleToolbarProps {
|
||||||
onNewItem: () => void
|
onNewItem: () => void
|
||||||
|
filters: TaskFilters
|
||||||
|
onFiltersChange: (filters: TaskFilters) => void
|
||||||
|
projectName: string
|
||||||
|
tasksCount: number
|
||||||
|
tasks: ScheduleTaskData[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScheduleToolbar({ onNewItem }: ScheduleToolbarProps) {
|
export function ScheduleToolbar({
|
||||||
const [offlineMode, setOfflineMode] = useState(false)
|
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 (
|
return (
|
||||||
<div className="flex items-center justify-between gap-2 py-2 border-b mb-2">
|
<>
|
||||||
|
<div className="flex items-center justify-between gap-2 py-2 border-b mb-2 print:hidden">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="sm" className="h-9">
|
<Button variant="outline" size="sm" className="h-9">
|
||||||
@ -38,51 +237,166 @@ export function ScheduleToolbar({ onNewItem }: ScheduleToolbarProps) {
|
|||||||
Actions
|
Actions
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-48">
|
<DropdownMenuContent align="start" className="w-56">
|
||||||
<DropdownMenuItem>
|
<DropdownMenuSub>
|
||||||
<IconSettings className="size-4 mr-2" />
|
<DropdownMenuSubTrigger>
|
||||||
Settings
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<IconHistory className="size-4 mr-2" />
|
|
||||||
Version History
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<IconFilter className="size-4 mr-2" />
|
<IconFilter className="size-4 mr-2" />
|
||||||
Filter Tasks
|
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>
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground px-2 py-1">
|
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground px-2 py-1">
|
||||||
Import & Export
|
Import & Export
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem onClick={handleExportCSV}>
|
||||||
<IconDownload className="size-4 mr-2" />
|
<IconDownload className="size-4 mr-2" />
|
||||||
Export Schedule
|
Export Schedule
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem onClick={handleImportClick}>
|
||||||
<IconUpload className="size-4 mr-2" />
|
<IconUpload className="size-4 mr-2" />
|
||||||
Import Schedule
|
Import Schedule
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem onClick={handlePrint}>
|
||||||
<IconPrinter className="size-4 mr-2" />
|
<IconPrinter className="size-4 mr-2" />
|
||||||
Print
|
Print
|
||||||
</DropdownMenuItem>
|
</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>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<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">
|
<Button size="sm" onClick={onNewItem} className="h-9">
|
||||||
<IconPlus className="size-4 mr-2" />
|
<IconPlus className="size-4 mr-2" />
|
||||||
New Task
|
New Task
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState, useMemo } from "react"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
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 { ScheduleListView } from "./schedule-list-view"
|
||||||
import { ScheduleGanttView } from "./schedule-gantt-view"
|
import { ScheduleGanttView } from "./schedule-gantt-view"
|
||||||
import { ScheduleCalendarView } from "./schedule-calendar-view"
|
import { ScheduleCalendarView } from "./schedule-calendar-view"
|
||||||
@ -19,11 +20,19 @@ import type {
|
|||||||
type TopTab = "schedule" | "baseline" | "exceptions"
|
type TopTab = "schedule" | "baseline" | "exceptions"
|
||||||
type ScheduleSubTab = "calendar" | "list" | "gantt"
|
type ScheduleSubTab = "calendar" | "list" | "gantt"
|
||||||
|
|
||||||
|
const DEFAULT_FILTERS: TaskFilters = {
|
||||||
|
status: [],
|
||||||
|
phase: [],
|
||||||
|
assignedTo: "",
|
||||||
|
search: "",
|
||||||
|
}
|
||||||
|
|
||||||
interface ScheduleViewProps {
|
interface ScheduleViewProps {
|
||||||
projectId: string
|
projectId: string
|
||||||
projectName: string
|
projectName: string
|
||||||
initialData: ScheduleData
|
initialData: ScheduleData
|
||||||
baselines: ScheduleBaselineData[]
|
baselines: ScheduleBaselineData[]
|
||||||
|
allProjects?: { id: string; name: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScheduleView({
|
export function ScheduleView({
|
||||||
@ -31,18 +40,51 @@ export function ScheduleView({
|
|||||||
projectName,
|
projectName,
|
||||||
initialData,
|
initialData,
|
||||||
baselines,
|
baselines,
|
||||||
|
allProjects = [],
|
||||||
}: ScheduleViewProps) {
|
}: ScheduleViewProps) {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const [topTab, setTopTab] = useState<TopTab>("schedule")
|
const [topTab, setTopTab] = useState<TopTab>("schedule")
|
||||||
const [subTab, setSubTab] = useState<ScheduleSubTab>("calendar")
|
const [subTab, setSubTab] = useState<ScheduleSubTab>("calendar")
|
||||||
const [taskFormOpen, setTaskFormOpen] = useState(false)
|
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 (
|
return (
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<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">
|
<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">
|
<div className="flex items-center gap-3">
|
||||||
{projectName} - Schedule
|
<ProjectSwitcher
|
||||||
</h1>
|
projects={allProjects}
|
||||||
|
currentProjectId={projectId}
|
||||||
|
currentProjectName={projectName}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">- Schedule</span>
|
||||||
|
</div>
|
||||||
<Tabs
|
<Tabs
|
||||||
value={topTab}
|
value={topTab}
|
||||||
onValueChange={(v) => setTopTab(v as TopTab)}
|
onValueChange={(v) => setTopTab(v as TopTab)}
|
||||||
@ -63,7 +105,14 @@ export function ScheduleView({
|
|||||||
className="flex flex-col flex-1 min-h-0"
|
className="flex flex-col flex-1 min-h-0"
|
||||||
>
|
>
|
||||||
<TabsContent value="schedule" className="mt-0 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
|
<Tabs
|
||||||
value={subTab}
|
value={subTab}
|
||||||
@ -91,33 +140,33 @@ export function ScheduleView({
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</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 ? (
|
{isMobile ? (
|
||||||
<ScheduleMobileView
|
<ScheduleMobileView
|
||||||
tasks={initialData.tasks}
|
tasks={filteredTasks}
|
||||||
exceptions={initialData.exceptions}
|
exceptions={initialData.exceptions}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ScheduleCalendarView
|
<ScheduleCalendarView
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
tasks={initialData.tasks}
|
tasks={filteredTasks}
|
||||||
exceptions={initialData.exceptions}
|
exceptions={initialData.exceptions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</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
|
<ScheduleListView
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
tasks={initialData.tasks}
|
tasks={filteredTasks}
|
||||||
dependencies={initialData.dependencies}
|
dependencies={initialData.dependencies}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</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
|
<ScheduleGanttView
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
tasks={initialData.tasks}
|
tasks={filteredTasks}
|
||||||
dependencies={initialData.dependencies}
|
dependencies={initialData.dependencies}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user