Add inline editing, delete/archive buttons, and Won't Do column to aggregator
- Add delete (×) and archive (↑) hover buttons on task cards - Add Won't Do as fourth kanban column (grid now 4 columns) - Implement inline title editing (click to edit, Enter/Escape) - Add status dropdown for quick status changes - Add priority dropdown with color-coded display - Make description editable when card is expanded (Ctrl+Enter to save) - Add POST /api/tasks/archive endpoint (moves to archive folder) - Add DELETE /api/tasks/delete endpoint (permanent deletion) - Enhance PATCH /api/tasks/update to support all fields 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
6421377c86
commit
10ee1094c1
|
|
@ -10,16 +10,15 @@
|
||||||
|
|
||||||
import { type Server, type ServerWebSocket, $ } from "bun";
|
import { type Server, type ServerWebSocket, $ } from "bun";
|
||||||
import { watch, type FSWatcher } from "node:fs";
|
import { watch, type FSWatcher } from "node:fs";
|
||||||
import { readdir, stat, readFile, writeFile, mkdir } from "node:fs/promises";
|
import { readdir, stat, readFile, writeFile, mkdir, unlink } from "node:fs/promises";
|
||||||
import { join, basename, dirname, relative } from "node:path";
|
import { join, basename, dirname } from "node:path";
|
||||||
import { parseTask } from "../markdown/parser.ts";
|
import { parseTask } from "../markdown/parser.ts";
|
||||||
import type { Task } from "../types/index.ts";
|
import type { Task } from "../types/index.ts";
|
||||||
import { sortByTaskId } from "../utils/task-sorting.ts";
|
import { sortByTaskId } from "../utils/task-sorting.ts";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
// @ts-expect-error - Bun file import
|
// @ts-expect-error - Bun file import
|
||||||
import favicon from "../web/favicon.png" with { type: "file" };
|
import favicon from "../web/favicon.png" with { type: "file" };
|
||||||
import { dirname, join as pathJoin } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
// Get the directory of this file
|
// Get the directory of this file
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
|
@ -114,6 +113,12 @@ export class BacklogAggregator {
|
||||||
"/api/tasks/create": {
|
"/api/tasks/create": {
|
||||||
POST: async (req: Request) => this.handleCreateTask(req),
|
POST: async (req: Request) => this.handleCreateTask(req),
|
||||||
},
|
},
|
||||||
|
"/api/tasks/archive": {
|
||||||
|
POST: async (req: Request) => this.handleArchiveTask(req),
|
||||||
|
},
|
||||||
|
"/api/tasks/delete": {
|
||||||
|
DELETE: async (req: Request) => this.handleDeleteTask(req),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fetch: async (req: Request, server: Server) => {
|
fetch: async (req: Request, server: Server) => {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
|
|
@ -137,7 +142,7 @@ export class BacklogAggregator {
|
||||||
|
|
||||||
// Serve the HTML app for root path
|
// Serve the HTML app for root path
|
||||||
if (url.pathname === "/" || url.pathname === "") {
|
if (url.pathname === "/" || url.pathname === "") {
|
||||||
const htmlPath = pathJoin(__dirname, "web", "index.html");
|
const htmlPath = join(__dirname, "web", "index.html");
|
||||||
const htmlFile = Bun.file(htmlPath);
|
const htmlFile = Bun.file(htmlPath);
|
||||||
return new Response(htmlFile, { headers: { "Content-Type": "text/html" } });
|
return new Response(htmlFile, { headers: { "Content-Type": "text/html" } });
|
||||||
}
|
}
|
||||||
|
|
@ -510,14 +515,19 @@ export class BacklogAggregator {
|
||||||
private async handleUpdateTask(req: Request): Promise<Response> {
|
private async handleUpdateTask(req: Request): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { projectPath, taskId, status } = body as {
|
const { projectPath, taskId, status, title, description, priority, labels, assignee } = body as {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
status: string;
|
status?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
priority?: string;
|
||||||
|
labels?: string[];
|
||||||
|
assignee?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!projectPath || !taskId || !status) {
|
if (!projectPath || !taskId) {
|
||||||
return Response.json({ error: "Missing required fields: projectPath, taskId, status" }, { status: 400 });
|
return Response.json({ error: "Missing required fields: projectPath, taskId" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the task
|
// Find the task
|
||||||
|
|
@ -540,20 +550,49 @@ export class BacklogAggregator {
|
||||||
return Response.json({ error: "Task file not found" }, { status: 404 });
|
return Response.json({ error: "Task file not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the status in the frontmatter
|
// Update the fields in the frontmatter
|
||||||
const updatedContent = this.updateTaskStatus(content, status);
|
let updatedContent = content;
|
||||||
|
if (status !== undefined) {
|
||||||
|
updatedContent = this.updateTaskField(updatedContent, "status", status);
|
||||||
|
}
|
||||||
|
if (title !== undefined) {
|
||||||
|
updatedContent = this.updateTaskField(updatedContent, "title", title);
|
||||||
|
}
|
||||||
|
if (priority !== undefined) {
|
||||||
|
updatedContent = this.updateTaskField(updatedContent, "priority", priority);
|
||||||
|
}
|
||||||
|
if (labels !== undefined) {
|
||||||
|
updatedContent = this.updateTaskField(updatedContent, "labels", `[${labels.join(", ")}]`);
|
||||||
|
}
|
||||||
|
if (assignee !== undefined) {
|
||||||
|
updatedContent = this.updateTaskField(updatedContent, "assignee", `[${assignee.join(", ")}]`);
|
||||||
|
}
|
||||||
|
if (description !== undefined) {
|
||||||
|
updatedContent = this.updateTaskDescription(updatedContent, description);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add updated_date
|
||||||
|
updatedContent = this.addUpdatedDate(updatedContent);
|
||||||
|
|
||||||
// Write the updated content back
|
// Write the updated content back
|
||||||
await writeFile(taskFilePath, updatedContent, "utf-8");
|
await writeFile(taskFilePath, updatedContent, "utf-8");
|
||||||
|
|
||||||
console.log(`Task ${taskId} status updated to "${status}" in ${this.projects.get(projectPath)?.name}`);
|
const updatedFields = [];
|
||||||
|
if (status !== undefined) updatedFields.push(`status="${status}"`);
|
||||||
|
if (title !== undefined) updatedFields.push(`title="${title}"`);
|
||||||
|
if (priority !== undefined) updatedFields.push(`priority="${priority}"`);
|
||||||
|
if (labels !== undefined) updatedFields.push(`labels=[${labels.join(", ")}]`);
|
||||||
|
if (assignee !== undefined) updatedFields.push(`assignee=[${assignee.join(", ")}]`);
|
||||||
|
if (description !== undefined) updatedFields.push("description updated");
|
||||||
|
|
||||||
|
console.log(`Task ${taskId} updated (${updatedFields.join(", ")}) in ${this.projects.get(projectPath)?.name}`);
|
||||||
|
|
||||||
// The file watcher will pick up the change and broadcast update
|
// The file watcher will pick up the change and broadcast update
|
||||||
// But we can also force an immediate update
|
// But we can also force an immediate update
|
||||||
await this.loadTask(projectPath, taskFilePath);
|
await this.loadTask(projectPath, taskFilePath);
|
||||||
this.broadcastUpdate();
|
this.broadcastUpdate();
|
||||||
|
|
||||||
return Response.json({ success: true, taskId, status });
|
return Response.json({ success: true, taskId, updated: updatedFields });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating task:", error);
|
console.error("Error updating task:", error);
|
||||||
return Response.json({ error: "Failed to update task" }, { status: 500 });
|
return Response.json({ error: "Failed to update task" }, { status: 500 });
|
||||||
|
|
@ -636,38 +675,167 @@ created_date: '${dateStr}'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleArchiveTask(req: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { projectPath, taskId } = body as {
|
||||||
|
projectPath: string;
|
||||||
|
taskId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!projectPath || !taskId) {
|
||||||
|
return Response.json({ error: "Missing required fields: projectPath, taskId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the task
|
||||||
|
const taskKey = `${projectPath}:${taskId}`;
|
||||||
|
const task = this.tasks.get(taskKey);
|
||||||
|
if (!task) {
|
||||||
|
return Response.json({ error: "Task not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the task file path
|
||||||
|
const tasksDir = join(projectPath, "backlog", "tasks");
|
||||||
|
const archiveDir = join(projectPath, "backlog", "archive", "tasks");
|
||||||
|
const taskFileName = `${taskId}.md`;
|
||||||
|
const taskFilePath = join(tasksDir, taskFileName);
|
||||||
|
const archiveFilePath = join(archiveDir, taskFileName);
|
||||||
|
|
||||||
|
// Ensure archive directory exists
|
||||||
|
try {
|
||||||
|
await mkdir(archiveDir, { recursive: true });
|
||||||
|
} catch {
|
||||||
|
// Directory might already exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the task file
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = await readFile(taskFilePath, "utf-8");
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Task file not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to archive (write to archive, delete from tasks)
|
||||||
|
await writeFile(archiveFilePath, content, "utf-8");
|
||||||
|
await unlink(taskFilePath);
|
||||||
|
|
||||||
|
// Remove from in-memory tasks
|
||||||
|
this.tasks.delete(taskKey);
|
||||||
|
|
||||||
|
console.log(`Task ${taskId} archived in ${this.projects.get(projectPath)?.name}`);
|
||||||
|
|
||||||
|
this.broadcastUpdate();
|
||||||
|
|
||||||
|
return Response.json({ success: true, taskId, archived: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error archiving task:", error);
|
||||||
|
return Response.json({ error: "Failed to archive task" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleDeleteTask(req: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { projectPath, taskId } = body as {
|
||||||
|
projectPath: string;
|
||||||
|
taskId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!projectPath || !taskId) {
|
||||||
|
return Response.json({ error: "Missing required fields: projectPath, taskId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the task
|
||||||
|
const taskKey = `${projectPath}:${taskId}`;
|
||||||
|
const task = this.tasks.get(taskKey);
|
||||||
|
if (!task) {
|
||||||
|
return Response.json({ error: "Task not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the task file path
|
||||||
|
const tasksDir = join(projectPath, "backlog", "tasks");
|
||||||
|
const taskFileName = `${taskId}.md`;
|
||||||
|
const taskFilePath = join(tasksDir, taskFileName);
|
||||||
|
|
||||||
|
// Delete the file
|
||||||
|
try {
|
||||||
|
await unlink(taskFilePath);
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Task file not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from in-memory tasks
|
||||||
|
this.tasks.delete(taskKey);
|
||||||
|
|
||||||
|
console.log(`Task ${taskId} deleted from ${this.projects.get(projectPath)?.name}`);
|
||||||
|
|
||||||
|
this.broadcastUpdate();
|
||||||
|
|
||||||
|
return Response.json({ success: true, taskId, deleted: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting task:", error);
|
||||||
|
return Response.json({ error: "Failed to delete task" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private updateTaskStatus(content: string, newStatus: string): string {
|
private updateTaskStatus(content: string, newStatus: string): string {
|
||||||
// Parse frontmatter and update status
|
return this.updateTaskField(content, "status", newStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateTaskField(content: string, field: string, value: string): string {
|
||||||
|
// Parse frontmatter and update the field
|
||||||
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||||
if (!frontmatterMatch) {
|
if (!frontmatterMatch || !frontmatterMatch[1]) {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
const frontmatter = frontmatterMatch[1];
|
const frontmatter = frontmatterMatch[1];
|
||||||
const updatedFrontmatter = frontmatter.replace(
|
const fieldRegex = new RegExp(`^${field}:\\s*.+$`, "m");
|
||||||
/^status:\s*.+$/m,
|
|
||||||
`status: ${newStatus}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add updated_date
|
let updatedFrontmatter: string;
|
||||||
|
if (fieldRegex.test(frontmatter)) {
|
||||||
|
// Update existing field
|
||||||
|
updatedFrontmatter = frontmatter.replace(fieldRegex, `${field}: ${value}`);
|
||||||
|
} else {
|
||||||
|
// Add new field
|
||||||
|
updatedFrontmatter = frontmatter + `\n${field}: ${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return content.replace(/^---\n[\s\S]*?\n---/, `---\n${updatedFrontmatter}\n---`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateTaskDescription(content: string, newDescription: string): string {
|
||||||
|
// Find and replace the description section
|
||||||
|
const descriptionRegex = /## Description\n\n[\s\S]*?(?=\n## |\n---|\Z)/;
|
||||||
|
const newDescriptionSection = `## Description\n\n${newDescription}\n`;
|
||||||
|
|
||||||
|
if (descriptionRegex.test(content)) {
|
||||||
|
return content.replace(descriptionRegex, newDescriptionSection);
|
||||||
|
}
|
||||||
|
// If no description section, add one after frontmatter
|
||||||
|
return content.replace(/^(---\n[\s\S]*?\n---\n\n?)/, `$1${newDescriptionSection}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private addUpdatedDate(content: string): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||||
|
|
||||||
let finalFrontmatter = updatedFrontmatter;
|
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||||
if (finalFrontmatter.includes("updated_date:")) {
|
if (!frontmatterMatch || !frontmatterMatch[1]) {
|
||||||
finalFrontmatter = finalFrontmatter.replace(
|
return content;
|
||||||
/^updated_date:\s*.+$/m,
|
|
||||||
`updated_date: '${dateStr}'`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Add updated_date before the closing ---
|
|
||||||
finalFrontmatter += `\nupdated_date: '${dateStr}'`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return content.replace(
|
const frontmatter = frontmatterMatch[1];
|
||||||
/^---\n[\s\S]*?\n---/,
|
let updatedFrontmatter: string;
|
||||||
`---\n${finalFrontmatter}\n---`,
|
|
||||||
);
|
if (frontmatter.includes("updated_date:")) {
|
||||||
|
updatedFrontmatter = frontmatter.replace(/^updated_date:\s*.+$/m, `updated_date: '${dateStr}'`);
|
||||||
|
} else {
|
||||||
|
updatedFrontmatter = frontmatter + `\nupdated_date: '${dateStr}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return content.replace(/^---\n[\s\S]*?\n---/, `---\n${updatedFrontmatter}\n---`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getNextTaskId(projectPath: string): Promise<string> {
|
private async getNextTaskId(projectPath: string): Promise<string> {
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,71 @@ function App() {
|
||||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||||
const [filter, setFilter] = useState<string>("");
|
const [filter, setFilter] = useState<string>("");
|
||||||
const [selectedProject, setSelectedProject] = useState<string | null>(null);
|
const [selectedProject, setSelectedProject] = useState<string | null>(null);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const handleArchiveTask = async (task: AggregatedTask) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/tasks/archive", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ projectPath: task.projectPath, taskId: task.id }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || "Failed to archive task");
|
||||||
|
}
|
||||||
|
setActionError(null);
|
||||||
|
} catch (error) {
|
||||||
|
setActionError(error instanceof Error ? error.message : "Failed to archive task");
|
||||||
|
setTimeout(() => setActionError(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTask = async (task: AggregatedTask) => {
|
||||||
|
if (!confirm(`Are you sure you want to delete "${task.title}"? This cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/tasks/delete", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ projectPath: task.projectPath, taskId: task.id }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || "Failed to delete task");
|
||||||
|
}
|
||||||
|
setActionError(null);
|
||||||
|
} catch (error) {
|
||||||
|
setActionError(error instanceof Error ? error.message : "Failed to delete task");
|
||||||
|
setTimeout(() => setActionError(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateTask = async (task: AggregatedTask, updates: Partial<AggregatedTask>) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/tasks/update", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
projectPath: task.projectPath,
|
||||||
|
taskId: task.id,
|
||||||
|
...updates,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || "Failed to update task");
|
||||||
|
}
|
||||||
|
setActionError(null);
|
||||||
|
} catch (error) {
|
||||||
|
setActionError(error instanceof Error ? error.message : "Failed to update task");
|
||||||
|
setTimeout(() => setActionError(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const connectWebSocket = useCallback(() => {
|
const connectWebSocket = useCallback(() => {
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
const ws = new WebSocket(`${protocol}//${window.location.host}`);
|
const ws = new WebSocket(`${protocol}//${window.location.host}`);
|
||||||
|
|
@ -85,7 +147,7 @@ function App() {
|
||||||
}, [connectWebSocket]);
|
}, [connectWebSocket]);
|
||||||
|
|
||||||
// Group tasks by status
|
// Group tasks by status
|
||||||
const statuses = ["To Do", "In Progress", "Done"];
|
const statuses = ["To Do", "In Progress", "Done", "Won't Do"];
|
||||||
const tasksByStatus = statuses.reduce(
|
const tasksByStatus = statuses.reduce(
|
||||||
(acc, status) => {
|
(acc, status) => {
|
||||||
acc[status] = tasks.filter((t) => {
|
acc[status] = tasks.filter((t) => {
|
||||||
|
|
@ -137,6 +199,19 @@ function App() {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||||
|
{actionError && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: "0.25rem 0.75rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
backgroundColor: "#ef4444",
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{actionError}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span style={{ fontSize: "0.875rem", color: "#94a3b8" }}>
|
<span style={{ fontSize: "0.875rem", color: "#94a3b8" }}>
|
||||||
{projects.length} projects | {tasks.length} tasks
|
{projects.length} projects | {tasks.length} tasks
|
||||||
{lastUpdate && ` | Updated ${lastUpdate.toLocaleTimeString()}`}
|
{lastUpdate && ` | Updated ${lastUpdate.toLocaleTimeString()}`}
|
||||||
|
|
@ -216,7 +291,7 @@ function App() {
|
||||||
style={{
|
style={{
|
||||||
padding: "2rem",
|
padding: "2rem",
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(3, 1fr)",
|
gridTemplateColumns: "repeat(4, 1fr)",
|
||||||
gap: "1.5rem",
|
gap: "1.5rem",
|
||||||
minHeight: "calc(100vh - 180px)",
|
minHeight: "calc(100vh - 180px)",
|
||||||
}}
|
}}
|
||||||
|
|
@ -259,7 +334,14 @@ function App() {
|
||||||
{/* Tasks */}
|
{/* Tasks */}
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem", flex: 1, overflowY: "auto" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem", flex: 1, overflowY: "auto" }}>
|
||||||
{tasksByStatus[status]?.map((task) => (
|
{tasksByStatus[status]?.map((task) => (
|
||||||
<TaskCard key={`${task.projectPath}:${task.id}`} task={task} />
|
<TaskCard
|
||||||
|
key={`${task.projectPath}:${task.id}`}
|
||||||
|
task={task}
|
||||||
|
onArchive={handleArchiveTask}
|
||||||
|
onDelete={handleDeleteTask}
|
||||||
|
onUpdate={handleUpdateTask}
|
||||||
|
statuses={statuses}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
{(!tasksByStatus[status] || tasksByStatus[status].length === 0) && (
|
{(!tasksByStatus[status] || tasksByStatus[status].length === 0) && (
|
||||||
<div style={{ color: "#64748b", textAlign: "center", padding: "2rem", fontSize: "0.875rem" }}>
|
<div style={{ color: "#64748b", textAlign: "center", padding: "2rem", fontSize: "0.875rem" }}>
|
||||||
|
|
@ -274,8 +356,19 @@ function App() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TaskCard({ task }: { task: AggregatedTask }) {
|
interface TaskCardProps {
|
||||||
|
task: AggregatedTask;
|
||||||
|
onArchive: (task: AggregatedTask) => void;
|
||||||
|
onDelete: (task: AggregatedTask) => void;
|
||||||
|
onUpdate: (task: AggregatedTask, updates: Partial<AggregatedTask>) => void;
|
||||||
|
statuses: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskCard({ task, onArchive, onDelete, onUpdate, statuses }: TaskCardProps) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [isHovering, setIsHovering] = useState(false);
|
||||||
|
const [editingField, setEditingField] = useState<string | null>(null);
|
||||||
|
const [editValue, setEditValue] = useState("");
|
||||||
|
|
||||||
const priorityColors: Record<string, string> = {
|
const priorityColors: Record<string, string> = {
|
||||||
high: "#ef4444",
|
high: "#ef4444",
|
||||||
|
|
@ -283,49 +376,212 @@ function TaskCard({ task }: { task: AggregatedTask }) {
|
||||||
low: "#22c55e",
|
low: "#22c55e",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const priorities = ["high", "medium", "low"];
|
||||||
|
|
||||||
|
const handleArchiveClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onArchive(task);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(task);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEditing = (field: string, currentValue: string, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditingField(field);
|
||||||
|
setEditValue(currentValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async (e: React.MouseEvent | React.KeyboardEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (editingField && editValue !== task[editingField as keyof AggregatedTask]) {
|
||||||
|
onUpdate(task, { [editingField]: editValue });
|
||||||
|
}
|
||||||
|
setEditingField(null);
|
||||||
|
setEditValue("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = (e: React.MouseEvent | React.KeyboardEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditingField(null);
|
||||||
|
setEditValue("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
saveEdit(e);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
cancelEdit(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onUpdate(task, { status: e.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePriorityChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onUpdate(task, { priority: e.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
backgroundColor: "#1e293b",
|
||||||
|
border: "1px solid #3b82f6",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
padding: "0.25rem 0.5rem",
|
||||||
|
color: "#e2e8f0",
|
||||||
|
fontSize: "inherit",
|
||||||
|
width: "100%",
|
||||||
|
outline: "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectStyle: React.CSSProperties = {
|
||||||
|
backgroundColor: "#1e293b",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
padding: "0.125rem 0.25rem",
|
||||||
|
color: "#e2e8f0",
|
||||||
|
fontSize: "0.625rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
outline: "none",
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => !editingField && setExpanded(!expanded)}
|
||||||
|
onMouseEnter={() => setIsHovering(true)}
|
||||||
|
onMouseLeave={() => setIsHovering(false)}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "#0f172a",
|
backgroundColor: "#0f172a",
|
||||||
borderRadius: "0.375rem",
|
borderRadius: "0.375rem",
|
||||||
padding: "0.75rem",
|
padding: "0.75rem",
|
||||||
cursor: "pointer",
|
cursor: editingField ? "default" : "pointer",
|
||||||
borderLeft: `4px solid ${task.projectColor}`,
|
borderLeft: `4px solid ${task.projectColor}`,
|
||||||
transition: "transform 0.1s, box-shadow 0.1s",
|
transition: "transform 0.1s, box-shadow 0.1s",
|
||||||
}}
|
position: "relative",
|
||||||
onMouseEnter={(e) => {
|
transform: isHovering && !editingField ? "translateY(-1px)" : "translateY(0)",
|
||||||
e.currentTarget.style.transform = "translateY(-1px)";
|
boxShadow: isHovering && !editingField ? "0 4px 6px -1px rgba(0, 0, 0, 0.3)" : "none",
|
||||||
e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.3)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.transform = "translateY(0)";
|
|
||||||
e.currentTarget.style.boxShadow = "none";
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Action buttons - shown on hover */}
|
||||||
|
{isHovering && !editingField && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "-8px",
|
||||||
|
right: "-8px",
|
||||||
|
display: "flex",
|
||||||
|
gap: "4px",
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={handleArchiveClick}
|
||||||
|
title="Archive task"
|
||||||
|
style={{
|
||||||
|
width: "24px",
|
||||||
|
height: "24px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
border: "none",
|
||||||
|
backgroundColor: "#3b82f6",
|
||||||
|
color: "white",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "12px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
boxShadow: "0 2px 4px rgba(0,0,0,0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
title="Delete task"
|
||||||
|
style={{
|
||||||
|
width: "24px",
|
||||||
|
height: "24px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
border: "none",
|
||||||
|
backgroundColor: "#ef4444",
|
||||||
|
color: "white",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
boxShadow: "0 2px 4px rgba(0,0,0,0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Task Header */}
|
{/* Task Header */}
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: "0.5rem" }}>
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: "0.5rem" }}>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ fontSize: "0.875rem", fontWeight: 500, marginBottom: "0.25rem" }}>{task.title}</div>
|
{editingField === "title" ? (
|
||||||
<div style={{ fontSize: "0.75rem", color: "#64748b" }}>
|
<div style={{ display: "flex", gap: "0.25rem", alignItems: "center" }}>
|
||||||
{task.id} | {task.projectName}
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
autoFocus
|
||||||
|
style={{ ...inputStyle, fontSize: "0.875rem", fontWeight: 500 }}
|
||||||
|
/>
|
||||||
|
<button onClick={saveEdit} style={{ ...selectStyle, backgroundColor: "#22c55e" }}>✓</button>
|
||||||
|
<button onClick={cancelEdit} style={{ ...selectStyle, backgroundColor: "#ef4444" }}>✕</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{ fontSize: "0.875rem", fontWeight: 500, marginBottom: "0.25rem", cursor: "text" }}
|
||||||
|
onClick={(e) => startEditing("title", task.title, e)}
|
||||||
|
title="Click to edit title"
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "#64748b", display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
|
<span>{task.id} | {task.projectName}</span>
|
||||||
|
<select
|
||||||
|
value={task.status}
|
||||||
|
onChange={handleStatusChange}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ ...selectStyle, marginLeft: "0.25rem" }}
|
||||||
|
title="Change status"
|
||||||
|
>
|
||||||
|
{statuses.map((s) => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{task.priority && (
|
<select
|
||||||
<span
|
value={task.priority || ""}
|
||||||
style={{
|
onChange={handlePriorityChange}
|
||||||
padding: "0.125rem 0.375rem",
|
onClick={(e) => e.stopPropagation()}
|
||||||
borderRadius: "0.25rem",
|
style={{
|
||||||
fontSize: "0.625rem",
|
...selectStyle,
|
||||||
fontWeight: 500,
|
backgroundColor: task.priority ? priorityColors[task.priority.toLowerCase()] + "22" : "#334155",
|
||||||
textTransform: "uppercase",
|
color: task.priority ? priorityColors[task.priority.toLowerCase()] : "#94a3b8",
|
||||||
backgroundColor: priorityColors[task.priority.toLowerCase()] + "22",
|
fontWeight: 500,
|
||||||
color: priorityColors[task.priority.toLowerCase()],
|
textTransform: "uppercase",
|
||||||
}}
|
}}
|
||||||
>
|
title="Change priority"
|
||||||
{task.priority}
|
>
|
||||||
</span>
|
<option value="">No priority</option>
|
||||||
)}
|
{priorities.map((p) => (
|
||||||
|
<option key={p} value={p}>{p}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Labels */}
|
{/* Labels */}
|
||||||
|
|
@ -349,19 +605,65 @@ function TaskCard({ task }: { task: AggregatedTask }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Expanded Details */}
|
{/* Expanded Details */}
|
||||||
{expanded && task.description && (
|
{expanded && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: "0.75rem",
|
marginTop: "0.75rem",
|
||||||
paddingTop: "0.75rem",
|
paddingTop: "0.75rem",
|
||||||
borderTop: "1px solid #334155",
|
borderTop: "1px solid #334155",
|
||||||
fontSize: "0.8rem",
|
|
||||||
color: "#94a3b8",
|
|
||||||
whiteSpace: "pre-wrap",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{task.description.slice(0, 500)}
|
{editingField === "description" ? (
|
||||||
{task.description.length > 500 && "..."}
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||||
|
<textarea
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") cancelEdit(e);
|
||||||
|
// Allow Enter for newlines in textarea, use Ctrl+Enter to save
|
||||||
|
if (e.key === "Enter" && e.ctrlKey) saveEdit(e);
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
autoFocus
|
||||||
|
rows={5}
|
||||||
|
style={{
|
||||||
|
...inputStyle,
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
resize: "vertical",
|
||||||
|
minHeight: "80px",
|
||||||
|
}}
|
||||||
|
placeholder="Enter description..."
|
||||||
|
/>
|
||||||
|
<div style={{ display: "flex", gap: "0.25rem", justifyContent: "flex-end" }}>
|
||||||
|
<span style={{ fontSize: "0.625rem", color: "#64748b", marginRight: "auto" }}>
|
||||||
|
Ctrl+Enter to save, Escape to cancel
|
||||||
|
</span>
|
||||||
|
<button onClick={saveEdit} style={{ ...selectStyle, backgroundColor: "#22c55e", padding: "0.25rem 0.5rem" }}>Save</button>
|
||||||
|
<button onClick={cancelEdit} style={{ ...selectStyle, backgroundColor: "#ef4444", padding: "0.25rem 0.5rem" }}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
onClick={(e) => startEditing("description", task.description || "", e)}
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "#94a3b8",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
cursor: "text",
|
||||||
|
minHeight: "20px",
|
||||||
|
}}
|
||||||
|
title="Click to edit description"
|
||||||
|
>
|
||||||
|
{task.description ? (
|
||||||
|
<>
|
||||||
|
{task.description.slice(0, 500)}
|
||||||
|
{task.description.length > 500 && "..."}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontStyle: "italic", color: "#64748b" }}>Click to add description...</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,15 @@ interface TaskCardProps {
|
||||||
onEdit: (task: Task) => void;
|
onEdit: (task: Task) => void;
|
||||||
onDragStart?: () => void;
|
onDragStart?: () => void;
|
||||||
onDragEnd?: () => void;
|
onDragEnd?: () => void;
|
||||||
|
onDelete?: (taskId: string) => void;
|
||||||
|
onArchive?: (taskId: string) => void;
|
||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TaskCard: React.FC<TaskCardProps> = ({ task, onEdit, onDragStart, onDragEnd, status }) => {
|
const TaskCard: React.FC<TaskCardProps> = ({ task, onEdit, onDragStart, onDragEnd, onDelete, onArchive, status }) => {
|
||||||
const [isDragging, setIsDragging] = React.useState(false);
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
const [showBranchTooltip, setShowBranchTooltip] = React.useState(false);
|
const [showBranchTooltip, setShowBranchTooltip] = React.useState(false);
|
||||||
|
const [isHovering, setIsHovering] = React.useState(false);
|
||||||
|
|
||||||
// Check if task is from another branch (read-only)
|
// Check if task is from another branch (read-only)
|
||||||
const isFromOtherBranch = Boolean(task.branch);
|
const isFromOtherBranch = Boolean(task.branch);
|
||||||
|
|
@ -74,8 +77,54 @@ const TaskCard: React.FC<TaskCardProps> = ({ task, onEdit, onDragStart, onDragEn
|
||||||
return text.substring(0, maxLength).trim() + '...';
|
return text.substring(0, maxLength).trim() + '...';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onDelete && !isFromOtherBranch) {
|
||||||
|
onDelete(task.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchiveClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onArchive && !isFromOtherBranch) {
|
||||||
|
onArchive(task.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div
|
||||||
|
className="relative"
|
||||||
|
onMouseEnter={() => setIsHovering(true)}
|
||||||
|
onMouseLeave={() => setIsHovering(false)}
|
||||||
|
>
|
||||||
|
{/* Action buttons - shown on hover */}
|
||||||
|
{isHovering && !isFromOtherBranch && !isDragging && (
|
||||||
|
<div className="absolute -top-2 -right-2 z-10 flex gap-1">
|
||||||
|
{onArchive && (
|
||||||
|
<button
|
||||||
|
onClick={handleArchiveClick}
|
||||||
|
className="w-6 h-6 flex items-center justify-center bg-blue-500 hover:bg-blue-600 text-white rounded-full shadow-md transition-colors duration-150 cursor-pointer"
|
||||||
|
title="Archive task"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
className="w-6 h-6 flex items-center justify-center bg-red-500 hover:bg-red-600 text-white rounded-full shadow-md transition-colors duration-150 cursor-pointer"
|
||||||
|
title="Delete task"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Branch tooltip when trying to drag cross-branch task */}
|
{/* Branch tooltip when trying to drag cross-branch task */}
|
||||||
{showBranchTooltip && isFromOtherBranch && (
|
{showBranchTooltip && isFromOtherBranch && (
|
||||||
<div className="absolute -top-12 left-1/2 transform -translate-x-1/2 z-50 px-3 py-2 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-md shadow-lg whitespace-nowrap">
|
<div className="absolute -top-12 left-1/2 transform -translate-x-1/2 z-50 px-3 py-2 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-md shadow-lg whitespace-nowrap">
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ interface TaskColumnProps {
|
||||||
onDragStart?: () => void;
|
onDragStart?: () => void;
|
||||||
onDragEnd?: () => void;
|
onDragEnd?: () => void;
|
||||||
onCleanup?: () => void;
|
onCleanup?: () => void;
|
||||||
|
onDeleteTask?: (taskId: string) => void;
|
||||||
|
onArchiveTask?: (taskId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TaskColumn: React.FC<TaskColumnProps> = ({
|
const TaskColumn: React.FC<TaskColumnProps> = ({
|
||||||
|
|
@ -24,7 +26,9 @@ const TaskColumn: React.FC<TaskColumnProps> = ({
|
||||||
dragSourceStatus,
|
dragSourceStatus,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
onCleanup
|
onCleanup,
|
||||||
|
onDeleteTask,
|
||||||
|
onArchiveTask
|
||||||
}) => {
|
}) => {
|
||||||
const [isDragOver, setIsDragOver] = React.useState(false);
|
const [isDragOver, setIsDragOver] = React.useState(false);
|
||||||
const [draggedTaskId, setDraggedTaskId] = React.useState<string | null>(null);
|
const [draggedTaskId, setDraggedTaskId] = React.useState<string | null>(null);
|
||||||
|
|
@ -176,6 +180,8 @@ const TaskColumn: React.FC<TaskColumnProps> = ({
|
||||||
setDropPosition(null);
|
setDropPosition(null);
|
||||||
onDragEnd?.();
|
onDragEnd?.();
|
||||||
}}
|
}}
|
||||||
|
onDelete={onDeleteTask}
|
||||||
|
onArchive={onArchiveTask}
|
||||||
status={title}
|
status={title}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue