diff --git a/frontend/src/components/KanbanBoard.tsx b/frontend/src/components/KanbanBoard.tsx new file mode 100644 index 0000000..5f15734 --- /dev/null +++ b/frontend/src/components/KanbanBoard.tsx @@ -0,0 +1,316 @@ +import { useState, useCallback, useMemo } from "react"; +import { Link } from "react-router-dom"; +import type { Task, TaskStatus } from "../lib/types"; + +interface KanbanColumn { + status: TaskStatus; + label: string; + icon: string; + color: string; + bgColor: string; + borderColor: string; + dropColor: string; +} + +const COLUMNS: KanbanColumn[] = [ + { + status: "active", + label: "Active", + icon: "⚡", + color: "text-amber-700", + bgColor: "bg-amber-50", + borderColor: "border-amber-200", + dropColor: "bg-amber-100", + }, + { + status: "queued", + label: "Queued", + icon: "📋", + color: "text-blue-700", + bgColor: "bg-blue-50", + borderColor: "border-blue-200", + dropColor: "bg-blue-100", + }, + { + status: "blocked", + label: "Blocked", + icon: "🚫", + color: "text-red-700", + bgColor: "bg-red-50", + borderColor: "border-red-200", + dropColor: "bg-red-100", + }, + { + status: "completed", + label: "Done", + icon: "✅", + color: "text-green-700", + bgColor: "bg-green-50", + borderColor: "border-green-200", + dropColor: "bg-green-100", + }, +]; + +const PRIORITY_COLORS: Record = { + critical: "bg-red-100 text-red-700 border-red-200", + high: "bg-orange-100 text-orange-700 border-orange-200", + medium: "bg-blue-100 text-blue-700 border-blue-200", + low: "bg-gray-100 text-gray-500 border-gray-200", +}; + +interface KanbanCardProps { + task: Task; + projectName?: string; + onDragStart: (taskId: string) => void; + onDragEnd: () => void; + isDragging: boolean; + onClick: () => void; +} + +function KanbanCard({ task, projectName, onDragStart, onDragEnd, isDragging, onClick }: KanbanCardProps) { + const subtasksDone = task.subtasks?.filter((s) => s.completed).length ?? 0; + const subtasksTotal = task.subtasks?.length ?? 0; + + return ( +
{ + e.dataTransfer.setData("text/plain", task.id); + e.dataTransfer.effectAllowed = "move"; + onDragStart(task.id); + }} + onDragEnd={onDragEnd} + onClick={onClick} + className={`bg-white rounded-lg border border-gray-200 p-3 cursor-grab active:cursor-grabbing shadow-sm hover:shadow-md transition-all ${ + isDragging ? "opacity-40 scale-95" : "opacity-100" + }`} + > + {/* Header: number + priority */} +
+ e.stopPropagation()} + className="text-xs font-bold font-mono text-gray-500 hover:text-amber-600 transition" + > + HQ-{task.taskNumber} + + + {task.priority} + +
+ + {/* Title */} +

+ {task.title} +

+ + {/* Meta: project, assignee, subtasks, due */} +
+ {projectName && ( + + 📁 {projectName} + + )} + {task.assigneeName && ( + + 👤 {task.assigneeName} + + )} + {task.dueDate && (() => { + const due = new Date(task.dueDate); + const diffMs = due.getTime() - Date.now(); + const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); + const isOverdue = diffMs < 0; + const label = isOverdue ? "overdue" : diffDays === 0 ? "today" : diffDays === 1 ? "tomorrow" : `${diffDays}d`; + return ( + + 📅 {label} + + ); + })()} +
+ + {/* Subtask progress */} + {subtasksTotal > 0 && ( +
+
+
+
+ {subtasksDone}/{subtasksTotal} +
+ )} +
+ ); +} + +interface KanbanColumnProps { + column: KanbanColumn; + tasks: Task[]; + projectMap: Record; + draggingId: string | null; + onDragStart: (taskId: string) => void; + onDragEnd: () => void; + onDrop: (taskId: string, status: TaskStatus) => void; + onCardClick: (taskId: string) => void; +} + +function KanbanColumnView({ + column, + tasks, + projectMap, + draggingId, + onDragStart, + onDragEnd, + onDrop, + onCardClick, +}: KanbanColumnProps) { + const [dragOver, setDragOver] = useState(false); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setDragOver(true); + }, []); + + const handleDragLeave = useCallback(() => { + setDragOver(false); + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const taskId = e.dataTransfer.getData("text/plain"); + if (taskId) { + onDrop(taskId, column.status); + } + }, + [column.status, onDrop] + ); + + return ( +
+ {/* Column header */} +
+
+ {column.icon} +

{column.label}

+
+ + {tasks.length} + +
+ + {/* Cards */} +
+ {tasks.length === 0 ? ( +
+ {dragOver ? "Drop here" : "No tasks"} +
+ ) : ( + tasks.map((task) => ( + onCardClick(task.id)} + /> + )) + )} + {dragOver && tasks.length > 0 && ( +
+ Drop here +
+ )} +
+
+ ); +} + +interface KanbanBoardProps { + tasks: Task[]; + projectMap: Record; + onStatusChange: (taskId: string, status: TaskStatus) => void; + onCardClick: (taskId: string) => void; + search: string; + filterPriority: string; +} + +export function KanbanBoard({ + tasks, + projectMap, + onStatusChange, + onCardClick, + search, + filterPriority, +}: KanbanBoardProps) { + const [draggingId, setDraggingId] = useState(null); + + const filteredTasks = useMemo(() => { + let filtered = tasks; + if (search.trim()) { + const q = search.toLowerCase(); + filtered = filtered.filter( + (t) => + t.title.toLowerCase().includes(q) || + (t.description && t.description.toLowerCase().includes(q)) || + (t.taskNumber && `hq-${t.taskNumber}`.includes(q)) || + (t.assigneeName && t.assigneeName.toLowerCase().includes(q)) + ); + } + if (filterPriority) { + filtered = filtered.filter((t) => t.priority === filterPriority); + } + return filtered; + }, [tasks, search, filterPriority]); + + const tasksByStatus = useMemo(() => { + const map: Record = {}; + for (const col of COLUMNS) { + map[col.status] = filteredTasks.filter((t) => t.status === col.status); + } + return map; + }, [filteredTasks]); + + const handleDrop = useCallback( + (taskId: string, newStatus: TaskStatus) => { + const task = tasks.find((t) => t.id === taskId); + if (!task || task.status === newStatus) return; + onStatusChange(taskId, newStatus); + }, + [tasks, onStatusChange] + ); + + return ( +
+ {COLUMNS.map((column) => ( + setDraggingId(null)} + onDrop={handleDrop} + onCardClick={onCardClick} + /> + ))} +
+ ); +} diff --git a/frontend/src/pages/QueuePage.tsx b/frontend/src/pages/QueuePage.tsx index 5280167..ed826c2 100644 --- a/frontend/src/pages/QueuePage.tsx +++ b/frontend/src/pages/QueuePage.tsx @@ -4,10 +4,13 @@ import { useCurrentUser } from "../hooks/useCurrentUser"; import { TaskCard } from "../components/TaskCard"; import { TaskDetailPanel } from "../components/TaskDetailPanel"; import { CreateTaskModal } from "../components/CreateTaskModal"; +import { KanbanBoard } from "../components/KanbanBoard"; import { useToast } from "../components/Toast"; import { updateTask, reorderTasks, createTask, fetchProjects } from "../lib/api"; import type { TaskStatus, Project } from "../lib/types"; +type ViewMode = "list" | "board"; + export function QueuePage() { const { tasks, loading, error, refresh } = useTasks(5000); const { isAuthenticated } = useCurrentUser(); @@ -17,9 +20,17 @@ export function QueuePage() { const [search, setSearch] = useState(""); const [filterPriority, setFilterPriority] = useState(""); const [filterStatus, setFilterStatus] = useState(""); + const [viewMode, setViewMode] = useState(() => { + return (localStorage.getItem("hammer-queue-view") as ViewMode) || "list"; + }); const [projects, setProjects] = useState([]); const { toast } = useToast(); + // Persist view mode + useEffect(() => { + localStorage.setItem("hammer-queue-view", viewMode); + }, [viewMode]); + // Load projects for name display useEffect(() => { fetchProjects().then(setProjects).catch(() => {}); @@ -142,13 +153,38 @@ export function QueuePage() { )}

- +
+ {/* View toggle */} +
+ + +
+ +
@@ -184,18 +220,20 @@ export function QueuePage() { - + {viewMode === "list" && ( + + )} {activeFilters > 0 && ( - {showCompleted && ( + )} + + )} + + {/* Blocked */} + {showSection("blocked") && blockedTasks.length > 0 && ( +
+

+ 🚫 Blocked ({blockedTasks.length}) +

+
+ {blockedTasks.map((task) => ( + setSelectedTask(task.id)} + projectName={task.projectId ? projectMap[task.projectId] : undefined} + /> + ))} +
+
+ )} + + {/* Queue */} + {showSection("queued") && ( +
+

+ 📋 Queue ({queuedTasks.length}) +

+ {queuedTasks.length === 0 ? ( +
+ Queue is empty +
+ ) : ( +
+ {queuedTasks.map((task, i) => ( + handleMoveUp(i)} + onMoveDown={() => handleMoveDown(i)} + isFirst={i === 0} + isLast={i === queuedTasks.length - 1} + onClick={() => setSelectedTask(task.id)} + projectName={task.projectId ? projectMap[task.projectId] : undefined} + /> + ))} +
+ )} +
+ )} + + {/* Completed */} + {(showSection("completed") || showSection("cancelled")) && ( +
+ {filterStatus ? ( + <> +

+ {filterStatus === "completed" ? "✅" : "❌"} {filterStatus.charAt(0).toUpperCase() + filterStatus.slice(1)} ({completedTasks.length}) +

{completedTasks.map((task) => ( ))}
- )} - - )} -
- )} -
+ + ) : ( + <> + + {showCompleted && ( +
+ {completedTasks.map((task) => ( + setSelectedTask(task.id)} + projectName={task.projectId ? projectMap[task.projectId] : undefined} + /> + ))} +
+ )} + + )} + + )} +
+ )} {/* Task Detail Panel */} {selectedTaskData && (