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 { TaskCard } from "../components/TaskCard";
|
||||||
import { TaskDetailPanel } from "../components/TaskDetailPanel";
|
import { TaskDetailPanel } from "../components/TaskDetailPanel";
|
||||||
import { CreateTaskModal } from "../components/CreateTaskModal";
|
import { CreateTaskModal } from "../components/CreateTaskModal";
|
||||||
|
import { KanbanBoard } from "../components/KanbanBoard";
|
||||||
import { useToast } from "../components/Toast";
|
import { useToast } from "../components/Toast";
|
||||||
import { updateTask, reorderTasks, createTask, fetchProjects } from "../lib/api";
|
import { updateTask, reorderTasks, createTask, fetchProjects } from "../lib/api";
|
||||||
import type { TaskStatus, Project } from "../lib/types";
|
import type { TaskStatus, Project } from "../lib/types";
|
||||||
|
|
||||||
|
type ViewMode = "list" | "board";
|
||||||
|
|
||||||
export function QueuePage() {
|
export function QueuePage() {
|
||||||
const { tasks, loading, error, refresh } = useTasks(5000);
|
const { tasks, loading, error, refresh } = useTasks(5000);
|
||||||
const { isAuthenticated } = useCurrentUser();
|
const { isAuthenticated } = useCurrentUser();
|
||||||
@@ -17,9 +20,17 @@ export function QueuePage() {
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [filterPriority, setFilterPriority] = useState<string>("");
|
const [filterPriority, setFilterPriority] = useState<string>("");
|
||||||
const [filterStatus, setFilterStatus] = 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 [projects, setProjects] = useState<Project[]>([]);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Persist view mode
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem("hammer-queue-view", viewMode);
|
||||||
|
}, [viewMode]);
|
||||||
|
|
||||||
// Load projects for name display
|
// Load projects for name display
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchProjects().then(setProjects).catch(() => {});
|
fetchProjects().then(setProjects).catch(() => {});
|
||||||
@@ -142,13 +153,38 @@ export function QueuePage() {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => setShowCreate(true)}
|
{/* View toggle */}
|
||||||
className="text-sm bg-amber-500 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-amber-600 transition font-medium"
|
<div className="hidden sm:flex items-center bg-gray-100 rounded-lg p-0.5">
|
||||||
title="Ctrl+N"
|
<button
|
||||||
>
|
onClick={() => setViewMode("list")}
|
||||||
+ New
|
className={`p-1.5 rounded-md transition ${viewMode === "list" ? "bg-white shadow-sm text-amber-600" : "text-gray-400 hover:text-gray-600"}`}
|
||||||
</button>
|
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>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
@@ -184,18 +220,20 @@ export function QueuePage() {
|
|||||||
<option value="medium">🔵 Medium</option>
|
<option value="medium">🔵 Medium</option>
|
||||||
<option value="low">⚪ Low</option>
|
<option value="low">⚪ Low</option>
|
||||||
</select>
|
</select>
|
||||||
<select
|
{viewMode === "list" && (
|
||||||
value={filterStatus}
|
<select
|
||||||
onChange={(e) => setFilterStatus(e.target.value)}
|
value={filterStatus}
|
||||||
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"
|
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="">All statuses</option>
|
||||||
<option value="queued">📋 Queued</option>
|
<option value="active">⚡ Active</option>
|
||||||
<option value="blocked">🚫 Blocked</option>
|
<option value="queued">📋 Queued</option>
|
||||||
<option value="completed">✅ Completed</option>
|
<option value="blocked">🚫 Blocked</option>
|
||||||
<option value="cancelled">❌ Cancelled</option>
|
<option value="completed">✅ Completed</option>
|
||||||
</select>
|
<option value="cancelled">❌ Cancelled</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
{activeFilters > 0 && (
|
{activeFilters > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setFilterPriority(""); setFilterStatus(""); }}
|
onClick={() => { setFilterPriority(""); setFilterStatus(""); }}
|
||||||
@@ -215,122 +253,111 @@ export function QueuePage() {
|
|||||||
onCreate={handleCreate}
|
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">
|
{viewMode === "board" ? (
|
||||||
{loading && (
|
<KanbanBoard
|
||||||
<div className="text-center text-gray-400 py-12">Loading tasks...</div>
|
tasks={tasks}
|
||||||
)}
|
projectMap={projectMap}
|
||||||
{error && (
|
onStatusChange={handleStatusChange}
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg p-3 text-sm">
|
onCardClick={(id) => setSelectedTask(id)}
|
||||||
{error}
|
search={search}
|
||||||
</div>
|
filterPriority={filterPriority}
|
||||||
)}
|
/>
|
||||||
|
) : (
|
||||||
{/* Active Task */}
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-4 sm:py-6 space-y-5 sm:space-y-6">
|
||||||
{showSection("active") && (
|
{loading && (
|
||||||
<section>
|
<div className="text-center text-gray-400 py-12">Loading tasks...</div>
|
||||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
)}
|
||||||
⚡ Currently Working On
|
{error && (
|
||||||
</h2>
|
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg p-3 text-sm">
|
||||||
{activeTasks.length === 0 ? (
|
{error}
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Queue */}
|
{/* Active Task */}
|
||||||
{showSection("queued") && (
|
{showSection("active") && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||||
📋 Queue ({queuedTasks.length})
|
⚡ Currently Working On
|
||||||
</h2>
|
</h2>
|
||||||
{queuedTasks.length === 0 ? (
|
{activeTasks.length === 0 ? (
|
||||||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center text-gray-400">
|
<div className="border-2 border-dashed border-gray-200 rounded-lg p-8 text-center text-gray-400">
|
||||||
Queue is empty
|
No active task — Hammer is idle
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
{queuedTasks.map((task, i) => (
|
{activeTasks.map((task) => (
|
||||||
<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
|
<TaskCard
|
||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
onStatusChange={handleStatusChange}
|
onStatusChange={handleStatusChange}
|
||||||
|
isActive
|
||||||
onClick={() => setSelectedTask(task.id)}
|
onClick={() => setSelectedTask(task.id)}
|
||||||
projectName={task.projectId ? projectMap[task.projectId] : undefined}
|
projectName={task.projectId ? projectMap[task.projectId] : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
) : (
|
</section>
|
||||||
<>
|
)}
|
||||||
<button
|
|
||||||
onClick={() => setShowCompleted(!showCompleted)}
|
{/* Blocked */}
|
||||||
className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3 flex items-center gap-1 hover:text-gray-700"
|
{showSection("blocked") && blockedTasks.length > 0 && (
|
||||||
>
|
<section>
|
||||||
{showCompleted ? "▾" : "▸"} Completed / Cancelled ({completedTasks.length})
|
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||||
</button>
|
🚫 Blocked ({blockedTasks.length})
|
||||||
{showCompleted && (
|
</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">
|
<div className="space-y-2 opacity-60">
|
||||||
{completedTasks.map((task) => (
|
{completedTasks.map((task) => (
|
||||||
<TaskCard
|
<TaskCard
|
||||||
@@ -342,12 +369,34 @@ export function QueuePage() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</>
|
||||||
</>
|
) : (
|
||||||
)}
|
<>
|
||||||
</section>
|
<button
|
||||||
)}
|
onClick={() => setShowCompleted(!showCompleted)}
|
||||||
</div>
|
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 */}
|
{/* Task Detail Panel */}
|
||||||
{selectedTaskData && (
|
{selectedTaskData && (
|
||||||
|
|||||||
Reference in New Issue
Block a user