feat: kanban board view with drag-and-drop status changes
- New KanbanBoard component with 4 columns: Active, Queued, Blocked, Done - HTML5 drag-and-drop to move tasks between status columns - Visual drop zone highlighting and drag state - Task cards show priority, project, assignee, due date, subtask progress - View toggle (list/board) in Queue page header, persisted to localStorage - Status filter hidden in board mode (columns serve as visual filter) - Cards link to task detail pages, click opens detail panel
This commit is contained in:
316
frontend/src/components/KanbanBoard.tsx
Normal file
316
frontend/src/components/KanbanBoard.tsx
Normal file
@@ -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<string, string> = {
|
||||
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 (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
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 */}
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<Link
|
||||
to={`/task/HQ-${task.taskNumber}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-xs font-bold font-mono text-gray-500 hover:text-amber-600 transition"
|
||||
>
|
||||
HQ-{task.taskNumber}
|
||||
</Link>
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full border ${PRIORITY_COLORS[task.priority] || PRIORITY_COLORS.medium}`}>
|
||||
{task.priority}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h4 className="text-sm font-medium text-gray-900 leading-snug mb-1.5 line-clamp-2">
|
||||
{task.title}
|
||||
</h4>
|
||||
|
||||
{/* Meta: project, assignee, subtasks, due */}
|
||||
<div className="flex flex-wrap gap-1.5 items-center">
|
||||
{projectName && (
|
||||
<span className="text-[10px] text-sky-600 bg-sky-50 px-1.5 py-0.5 rounded-full">
|
||||
📁 {projectName}
|
||||
</span>
|
||||
)}
|
||||
{task.assigneeName && (
|
||||
<span className="text-[10px] text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded-full">
|
||||
👤 {task.assigneeName}
|
||||
</span>
|
||||
)}
|
||||
{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 (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${
|
||||
isOverdue ? "bg-red-100 text-red-600" : diffDays <= 2 ? "bg-amber-100 text-amber-600" : "bg-gray-100 text-gray-500"
|
||||
}`}>
|
||||
📅 {label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Subtask progress */}
|
||||
{subtasksTotal > 0 && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="flex-1 bg-gray-100 rounded-full h-1">
|
||||
<div
|
||||
className="bg-green-500 h-1 rounded-full transition-all"
|
||||
style={{ width: `${(subtasksDone / subtasksTotal) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-400">{subtasksDone}/{subtasksTotal}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface KanbanColumnProps {
|
||||
column: KanbanColumn;
|
||||
tasks: Task[];
|
||||
projectMap: Record<string, string>;
|
||||
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 (
|
||||
<div
|
||||
className={`flex flex-col rounded-xl border ${column.borderColor} ${
|
||||
dragOver ? column.dropColor : column.bgColor
|
||||
} transition-colors min-h-[200px]`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Column header */}
|
||||
<div className={`px-3 py-2.5 border-b ${column.borderColor} flex items-center justify-between`}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm">{column.icon}</span>
|
||||
<h3 className={`text-sm font-semibold ${column.color}`}>{column.label}</h3>
|
||||
</div>
|
||||
<span className={`text-xs font-medium ${column.color} opacity-70`}>
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="flex-1 p-2 space-y-2 overflow-y-auto max-h-[calc(100vh-16rem)]">
|
||||
{tasks.length === 0 ? (
|
||||
<div className="text-xs text-gray-400 text-center py-6 italic">
|
||||
{dragOver ? "Drop here" : "No tasks"}
|
||||
</div>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<KanbanCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
projectName={task.projectId ? projectMap[task.projectId] : undefined}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
isDragging={draggingId === task.id}
|
||||
onClick={() => onCardClick(task.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{dragOver && tasks.length > 0 && (
|
||||
<div className={`border-2 border-dashed ${column.borderColor} rounded-lg p-4 text-center text-xs ${column.color} opacity-50`}>
|
||||
Drop here
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface KanbanBoardProps {
|
||||
tasks: Task[];
|
||||
projectMap: Record<string, string>;
|
||||
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<string | null>(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<string, Task[]> = {};
|
||||
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 (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 px-4 sm:px-6 py-4">
|
||||
{COLUMNS.map((column) => (
|
||||
<KanbanColumnView
|
||||
key={column.status}
|
||||
column={column}
|
||||
tasks={tasksByStatus[column.status] || []}
|
||||
projectMap={projectMap}
|
||||
draggingId={draggingId}
|
||||
onDragStart={setDraggingId}
|
||||
onDragEnd={() => setDraggingId(null)}
|
||||
onDrop={handleDrop}
|
||||
onCardClick={onCardClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string>("");
|
||||
const [filterStatus, setFilterStatus] = useState<string>("");
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(() => {
|
||||
return (localStorage.getItem("hammer-queue-view") as ViewMode) || "list";
|
||||
});
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
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() {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="text-sm bg-amber-500 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-amber-600 transition font-medium"
|
||||
title="Ctrl+N"
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View toggle */}
|
||||
<div className="hidden sm:flex items-center bg-gray-100 rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={() => setViewMode("list")}
|
||||
className={`p-1.5 rounded-md transition ${viewMode === "list" ? "bg-white shadow-sm text-amber-600" : "text-gray-400 hover:text-gray-600"}`}
|
||||
title="List view"
|
||||
aria-label="List view"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("board")}
|
||||
className={`p-1.5 rounded-md transition ${viewMode === "board" ? "bg-white shadow-sm text-amber-600" : "text-gray-400 hover:text-gray-600"}`}
|
||||
title="Board view"
|
||||
aria-label="Board view"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="text-sm bg-amber-500 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-amber-600 transition font-medium"
|
||||
title="Ctrl+N"
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex-1 relative">
|
||||
@@ -184,18 +220,20 @@ export function QueuePage() {
|
||||
<option value="medium">🔵 Medium</option>
|
||||
<option value="low">⚪ Low</option>
|
||||
</select>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-200 bg-white"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="active">⚡ Active</option>
|
||||
<option value="queued">📋 Queued</option>
|
||||
<option value="blocked">🚫 Blocked</option>
|
||||
<option value="completed">✅ Completed</option>
|
||||
<option value="cancelled">❌ Cancelled</option>
|
||||
</select>
|
||||
{viewMode === "list" && (
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-200 bg-white"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="active">⚡ Active</option>
|
||||
<option value="queued">📋 Queued</option>
|
||||
<option value="blocked">🚫 Blocked</option>
|
||||
<option value="completed">✅ Completed</option>
|
||||
<option value="cancelled">❌ Cancelled</option>
|
||||
</select>
|
||||
)}
|
||||
{activeFilters > 0 && (
|
||||
<button
|
||||
onClick={() => { setFilterPriority(""); setFilterStatus(""); }}
|
||||
@@ -215,122 +253,111 @@ export function QueuePage() {
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-4 sm:py-6 space-y-5 sm:space-y-6">
|
||||
{loading && (
|
||||
<div className="text-center text-gray-400 py-12">Loading tasks...</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg p-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Task */}
|
||||
{showSection("active") && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
⚡ Currently Working On
|
||||
</h2>
|
||||
{activeTasks.length === 0 ? (
|
||||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-8 text-center text-gray-400">
|
||||
No active task — Hammer is idle
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{activeTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onStatusChange={handleStatusChange}
|
||||
isActive
|
||||
onClick={() => setSelectedTask(task.id)}
|
||||
projectName={task.projectId ? projectMap[task.projectId] : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Blocked */}
|
||||
{showSection("blocked") && blockedTasks.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
🚫 Blocked ({blockedTasks.length})
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{blockedTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onStatusChange={handleStatusChange}
|
||||
onClick={() => setSelectedTask(task.id)}
|
||||
projectName={task.projectId ? projectMap[task.projectId] : undefined}
|
||||
/>
|
||||
))}
|
||||
{viewMode === "board" ? (
|
||||
<KanbanBoard
|
||||
tasks={tasks}
|
||||
projectMap={projectMap}
|
||||
onStatusChange={handleStatusChange}
|
||||
onCardClick={(id) => setSelectedTask(id)}
|
||||
search={search}
|
||||
filterPriority={filterPriority}
|
||||
/>
|
||||
) : (
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-4 sm:py-6 space-y-5 sm:space-y-6">
|
||||
{loading && (
|
||||
<div className="text-center text-gray-400 py-12">Loading tasks...</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg p-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Queue */}
|
||||
{showSection("queued") && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
📋 Queue ({queuedTasks.length})
|
||||
</h2>
|
||||
{queuedTasks.length === 0 ? (
|
||||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center text-gray-400">
|
||||
Queue is empty
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{queuedTasks.map((task, i) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onStatusChange={handleStatusChange}
|
||||
onMoveUp={() => handleMoveUp(i)}
|
||||
onMoveDown={() => handleMoveDown(i)}
|
||||
isFirst={i === 0}
|
||||
isLast={i === queuedTasks.length - 1}
|
||||
onClick={() => setSelectedTask(task.id)}
|
||||
projectName={task.projectId ? projectMap[task.projectId] : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Completed */}
|
||||
{(showSection("completed") || showSection("cancelled")) && (
|
||||
<section>
|
||||
{filterStatus ? (
|
||||
<>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
{filterStatus === "completed" ? "✅" : "❌"} {filterStatus.charAt(0).toUpperCase() + filterStatus.slice(1)} ({completedTasks.length})
|
||||
</h2>
|
||||
<div className="space-y-2 opacity-60">
|
||||
{completedTasks.map((task) => (
|
||||
{/* Active Task */}
|
||||
{showSection("active") && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
⚡ Currently Working On
|
||||
</h2>
|
||||
{activeTasks.length === 0 ? (
|
||||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-8 text-center text-gray-400">
|
||||
No active task — Hammer is idle
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{activeTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onStatusChange={handleStatusChange}
|
||||
isActive
|
||||
onClick={() => setSelectedTask(task.id)}
|
||||
projectName={task.projectId ? projectMap[task.projectId] : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowCompleted(!showCompleted)}
|
||||
className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3 flex items-center gap-1 hover:text-gray-700"
|
||||
>
|
||||
{showCompleted ? "▾" : "▸"} Completed / Cancelled ({completedTasks.length})
|
||||
</button>
|
||||
{showCompleted && (
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Blocked */}
|
||||
{showSection("blocked") && blockedTasks.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
🚫 Blocked ({blockedTasks.length})
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{blockedTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onStatusChange={handleStatusChange}
|
||||
onClick={() => setSelectedTask(task.id)}
|
||||
projectName={task.projectId ? projectMap[task.projectId] : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Queue */}
|
||||
{showSection("queued") && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
📋 Queue ({queuedTasks.length})
|
||||
</h2>
|
||||
{queuedTasks.length === 0 ? (
|
||||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center text-gray-400">
|
||||
Queue is empty
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{queuedTasks.map((task, i) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onStatusChange={handleStatusChange}
|
||||
onMoveUp={() => handleMoveUp(i)}
|
||||
onMoveDown={() => handleMoveDown(i)}
|
||||
isFirst={i === 0}
|
||||
isLast={i === queuedTasks.length - 1}
|
||||
onClick={() => setSelectedTask(task.id)}
|
||||
projectName={task.projectId ? projectMap[task.projectId] : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Completed */}
|
||||
{(showSection("completed") || showSection("cancelled")) && (
|
||||
<section>
|
||||
{filterStatus ? (
|
||||
<>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
{filterStatus === "completed" ? "✅" : "❌"} {filterStatus.charAt(0).toUpperCase() + filterStatus.slice(1)} ({completedTasks.length})
|
||||
</h2>
|
||||
<div className="space-y-2 opacity-60">
|
||||
{completedTasks.map((task) => (
|
||||
<TaskCard
|
||||
@@ -342,12 +369,34 @@ export function QueuePage() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowCompleted(!showCompleted)}
|
||||
className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3 flex items-center gap-1 hover:text-gray-700"
|
||||
>
|
||||
{showCompleted ? "▾" : "▸"} Completed / Cancelled ({completedTasks.length})
|
||||
</button>
|
||||
{showCompleted && (
|
||||
<div className="space-y-2 opacity-60">
|
||||
{completedTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onStatusChange={handleStatusChange}
|
||||
onClick={() => setSelectedTask(task.id)}
|
||||
projectName={task.projectId ? projectMap[task.projectId] : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task Detail Panel */}
|
||||
{selectedTaskData && (
|
||||
|
||||
Reference in New Issue
Block a user