1016 lines
38 KiB
TypeScript
1016 lines
38 KiB
TypeScript
"use client"
|
|
|
|
import { Suspense, useEffect, useMemo, useState } from "react"
|
|
import Link from "next/link"
|
|
import { useRouter, useSearchParams } from "next/navigation"
|
|
import { ArrowLeft, CheckCircle2, Filter, FolderKanban, RefreshCcw } from "lucide-react"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
|
import { Input } from "@/components/ui/input"
|
|
import { isSprintInProgress, parseSprintEnd, parseSprintStart } from "@/lib/utils"
|
|
import { useTaskStore, type Priority, type TaskStatus, type TaskType } from "@/stores/useTaskStore"
|
|
|
|
const TASK_STATUSES: TaskStatus[] = [
|
|
"open",
|
|
"todo",
|
|
"blocked",
|
|
"in-progress",
|
|
"review",
|
|
"validate",
|
|
"archived",
|
|
"canceled",
|
|
"done",
|
|
]
|
|
|
|
const TASK_PRIORITIES: Priority[] = ["low", "medium", "high", "urgent"]
|
|
const TASK_TYPES: TaskType[] = ["idea", "task", "bug", "research", "plan"]
|
|
|
|
const BACKLOG_FILTER_TOKEN = "backlog"
|
|
const UNASSIGNED_FILTER_TOKEN = "unassigned"
|
|
|
|
type TaskScope = "all" | "active-sprint"
|
|
type DueFilter = "any" | "has" | "none" | "overdue" | "today" | "upcoming"
|
|
type TaskDateField = "createdAt" | "updatedAt" | "dueDate"
|
|
type SortOrder = "asc" | "desc"
|
|
|
|
type Filters = {
|
|
scope: TaskScope
|
|
sortBy: TaskDateField
|
|
sortOrder: SortOrder
|
|
query: string
|
|
statuses: TaskStatus[]
|
|
priorities: Priority[]
|
|
types: TaskType[]
|
|
projectIds: string[]
|
|
sprintIds: string[]
|
|
assigneeIds: string[]
|
|
tags: string[]
|
|
due: DueFilter
|
|
}
|
|
|
|
const DUE_FILTERS: DueFilter[] = ["any", "has", "none", "overdue", "today", "upcoming"]
|
|
const DATE_SORT_FIELDS: TaskDateField[] = ["updatedAt", "createdAt", "dueDate"]
|
|
const DUE_FILTER_LABELS: Record<DueFilter, string> = {
|
|
any: "Any Due",
|
|
has: "Has Due Date",
|
|
none: "No Due Date",
|
|
overdue: "Overdue",
|
|
today: "Due Today",
|
|
upcoming: "Upcoming",
|
|
}
|
|
const DATE_SORT_FIELD_LABELS: Record<TaskDateField, string> = {
|
|
updatedAt: "Updated At",
|
|
createdAt: "Created At",
|
|
dueDate: "Due Date",
|
|
}
|
|
|
|
function toTimestamp(value: string): number {
|
|
const timestamp = new Date(value).getTime()
|
|
return Number.isNaN(timestamp) ? 0 : timestamp
|
|
}
|
|
|
|
function parseCsv(raw: string | null): string[] {
|
|
if (!raw) return []
|
|
return raw
|
|
.split(",")
|
|
.map((entry) => entry.trim())
|
|
.filter((entry) => entry.length > 0)
|
|
}
|
|
|
|
function parseScope(raw: string | null): TaskScope {
|
|
return raw === "active-sprint" ? "active-sprint" : "all"
|
|
}
|
|
|
|
function parseDueFilter(raw: string | null): DueFilter {
|
|
if (!raw) return "any"
|
|
return DUE_FILTERS.includes(raw as DueFilter) ? (raw as DueFilter) : "any"
|
|
}
|
|
|
|
function parseSortBy(raw: string | null): TaskDateField {
|
|
if (!raw) return "updatedAt"
|
|
return DATE_SORT_FIELDS.includes(raw as TaskDateField) ? (raw as TaskDateField) : "updatedAt"
|
|
}
|
|
|
|
function parseSortOrder(raw: string | null): SortOrder {
|
|
return raw === "asc" ? "asc" : "desc"
|
|
}
|
|
|
|
function isTaskStatus(value: string): value is TaskStatus {
|
|
return TASK_STATUSES.includes(value as TaskStatus)
|
|
}
|
|
|
|
function isTaskPriority(value: string): value is Priority {
|
|
return TASK_PRIORITIES.includes(value as Priority)
|
|
}
|
|
|
|
function isTaskType(value: string): value is TaskType {
|
|
return TASK_TYPES.includes(value as TaskType)
|
|
}
|
|
|
|
function formatStatus(status: TaskStatus): string {
|
|
if (status === "todo") return "To Do"
|
|
return status
|
|
.split("-")
|
|
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
|
|
.join(" ")
|
|
}
|
|
|
|
function formatType(type: TaskType): string {
|
|
return `${type.charAt(0).toUpperCase()}${type.slice(1)}`
|
|
}
|
|
|
|
function formatUpdatedAt(value: string): string {
|
|
const parsed = new Date(value)
|
|
if (Number.isNaN(parsed.getTime())) return "Unknown update time"
|
|
return parsed.toLocaleString()
|
|
}
|
|
|
|
function getPriorityClasses(priority: Priority): string {
|
|
switch (priority) {
|
|
case "urgent":
|
|
return "bg-red-500/10 text-red-300 border-red-500/30"
|
|
case "high":
|
|
return "bg-orange-500/10 text-orange-300 border-orange-500/30"
|
|
case "medium":
|
|
return "bg-yellow-500/10 text-yellow-300 border-yellow-500/30"
|
|
default:
|
|
return "bg-blue-500/10 text-blue-300 border-blue-500/30"
|
|
}
|
|
}
|
|
|
|
function getStatusClasses(status: TaskStatus): string {
|
|
switch (status) {
|
|
case "done":
|
|
return "bg-emerald-500/10 text-emerald-300 border-emerald-500/30"
|
|
case "in-progress":
|
|
return "bg-blue-500/10 text-blue-300 border-blue-500/30"
|
|
case "blocked":
|
|
return "bg-red-500/10 text-red-300 border-red-500/30"
|
|
case "review":
|
|
case "validate":
|
|
return "bg-purple-500/10 text-purple-300 border-purple-500/30"
|
|
case "canceled":
|
|
return "bg-rose-500/10 text-rose-300 border-rose-500/30"
|
|
default:
|
|
return "bg-slate-500/10 text-slate-300 border-slate-500/30"
|
|
}
|
|
}
|
|
|
|
function toggleOrdered<T extends string>(current: T[], value: T, universe: readonly T[]): T[] {
|
|
const next = new Set(current)
|
|
if (next.has(value)) {
|
|
next.delete(value)
|
|
} else {
|
|
next.add(value)
|
|
}
|
|
return universe.filter((entry) => next.has(entry))
|
|
}
|
|
|
|
function getTaskDateSortValue(
|
|
task: { createdAt: string; updatedAt: string; dueDate?: string },
|
|
field: TaskDateField
|
|
): number | null {
|
|
if (field === "dueDate") {
|
|
if (!task.dueDate) return null
|
|
const timestamp = parseSprintStart(task.dueDate).getTime()
|
|
return Number.isNaN(timestamp) ? null : timestamp
|
|
}
|
|
|
|
const source = field === "createdAt" ? task.createdAt : task.updatedAt
|
|
const timestamp = new Date(source).getTime()
|
|
return Number.isNaN(timestamp) ? null : timestamp
|
|
}
|
|
|
|
function buildTasksUrl(filters: Filters): string {
|
|
const params = new URLSearchParams()
|
|
if (filters.scope !== "all") params.set("scope", filters.scope)
|
|
if (filters.sortBy !== "updatedAt") params.set("sortBy", filters.sortBy)
|
|
if (filters.sortOrder !== "desc") params.set("sortOrder", filters.sortOrder)
|
|
if (filters.query.trim().length > 0) params.set("q", filters.query.trim())
|
|
if (filters.statuses.length > 0) params.set("status", filters.statuses.join(","))
|
|
if (filters.priorities.length > 0) params.set("priority", filters.priorities.join(","))
|
|
if (filters.types.length > 0) params.set("type", filters.types.join(","))
|
|
if (filters.projectIds.length > 0) params.set("project", filters.projectIds.join(","))
|
|
if (filters.sprintIds.length > 0) params.set("sprint", filters.sprintIds.join(","))
|
|
if (filters.assigneeIds.length > 0) params.set("assignee", filters.assigneeIds.join(","))
|
|
if (filters.tags.length > 0) params.set("tag", filters.tags.join(","))
|
|
if (filters.due !== "any") params.set("due", filters.due)
|
|
const query = params.toString()
|
|
return query.length > 0 ? `/tasks?${query}` : "/tasks"
|
|
}
|
|
|
|
function TasksPageContent() {
|
|
const router = useRouter()
|
|
const searchParams = useSearchParams()
|
|
const { projects, sprints, tasks, syncFromServer, syncError } = useTaskStore()
|
|
|
|
const [authReady, setAuthReady] = useState(false)
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [searchInput, setSearchInput] = useState("")
|
|
const [filtersDialogOpen, setFiltersDialogOpen] = useState(false)
|
|
|
|
useEffect(() => {
|
|
let isMounted = true
|
|
const loadSession = async () => {
|
|
try {
|
|
const response = await fetch("/api/auth/session", { cache: "no-store" })
|
|
if (!response.ok) {
|
|
if (isMounted) router.replace("/login")
|
|
return
|
|
}
|
|
if (isMounted) setAuthReady(true)
|
|
} catch {
|
|
if (isMounted) router.replace("/login")
|
|
}
|
|
}
|
|
|
|
void loadSession()
|
|
return () => {
|
|
isMounted = false
|
|
}
|
|
}, [router])
|
|
|
|
const projectById = useMemo(() => {
|
|
const mapping = new Map<string, { name: string; color: string }>()
|
|
projects.forEach((project) => {
|
|
mapping.set(project.id, { name: project.name, color: project.color })
|
|
})
|
|
return mapping
|
|
}, [projects])
|
|
|
|
const sprintById = useMemo(() => {
|
|
const mapping = new Map<string, string>()
|
|
sprints.forEach((sprint) => {
|
|
mapping.set(sprint.id, sprint.name)
|
|
})
|
|
return mapping
|
|
}, [sprints])
|
|
|
|
const projectIdSet = useMemo(() => new Set(projects.map((project) => project.id)), [projects])
|
|
const sprintIdSet = useMemo(() => new Set(sprints.map((sprint) => sprint.id)), [sprints])
|
|
|
|
const assigneeOptions = useMemo(() => {
|
|
const byId = new Map<string, string>()
|
|
tasks.forEach((task) => {
|
|
if (!task.assigneeId || !task.assigneeName) return
|
|
byId.set(task.assigneeId, task.assigneeName)
|
|
})
|
|
return Array.from(byId.entries())
|
|
.map(([id, name]) => ({ id, name }))
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
}, [tasks])
|
|
const assigneeIdSet = useMemo(() => new Set(assigneeOptions.map((entry) => entry.id)), [assigneeOptions])
|
|
|
|
const tagOptions = useMemo(() => {
|
|
const counts = new Map<string, { value: string; label: string; count: number }>()
|
|
tasks.forEach((task) => {
|
|
task.tags.forEach((tag) => {
|
|
const normalized = tag.trim().toLowerCase()
|
|
if (!normalized) return
|
|
const existing = counts.get(normalized)
|
|
if (existing) {
|
|
existing.count += 1
|
|
return
|
|
}
|
|
counts.set(normalized, { value: normalized, label: tag.trim(), count: 1 })
|
|
})
|
|
})
|
|
return Array.from(counts.values())
|
|
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label))
|
|
.slice(0, 24)
|
|
}, [tasks])
|
|
const tagValueSet = useMemo(() => new Set(tagOptions.map((entry) => entry.value)), [tagOptions])
|
|
|
|
const filters = useMemo<Filters>(() => {
|
|
const scope = parseScope(searchParams.get("scope"))
|
|
const sortBy = parseSortBy(searchParams.get("sortBy"))
|
|
const sortOrder = parseSortOrder(searchParams.get("sortOrder"))
|
|
const query = (searchParams.get("q") || "").trim()
|
|
const statuses = parseCsv(searchParams.get("status")).filter(isTaskStatus)
|
|
const priorities = parseCsv(searchParams.get("priority")).filter(isTaskPriority)
|
|
const types = parseCsv(searchParams.get("type")).filter(isTaskType)
|
|
const projectIds = parseCsv(searchParams.get("project")).filter((entry) => projectIdSet.has(entry))
|
|
const sprintIds = parseCsv(searchParams.get("sprint")).filter((entry) => {
|
|
return entry === BACKLOG_FILTER_TOKEN || sprintIdSet.has(entry)
|
|
})
|
|
const assigneeIds = parseCsv(searchParams.get("assignee")).filter((entry) => {
|
|
return entry === UNASSIGNED_FILTER_TOKEN || assigneeIdSet.has(entry)
|
|
})
|
|
const tags = parseCsv(searchParams.get("tag"))
|
|
.map((entry) => entry.toLowerCase())
|
|
.filter((entry) => tagValueSet.has(entry))
|
|
const due = parseDueFilter(searchParams.get("due"))
|
|
|
|
return {
|
|
scope,
|
|
sortBy,
|
|
sortOrder,
|
|
query,
|
|
statuses,
|
|
priorities,
|
|
types,
|
|
projectIds,
|
|
sprintIds,
|
|
assigneeIds,
|
|
tags,
|
|
due,
|
|
}
|
|
}, [assigneeIdSet, projectIdSet, searchParams, sprintIdSet, tagValueSet])
|
|
|
|
useEffect(() => {
|
|
if (!authReady) return
|
|
|
|
let active = true
|
|
const loadData = async () => {
|
|
setIsLoading(true)
|
|
await syncFromServer({ scope: filters.scope })
|
|
if (active) setIsLoading(false)
|
|
}
|
|
|
|
void loadData()
|
|
return () => {
|
|
active = false
|
|
}
|
|
}, [authReady, filters.scope, syncFromServer])
|
|
|
|
const activeSprint = useMemo(() => {
|
|
const now = new Date()
|
|
return (
|
|
sprints
|
|
.filter((sprint) => isSprintInProgress(sprint.startDate, sprint.endDate, now))
|
|
.sort((a, b) => parseSprintStart(b.startDate).getTime() - parseSprintStart(a.startDate).getTime())[0] ||
|
|
null
|
|
)
|
|
}, [sprints])
|
|
|
|
const filteredTasks = useMemo(() => {
|
|
const now = new Date()
|
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0)
|
|
const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999)
|
|
const query = filters.query.toLowerCase()
|
|
|
|
return tasks
|
|
.filter((task) => {
|
|
if (filters.statuses.length > 0 && !filters.statuses.includes(task.status)) return false
|
|
if (filters.priorities.length > 0 && !filters.priorities.includes(task.priority)) return false
|
|
if (filters.types.length > 0 && !filters.types.includes(task.type)) return false
|
|
if (filters.projectIds.length > 0 && !filters.projectIds.includes(task.projectId)) return false
|
|
|
|
if (filters.sprintIds.length > 0) {
|
|
const matchesSprint = filters.sprintIds.some((filterToken) => {
|
|
if (filterToken === BACKLOG_FILTER_TOKEN) return !task.sprintId
|
|
return task.sprintId === filterToken
|
|
})
|
|
if (!matchesSprint) return false
|
|
}
|
|
|
|
if (filters.assigneeIds.length > 0) {
|
|
const matchesAssignee = filters.assigneeIds.some((filterToken) => {
|
|
if (filterToken === UNASSIGNED_FILTER_TOKEN) return !task.assigneeId
|
|
return task.assigneeId === filterToken
|
|
})
|
|
if (!matchesAssignee) return false
|
|
}
|
|
|
|
if (filters.tags.length > 0) {
|
|
const taskTagSet = new Set(task.tags.map((tag) => tag.toLowerCase()))
|
|
const matchesTags = filters.tags.some((tag) => taskTagSet.has(tag))
|
|
if (!matchesTags) return false
|
|
}
|
|
|
|
if (filters.due !== "any") {
|
|
if (!task.dueDate) {
|
|
if (filters.due !== "none") return false
|
|
} else {
|
|
if (filters.due === "none") return false
|
|
if (filters.due !== "has") {
|
|
const dueStart = parseSprintStart(task.dueDate)
|
|
const dueEnd = parseSprintEnd(task.dueDate)
|
|
if (Number.isNaN(dueStart.getTime()) || Number.isNaN(dueEnd.getTime())) return false
|
|
|
|
if (filters.due === "overdue" && dueEnd >= now) return false
|
|
if (filters.due === "today" && (dueStart > todayEnd || dueEnd < todayStart)) return false
|
|
if (filters.due === "upcoming" && dueStart <= todayEnd) return false
|
|
}
|
|
}
|
|
}
|
|
|
|
if (query.length > 0) {
|
|
const projectName = projectById.get(task.projectId)?.name || ""
|
|
const sprintName = task.sprintId ? sprintById.get(task.sprintId) || "" : "backlog"
|
|
const haystack = [
|
|
task.title,
|
|
task.description || "",
|
|
task.type,
|
|
task.status,
|
|
task.priority,
|
|
task.assigneeName || "",
|
|
projectName,
|
|
sprintName,
|
|
task.tags.join(" "),
|
|
]
|
|
.join(" ")
|
|
.toLowerCase()
|
|
|
|
if (!haystack.includes(query)) return false
|
|
}
|
|
|
|
return true
|
|
})
|
|
.sort((a, b) => {
|
|
const left = getTaskDateSortValue(a, filters.sortBy)
|
|
const right = getTaskDateSortValue(b, filters.sortBy)
|
|
|
|
if (left === null && right === null) {
|
|
return toTimestamp(b.updatedAt) - toTimestamp(a.updatedAt)
|
|
}
|
|
if (left === null) return 1
|
|
if (right === null) return -1
|
|
if (left === right) return toTimestamp(b.updatedAt) - toTimestamp(a.updatedAt)
|
|
|
|
return filters.sortOrder === "asc" ? left - right : right - left
|
|
})
|
|
}, [filters, projectById, sprintById, tasks])
|
|
|
|
const hasQueryParams = useMemo(() => searchParams.toString().length > 0, [searchParams])
|
|
|
|
const statusFilterSet = useMemo(() => new Set(filters.statuses), [filters.statuses])
|
|
const priorityFilterSet = useMemo(() => new Set(filters.priorities), [filters.priorities])
|
|
const typeFilterSet = useMemo(() => new Set(filters.types), [filters.types])
|
|
const projectFilterSet = useMemo(() => new Set(filters.projectIds), [filters.projectIds])
|
|
const sprintFilterSet = useMemo(() => new Set(filters.sprintIds), [filters.sprintIds])
|
|
const assigneeFilterSet = useMemo(() => new Set(filters.assigneeIds), [filters.assigneeIds])
|
|
const tagFilterSet = useMemo(() => new Set(filters.tags), [filters.tags])
|
|
|
|
const activeFilterCount =
|
|
filters.statuses.length +
|
|
filters.priorities.length +
|
|
filters.types.length +
|
|
filters.projectIds.length +
|
|
filters.sprintIds.length +
|
|
filters.assigneeIds.length +
|
|
filters.tags.length +
|
|
(filters.sortBy !== "updatedAt" || filters.sortOrder !== "desc" ? 1 : 0) +
|
|
(filters.query.length > 0 ? 1 : 0) +
|
|
(filters.due !== "any" ? 1 : 0) +
|
|
(filters.scope !== "all" ? 1 : 0)
|
|
|
|
const updateFilters = (updates: Partial<Filters>) => {
|
|
const nextFilters: Filters = { ...filters, ...updates }
|
|
router.push(buildTasksUrl(nextFilters))
|
|
}
|
|
|
|
const clearFilters = () => {
|
|
router.push("/tasks")
|
|
}
|
|
|
|
const applySearch = () => {
|
|
updateFilters({ query: searchInput.trim() })
|
|
}
|
|
|
|
const toggleStatusFilter = (status: TaskStatus) => {
|
|
updateFilters({
|
|
statuses: toggleOrdered(filters.statuses, status, TASK_STATUSES),
|
|
})
|
|
}
|
|
|
|
const togglePriorityFilter = (priority: Priority) => {
|
|
updateFilters({
|
|
priorities: toggleOrdered(filters.priorities, priority, TASK_PRIORITIES),
|
|
})
|
|
}
|
|
|
|
const toggleTypeFilter = (type: TaskType) => {
|
|
updateFilters({
|
|
types: toggleOrdered(filters.types, type, TASK_TYPES),
|
|
})
|
|
}
|
|
|
|
const toggleProjectFilter = (projectId: string) => {
|
|
updateFilters({
|
|
projectIds: toggleOrdered(filters.projectIds, projectId, projects.map((project) => project.id)),
|
|
})
|
|
}
|
|
|
|
const sprintOrder = [BACKLOG_FILTER_TOKEN, ...sprints.map((sprint) => sprint.id)]
|
|
const toggleSprintFilter = (sprintId: string) => {
|
|
updateFilters({
|
|
sprintIds: toggleOrdered(filters.sprintIds, sprintId, sprintOrder),
|
|
})
|
|
}
|
|
|
|
const assigneeOrder = [UNASSIGNED_FILTER_TOKEN, ...assigneeOptions.map((assignee) => assignee.id)]
|
|
const toggleAssigneeFilter = (assigneeId: string) => {
|
|
updateFilters({
|
|
assigneeIds: toggleOrdered(filters.assigneeIds, assigneeId, assigneeOrder),
|
|
})
|
|
}
|
|
|
|
const toggleTagFilter = (tag: string) => {
|
|
updateFilters({
|
|
tags: toggleOrdered(filters.tags, tag, tagOptions.map((entry) => entry.value)),
|
|
})
|
|
}
|
|
|
|
if (!authReady) {
|
|
return (
|
|
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center">
|
|
<p className="text-sm text-slate-400">Checking session...</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-slate-950 text-slate-100">
|
|
<header className="border-b border-slate-800 bg-slate-900/50 backdrop-blur">
|
|
<div className="max-w-[1800px] mx-auto px-4 py-4">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div className="flex items-center gap-4">
|
|
<Button
|
|
variant="outline"
|
|
className="border-slate-700 text-slate-200 hover:bg-slate-800"
|
|
onClick={() => router.push("/")}
|
|
>
|
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
Back to Board
|
|
</Button>
|
|
<div>
|
|
<h1 className="text-xl md:text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
|
|
Tasks
|
|
</h1>
|
|
<p className="text-xs md:text-sm text-slate-400 mt-1">
|
|
Master-filter task list
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
className="border-slate-700 text-slate-200 hover:bg-slate-800"
|
|
onClick={() => {
|
|
setSearchInput(filters.query)
|
|
setFiltersDialogOpen(true)
|
|
}}
|
|
>
|
|
<Filter className="w-4 h-4 mr-2" />
|
|
Filters
|
|
{activeFilterCount > 0 && <span className="ml-2 text-xs text-blue-300">({activeFilterCount})</span>}
|
|
</Button>
|
|
{hasQueryParams && (
|
|
<Button
|
|
variant="outline"
|
|
className="border-slate-700 text-slate-200 hover:bg-slate-800"
|
|
onClick={clearFilters}
|
|
>
|
|
<RefreshCcw className="w-4 h-4 mr-2" />
|
|
Clear Filters
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="max-w-[1800px] mx-auto px-4 py-6 space-y-4">
|
|
{syncError && (
|
|
<div className="rounded-lg border border-red-500/30 bg-red-500/10 text-red-200 text-sm px-3 py-2">
|
|
Sync error: {syncError}
|
|
</div>
|
|
)}
|
|
|
|
<Card className="bg-slate-900 border-slate-800">
|
|
<CardContent className="p-4 space-y-1">
|
|
<p className="text-sm text-slate-300">
|
|
Scope: <span className="text-slate-100">{filters.scope === "active-sprint" ? "Current sprint" : "All tasks"}</span>
|
|
</p>
|
|
<p className="text-sm text-slate-300">
|
|
Status: <span className="text-slate-100">{filters.statuses.length > 0 ? filters.statuses.map(formatStatus).join(", ") : "Any"}</span>
|
|
</p>
|
|
<p className="text-sm text-slate-300">
|
|
Priority: <span className="text-slate-100">{filters.priorities.length > 0 ? filters.priorities.map((entry) => entry.toUpperCase()).join(", ") : "Any"}</span>
|
|
</p>
|
|
<p className="text-sm text-slate-300">
|
|
Sort: <span className="text-slate-100">{DATE_SORT_FIELD_LABELS[filters.sortBy]} ({filters.sortOrder.toUpperCase()})</span>
|
|
</p>
|
|
{filters.scope === "active-sprint" && activeSprint && (
|
|
<p className="text-xs text-slate-400">
|
|
Active sprint: <span className="text-slate-200">{activeSprint.name}</span>
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-12 text-slate-400">
|
|
<span>Loading tasks...</span>
|
|
</div>
|
|
) : filteredTasks.length === 0 ? (
|
|
<Card className="bg-slate-900 border-slate-800">
|
|
<CardContent className="p-8 text-center">
|
|
<h3 className="text-lg font-semibold text-slate-100 mb-2">No tasks found</h3>
|
|
{filters.scope === "active-sprint" && !activeSprint ? (
|
|
<>
|
|
<p className="text-sm text-slate-400 mb-4">
|
|
There is no active sprint right now, so active-sprint scope returned no tasks.
|
|
</p>
|
|
<Button onClick={() => updateFilters({ scope: "all" })}>
|
|
<FolderKanban className="w-4 h-4 mr-2" />
|
|
Show All Tasks
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<p className="text-sm text-slate-400">
|
|
Open Filters to adjust status, priority, project, sprint, assignee, tags, due date, and search.
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{filteredTasks.map((task) => {
|
|
const project = projectById.get(task.projectId)
|
|
const sprintName = task.sprintId ? sprintById.get(task.sprintId) : null
|
|
|
|
return (
|
|
<Card key={task.id} className="bg-slate-900 border-slate-800 hover:border-slate-700 transition-colors">
|
|
<CardHeader className="pb-2">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<Link
|
|
href={`/tasks/${encodeURIComponent(task.id)}`}
|
|
className="font-semibold text-slate-100 hover:text-blue-400 hover:underline truncate"
|
|
>
|
|
{task.title}
|
|
</Link>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<Badge variant="outline" className={getStatusClasses(task.status)}>
|
|
{formatStatus(task.status)}
|
|
</Badge>
|
|
<Badge variant="outline" className={getPriorityClasses(task.priority)}>
|
|
{task.priority.toUpperCase()}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="pt-0">
|
|
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-400">
|
|
<span className="inline-flex items-center gap-1">
|
|
<span
|
|
className="w-2 h-2 rounded-full"
|
|
style={{ backgroundColor: project?.color || "#64748b" }}
|
|
/>
|
|
{project?.name || "Unknown project"}
|
|
</span>
|
|
<span>•</span>
|
|
<span>{sprintName || "Backlog"}</span>
|
|
<span>•</span>
|
|
<span>{task.assigneeName || "Unassigned"}</span>
|
|
<span>•</span>
|
|
<span>Updated {formatUpdatedAt(task.updatedAt)}</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{filters.scope === "active-sprint" && filteredTasks.length > 0 && (
|
|
<div className="pt-2">
|
|
<Button
|
|
variant="outline"
|
|
className="border-slate-700 text-slate-200 hover:bg-slate-800"
|
|
onClick={() => updateFilters({ scope: "all" })}
|
|
>
|
|
<CheckCircle2 className="w-4 h-4 mr-2" />
|
|
Show All Tasks
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Dialog
|
|
open={filtersDialogOpen}
|
|
onOpenChange={(open) => {
|
|
if (open) setSearchInput(filters.query)
|
|
setFiltersDialogOpen(open)
|
|
}}
|
|
>
|
|
<DialogContent className="bg-slate-900 border-slate-800 text-white w-[95vw] max-w-4xl max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Master Filters</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-5 py-2">
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium text-slate-200">Scope</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
className={filters.scope === "all" ? "bg-blue-500/10 text-blue-300 border-blue-500/30" : "border-slate-700 text-slate-300 hover:bg-slate-800"}
|
|
onClick={() => updateFilters({ scope: "all" })}
|
|
>
|
|
All Tasks
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
className={filters.scope === "active-sprint" ? "bg-blue-500/10 text-blue-300 border-blue-500/30" : "border-slate-700 text-slate-300 hover:bg-slate-800"}
|
|
onClick={() => updateFilters({ scope: "active-sprint" })}
|
|
>
|
|
Current Sprint
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium text-slate-200">Search</p>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={searchInput}
|
|
onChange={(event) => setSearchInput(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") {
|
|
event.preventDefault()
|
|
applySearch()
|
|
}
|
|
}}
|
|
placeholder="Search title, description, tags, assignee, project, sprint..."
|
|
className="bg-slate-950 border-slate-700 text-slate-100"
|
|
/>
|
|
<Button type="button" variant="outline" className="border-slate-700 text-slate-200 hover:bg-slate-800" onClick={applySearch}>
|
|
Apply
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium text-slate-200">Sort</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{DATE_SORT_FIELDS.map((field) => {
|
|
const isActive = filters.sortBy === field
|
|
return (
|
|
<Button
|
|
key={field}
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
className={isActive ? "bg-teal-500/10 text-teal-300 border-teal-500/30" : "border-slate-700 text-slate-300 hover:bg-slate-800"}
|
|
onClick={() => updateFilters({ sortBy: field })}
|
|
>
|
|
{DATE_SORT_FIELD_LABELS[field]}
|
|
</Button>
|
|
)
|
|
})}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{(["desc", "asc"] as const).map((order) => {
|
|
const isActive = filters.sortOrder === order
|
|
return (
|
|
<Button
|
|
key={order}
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
className={isActive ? "bg-teal-500/10 text-teal-300 border-teal-500/30" : "border-slate-700 text-slate-300 hover:bg-slate-800"}
|
|
onClick={() => updateFilters({ sortOrder: order })}
|
|
>
|
|
{order.toUpperCase()}
|
|
</Button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-sm font-medium text-slate-200 mb-2">Due Date</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{DUE_FILTERS.map((due) => {
|
|
const isActive = filters.due === due
|
|
return (
|
|
<Button
|
|
key={due}
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
className={isActive ? "bg-indigo-500/10 text-indigo-300 border-indigo-500/30" : "border-slate-700 text-slate-300 hover:bg-slate-800"}
|
|
onClick={() => updateFilters({ due })}
|
|
>
|
|
{DUE_FILTER_LABELS[due]}
|
|
</Button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-sm font-medium text-slate-200 mb-2">Status Filters</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{TASK_STATUSES.map((status) => {
|
|
const isActive = statusFilterSet.has(status)
|
|
return (
|
|
<Button
|
|
key={status}
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
className={isActive ? `${getStatusClasses(status)} border` : "border-slate-700 text-slate-300 hover:bg-slate-800"}
|
|
onClick={() => toggleStatusFilter(status)}
|
|
>
|
|
{formatStatus(status)}
|
|
</Button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-sm font-medium text-slate-200 mb-2">Priority Filters</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{TASK_PRIORITIES.map((priority) => {
|
|
const isActive = priorityFilterSet.has(priority)
|
|
return (
|
|
<Button
|
|
key={priority}
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
className={isActive ? `${getPriorityClasses(priority)} border` : "border-slate-700 text-slate-300 hover:bg-slate-800"}
|
|
onClick={() => togglePriorityFilter(priority)}
|
|
>
|
|
{priority.toUpperCase()}
|
|
</Button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-sm font-medium text-slate-200 mb-2">Type Filters</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{TASK_TYPES.map((type) => {
|
|
const isActive = typeFilterSet.has(type)
|
|
return (
|
|
<Button
|
|
key={type}
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
className={isActive ? "bg-cyan-500/10 text-cyan-300 border-cyan-500/30" : "border-slate-700 text-slate-300 hover:bg-slate-800"}
|
|
onClick={() => toggleTypeFilter(type)}
|
|
>
|
|
{formatType(type)}
|
|
</Button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-sm font-medium text-slate-200 mb-2">Project Filters</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{projects.length === 0 ? (
|
|
<p className="text-xs text-slate-500">No projects available.</p>
|
|
) : (
|
|
projects.map((project) => {
|
|
const isActive = projectFilterSet.has(project.id)
|
|
return (
|
|
<Button
|
|
key={project.id}
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
className={isActive ? "bg-emerald-500/10 text-emerald-300 border-emerald-500/30" : "border-slate-700 text-slate-300 hover:bg-slate-800"}
|
|
onClick={() => toggleProjectFilter(project.id)}
|
|
>
|
|
{project.name}
|
|
</Button>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-sm font-medium text-slate-200 mb-2">Sprint Filters</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
className={sprintFilterSet.has(BACKLOG_FILTER_TOKEN) ? "bg-violet-500/10 text-violet-300 border-violet-500/30" : "border-slate-700 text-slate-300 hover:bg-slate-800"}
|
|
onClick={() => toggleSprintFilter(BACKLOG_FILTER_TOKEN)}
|
|
>
|
|
Backlog
|
|
</Button>
|
|
{sprints.map((sprint) => {
|
|
const isActive = sprintFilterSet.has(sprint.id)
|
|
return (
|
|
<Button
|
|
key={sprint.id}
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
className={isActive ? "bg-violet-500/10 text-violet-300 border-violet-500/30" : "border-slate-700 text-slate-300 hover:bg-slate-800"}
|
|
onClick={() => toggleSprintFilter(sprint.id)}
|
|
>
|
|
{sprint.name}
|
|
</Button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-sm font-medium text-slate-200 mb-2">Assignee Filters</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
className={assigneeFilterSet.has(UNASSIGNED_FILTER_TOKEN) ? "bg-amber-500/10 text-amber-300 border-amber-500/30" : "border-slate-700 text-slate-300 hover:bg-slate-800"}
|
|
onClick={() => toggleAssigneeFilter(UNASSIGNED_FILTER_TOKEN)}
|
|
>
|
|
Unassigned
|
|
</Button>
|
|
{assigneeOptions.map((assignee) => {
|
|
const isActive = assigneeFilterSet.has(assignee.id)
|
|
return (
|
|
<Button
|
|
key={assignee.id}
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
className={isActive ? "bg-amber-500/10 text-amber-300 border-amber-500/30" : "border-slate-700 text-slate-300 hover:bg-slate-800"}
|
|
onClick={() => toggleAssigneeFilter(assignee.id)}
|
|
>
|
|
{assignee.name}
|
|
</Button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-sm font-medium text-slate-200 mb-2">Tag Filters</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{tagOptions.length === 0 ? (
|
|
<p className="text-xs text-slate-500">No tags found in tasks.</p>
|
|
) : (
|
|
tagOptions.map((tag) => {
|
|
const isActive = tagFilterSet.has(tag.value)
|
|
return (
|
|
<Button
|
|
key={tag.value}
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
className={isActive ? "bg-pink-500/10 text-pink-300 border-pink-500/30" : "border-slate-700 text-slate-300 hover:bg-slate-800"}
|
|
onClick={() => toggleTagFilter(tag.value)}
|
|
>
|
|
{tag.label}
|
|
</Button>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="border-slate-700 text-slate-200 hover:bg-slate-800"
|
|
onClick={clearFilters}
|
|
>
|
|
Clear All
|
|
</Button>
|
|
<Button type="button" onClick={() => setFiltersDialogOpen(false)}>
|
|
Done
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function TasksPage() {
|
|
return (
|
|
<Suspense
|
|
fallback={
|
|
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center">
|
|
<p className="text-sm text-slate-400">Loading tasks...</p>
|
|
</div>
|
|
}
|
|
>
|
|
<TasksPageContent />
|
|
</Suspense>
|
|
)
|
|
}
|