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 { 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>
|
||||
)
|
||||
|
||||
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"
|
||||
|
||||
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 & 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user