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:
2026-01-29 09:33:59 +00:00
parent 8c284684c9
commit ef35e508c4
2 changed files with 495 additions and 130 deletions

View 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>
);
}

View File

@@ -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,6 +153,30 @@ export function QueuePage() {
)}
</p>
</div>
<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"
@@ -150,6 +185,7 @@ export function QueuePage() {
+ New
</button>
</div>
</div>
<div className="flex gap-2 items-center">
<div className="flex-1 relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -184,6 +220,7 @@ export function QueuePage() {
<option value="medium">🔵 Medium</option>
<option value="low"> Low</option>
</select>
{viewMode === "list" && (
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
@@ -196,6 +233,7 @@ export function QueuePage() {
<option value="completed"> Completed</option>
<option value="cancelled"> Cancelled</option>
</select>
)}
{activeFilters > 0 && (
<button
onClick={() => { setFilterPriority(""); setFilterStatus(""); }}
@@ -215,6 +253,16 @@ export function QueuePage() {
onCreate={handleCreate}
/>
{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>
@@ -348,6 +396,7 @@ export function QueuePage() {
</section>
)}
</div>
)}
{/* Task Detail Panel */}
{selectedTaskData && (