feat(HAI-002): build board and column components with drag-and-drop
This commit is contained in:
28
packages/dashboard/app/components/Board.tsx
Normal file
28
packages/dashboard/app/components/Board.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Task, TaskDetail, Column as ColumnType } from "@hai/core";
|
||||
import { COLUMNS } from "@hai/core";
|
||||
import { Column } from "./Column";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
interface BoardProps {
|
||||
tasks: Task[];
|
||||
onMoveTask: (id: string, column: ColumnType) => Promise<Task>;
|
||||
onOpenDetail: (task: TaskDetail) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
export function Board({ tasks, onMoveTask, onOpenDetail, addToast }: BoardProps) {
|
||||
return (
|
||||
<main className="board" id="board">
|
||||
{COLUMNS.map((col) => (
|
||||
<Column
|
||||
key={col}
|
||||
column={col}
|
||||
tasks={tasks.filter((t) => t.column === col)}
|
||||
onMoveTask={onMoveTask}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
/>
|
||||
))}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
69
packages/dashboard/app/components/Column.tsx
Normal file
69
packages/dashboard/app/components/Column.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type { Task, TaskDetail, Column as ColumnType } from "@hai/core";
|
||||
import { COLUMN_LABELS, COLUMN_DESCRIPTIONS } from "@hai/core";
|
||||
import { TaskCard } from "./TaskCard";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
interface ColumnProps {
|
||||
column: ColumnType;
|
||||
tasks: Task[];
|
||||
onMoveTask: (id: string, column: ColumnType) => Promise<Task>;
|
||||
onOpenDetail: (task: TaskDetail) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
export function Column({ column, tasks, onMoveTask, onOpenDetail, addToast }: ColumnProps) {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
if (!el.contains(e.relatedTarget as Node)) {
|
||||
setDragOver(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const taskId = e.dataTransfer.getData("text/plain");
|
||||
if (!taskId) return;
|
||||
|
||||
try {
|
||||
await onMoveTask(taskId, column);
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
}
|
||||
}, [column, onMoveTask, addToast]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`column${dragOver ? " drag-over" : ""}`}
|
||||
data-column={column}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="column-header">
|
||||
<div className={`column-dot dot-${column}`} />
|
||||
<h2>{COLUMN_LABELS[column]}</h2>
|
||||
<span className="column-count">{tasks.length}</span>
|
||||
</div>
|
||||
<p className="column-desc">{COLUMN_DESCRIPTIONS[column]}</p>
|
||||
<div className="column-body">
|
||||
{tasks.length === 0 ? (
|
||||
<div className="empty-column">No tasks</div>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} onOpenDetail={onOpenDetail} addToast={addToast} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
packages/dashboard/app/components/TaskCard.tsx
Normal file
54
packages/dashboard/app/components/TaskCard.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { Task, TaskDetail } from "@hai/core";
|
||||
import { fetchTaskDetail } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task;
|
||||
onOpenDetail: (task: TaskDetail) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
export function TaskCard({ task, onOpenDetail, addToast }: TaskCardProps) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent) => {
|
||||
e.dataTransfer.setData("text/plain", task.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
setDragging(true);
|
||||
}, [task.id]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
try {
|
||||
const detail = await fetchTaskDetail(task.id);
|
||||
onOpenDetail(detail);
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
}
|
||||
}, [task.id, onOpenDetail, addToast]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`card${dragging ? " dragging" : ""}`}
|
||||
data-id={task.id}
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className="card-id">{task.id}</span>
|
||||
<div className="card-title">{task.title}</div>
|
||||
{task.dependencies && task.dependencies.length > 0 && (
|
||||
<div className="card-meta">
|
||||
<span className="card-dep-badge">
|
||||
⛓ {task.dependencies.length} dep{task.dependencies.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user