"use client"; import { useState, useMemo } from "react"; import { Task } from "@/types"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { getAgentDisplayName } from "@/lib/agent-utils"; import { Skeleton } from "@/components/ui/skeleton"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { ResponsiveTable, ResponsiveTableCardList, ResponsiveTableCard, ResponsiveTableCardRow, ResponsiveTableCardEmpty, } from "@/components/ui/responsive-table"; import { TaskStatusBadge } from "./task-status-badge"; import { TaskActions } from "./task-actions"; import { GitStatusBadge } from "./git-status-badge"; import Link from "next/link"; import { ArrowUpDown, ArrowUp, ArrowDown, ChevronLeft, ChevronRight, ChevronDown, ChevronRight as ChevronRightIcon, } from "lucide-react"; import { formatDistanceToNow } from "date-fns"; import { cn } from "@/lib/utils"; // Priority colors and labels const priorityColors: Record = { 0: "bg-red-200 text-red-700", 1: "bg-orange-200 text-orange-700", 2: "bg-blue-200 text-blue-700", 3: "bg-gray-200 text-gray-700", }; const priorityLabels: Record = { 0: "P0 - Highest", 1: "P1 - High", 2: "P2 - Medium", 3: "P3 - Low", }; // Sorting types - exported for parent components export type SortField = | "title" | "status" | "team" | "priority" | "assigned_to" | "created_at"; export type SortDirection = "asc" | "desc"; interface SortConfig { field: SortField; direction: SortDirection; } interface TaskTableProps { tasks: Task[] | undefined; isLoading: boolean; // id -> display name maps for the Project / Product column projectNames?: Record; // id -> git_url, used to build clickable branch/PR links on the row badge projectGitUrls?: Record; productNames?: Record; // Controlled sort props (optional for backwards compatibility) sortField?: SortField; sortDirection?: SortDirection; onSortChange?: (field: SortField, direction: SortDirection | null) => void; // Controlled pagination props currentPage?: number; pageSize?: number; onPageChange?: (page: number) => void; onPageSizeChange?: (size: number) => void; // Controlled expanded state expandedIds?: Set; onExpandedChange?: (ids: Set) => void; } const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; // Build a tree structure from flat tasks interface TaskTreeNode { task: Task; children: TaskTreeNode[]; depth: number; } function buildTaskTree(tasks: Task[]): { roots: TaskTreeNode[]; childrenMap: Map; } { const taskMap = new Map(); const childrenMap = new Map(); // Index all tasks tasks.forEach((task) => { taskMap.set(task.id, task); if (task.parent_task_id) { const siblings = childrenMap.get(task.parent_task_id) || []; siblings.push(task); childrenMap.set(task.parent_task_id, siblings); } }); // Build tree nodes for root tasks (no parent or parent not in current list) const roots: TaskTreeNode[] = []; function buildNode(task: Task, depth: number): TaskTreeNode { const children = (childrenMap.get(task.id) || []).map((child) => buildNode(child, depth + 1), ); return { task, children, depth }; } tasks.forEach((task) => { // Root if no parent_task_id or parent is not in the current filtered list if (!task.parent_task_id || !taskMap.has(task.parent_task_id)) { roots.push(buildNode(task, 0)); } }); return { roots, childrenMap }; } // Flatten tree for rendering, respecting expanded state function flattenTree( nodes: TaskTreeNode[], expandedIds: Set, result: TaskTreeNode[] = [], ): TaskTreeNode[] { nodes.forEach((node) => { result.push(node); if (node.children.length > 0 && expandedIds.has(node.task.id)) { flattenTree(node.children, expandedIds, result); } }); return result; } function TaskTableSkeleton() { return ( <> {Array.from({ length: 5 }).map((_, i) => ( ))} ); } function TaskTableEmpty() { return (
No tasks found
); } function TaskCardSkeletons() { return ( {Array.from({ length: 5 }).map((_, i) => ( ))} ); } interface SortableHeaderProps { label: string; field: SortField; sortConfig: SortConfig | null; onSort: (field: SortField) => void; className?: string; } function SortableHeader({ label, field, sortConfig, onSort, className, }: SortableHeaderProps) { const isActive = sortConfig?.field === field; const direction = isActive ? sortConfig.direction : null; return ( ); } export function TaskTable({ tasks, isLoading, projectNames = {}, projectGitUrls = {}, productNames = {}, sortField: controlledSortField, sortDirection: controlledSortDirection, onSortChange, currentPage: controlledCurrentPage, pageSize: controlledPageSize, onPageChange, onPageSizeChange, expandedIds: controlledExpandedIds, onExpandedChange, }: TaskTableProps) { // Internal state (used when not controlled) const [internalSortConfig, setInternalSortConfig] = useState({ field: "created_at", direction: "desc", }); const [internalCurrentPage, setInternalCurrentPage] = useState(1); const [internalPageSize, setInternalPageSize] = useState(25); const [internalExpandedIds, setInternalExpandedIds] = useState>( new Set(), ); // Use controlled or internal state const isControlled = onSortChange !== undefined; const sortConfig: SortConfig | null = useMemo(() => { if (isControlled) { return controlledSortField ? { field: controlledSortField, direction: controlledSortDirection || "desc", } : null; } return internalSortConfig; }, [ isControlled, controlledSortField, controlledSortDirection, internalSortConfig, ]); const currentPage = controlledCurrentPage ?? internalCurrentPage; const pageSize = controlledPageSize ?? internalPageSize; const expandedIds = controlledExpandedIds ?? internalExpandedIds; const handleSort = (field: SortField) => { if (isControlled) { // Calculate new state and notify parent if (sortConfig?.field === field) { if (sortConfig.direction === "asc") { onSortChange(field, "desc"); } else { onSortChange(field, null); // Clear sort } } else { onSortChange(field, "asc"); } } else { setInternalSortConfig((prev) => { if (prev?.field === field) { if (prev.direction === "asc") { return { field, direction: "desc" }; } else { return null; } } return { field, direction: "asc" }; }); setInternalCurrentPage(1); } }; // Build tree and get children map for counting const { roots, childrenMap } = useMemo(() => { if (!tasks) return { roots: [], childrenMap: new Map() }; return buildTaskTree(tasks); }, [tasks]); // Sort root nodes const sortedRoots = useMemo(() => { if (!sortConfig) return roots; const sortNodes = (nodes: TaskTreeNode[]): TaskTreeNode[] => { const sorted = [...nodes].sort((a, b) => { const { field, direction } = sortConfig; const multiplier = direction === "asc" ? 1 : -1; switch (field) { case "title": return multiplier * a.task.title.localeCompare(b.task.title); case "status": return multiplier * a.task.status.localeCompare(b.task.status); case "team": return multiplier * a.task.team.localeCompare(b.task.team); case "priority": return multiplier * (a.task.priority - b.task.priority); case "assigned_to": const aAssigned = a.task.assigned_to || ""; const bAssigned = b.task.assigned_to || ""; return multiplier * aAssigned.localeCompare(bAssigned); case "created_at": return ( multiplier * (new Date(a.task.created_at).getTime() - new Date(b.task.created_at).getTime()) ); default: return 0; } }); // Also sort children recursively return sorted.map((node) => ({ ...node, children: sortNodes(node.children), })); }; return sortNodes(roots); }, [roots, sortConfig]); // Flatten tree based on expanded state const flattenedTasks = useMemo(() => { return flattenTree(sortedRoots, expandedIds); }, [sortedRoots, expandedIds]); // Pagination on flattened list const totalItems = flattenedTasks.length; const totalPages = Math.ceil(totalItems / pageSize); const startIndex = (currentPage - 1) * pageSize; const endIndex = Math.min(startIndex + pageSize, totalItems); const paginatedTasks = flattenedTasks.slice(startIndex, endIndex); const handlePageSizeChange = (value: string) => { const newSize = Number(value); if (onPageSizeChange) { onPageSizeChange(newSize); } else { setInternalPageSize(newSize); setInternalCurrentPage(1); } }; const goToPage = (page: number) => { const newPage = Math.max(1, Math.min(page, totalPages)); if (onPageChange) { onPageChange(newPage); } else { setInternalCurrentPage(newPage); } }; const toggleExpand = (taskId: string) => { const next = new Set(expandedIds); if (next.has(taskId)) { next.delete(taskId); } else { next.add(taskId); } if (onExpandedChange) { onExpandedChange(next); } else { setInternalExpandedIds(next); } }; const expandAll = () => { const allParentIds = new Set(); tasks?.forEach((task) => { if (childrenMap.has(task.id)) { allParentIds.add(task.id); } }); if (onExpandedChange) { onExpandedChange(allParentIds); } else { setInternalExpandedIds(allParentIds); } }; const collapseAll = () => { if (onExpandedChange) { onExpandedChange(new Set()); } else { setInternalExpandedIds(new Set()); } }; // Count how many tasks have children const hasAnyChildren = childrenMap.size > 0; return ( {/* Tree controls */} {hasAnyChildren && !isLoading && (
Tree view:
)} Git Project / Product {isLoading ? ( ) : paginatedTasks.length === 0 ? ( ) : ( paginatedTasks.map((node) => { const task = node.task; const hasChildren = node.children.length > 0; const isExpanded = expandedIds.has(task.id); const childCount = childrenMap.get(task.id)?.length || 0; const handleRowClick = (e: React.MouseEvent) => { // Don't toggle if clicking on interactive elements const target = e.target as HTMLElement; if ( target.closest("a") || target.closest("button") || target.closest('[role="button"]') || target.closest("[data-no-expand]") ) { return; } if (hasChildren) { toggleExpand(task.id); } }; return ( 0 && "bg-muted/20", hasChildren && "cursor-pointer", )} onClick={handleRowClick} >
{hasChildren ? ( ) : ( )}
{task.title} {task.batch_id && !task.parent_task_id && ( MegaTask )} {childCount > 0 && ( {childCount} subtask {childCount !== 1 ? "s" : ""} )}
{task.team.replace(/_/g, " ")} {task.project_id && projectNames[task.project_id] ? ( {projectNames[task.project_id]} ) : task.product_id && productNames[task.product_id] ? ( {productNames[task.product_id]}{" "} (product) ) : ( )} {priorityLabels[task.priority] ?? "P2 - Medium"} {getAgentDisplayName(task.assigned_to)} {formatDistanceToNow(new Date(task.created_at), { addSuffix: true, })}
); }) )}
} cards={ isLoading ? ( ) : paginatedTasks.length === 0 ? ( No tasks found ) : ( {paginatedTasks.map((node) => { const task = node.task; const hasChildren = node.children.length > 0; const isExpanded = expandedIds.has(task.id); const childCount = childrenMap.get(task.id)?.length || 0; return (
{hasChildren && ( )} {task.title}
{(task.batch_id && !task.parent_task_id) || childCount > 0 ? (
{task.batch_id && !task.parent_task_id && ( MegaTask )} {childCount > 0 && ( {childCount} subtask {childCount !== 1 ? "s" : ""} )}
) : null}
{task.team.replace(/_/g, " ")} {task.project_id && projectNames[task.project_id] ? projectNames[task.project_id] : task.product_id && productNames[task.product_id] ? `${productNames[task.product_id]} (product)` : "—"} {priorityLabels[task.priority] ?? "P2 - Medium"} {getAgentDisplayName(task.assigned_to)} {formatDistanceToNow(new Date(task.created_at), { addSuffix: true, })}
); })}
) } /> {/* Pagination Controls */} {!isLoading && tasks && tasks.length > 0 && (
Rows:
{startIndex + 1}-{endIndex} of {totalItems}
)}
); }