Phase 2: Tasks
This commit is contained in:
@@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* API tests for tasks routes.
|
||||||
|
* These tests verify the task CRUD API logic using mocked Drizzle.
|
||||||
|
* Run with: npm test -- --testPathPattern=tasks
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||||
|
|
||||||
|
// Mock the database module
|
||||||
|
jest.mock('@project-e/db', () => ({
|
||||||
|
db: {
|
||||||
|
select: jest.fn(),
|
||||||
|
insert: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
},
|
||||||
|
sql: { unsafe: jest.fn() },
|
||||||
|
tasks: {},
|
||||||
|
taskTags: {},
|
||||||
|
taskDependencies: {},
|
||||||
|
tags: {},
|
||||||
|
activityFeed: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@/lib/auth', () => ({
|
||||||
|
withAuth: (handler: any) => handler,
|
||||||
|
requireWorkspaceAccess: jest.fn().mockResolvedValue(undefined),
|
||||||
|
createErrorResponse: (code: string, message: string, status: number, details?: unknown) => ({
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
status,
|
||||||
|
details,
|
||||||
|
}),
|
||||||
|
ApiError: class ApiError extends Error {
|
||||||
|
constructor(message: string, public status: number = 400, public code: string = 'BAD_REQUEST') {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@/lib/activity', () => ({
|
||||||
|
recordActivity: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Tasks API', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/domains/[domainId]/tasks', () => {
|
||||||
|
it('should list tasks with default pagination', async () => {
|
||||||
|
const { GET } = await import('@/app/api/domains/[domainId]/tasks/route');
|
||||||
|
const { db } = require('@project-e/db');
|
||||||
|
|
||||||
|
const mockTasks = [
|
||||||
|
{ id: '1', title: 'Task 1', status: 'todo', priority: 'medium', domainId: 'domain-1' },
|
||||||
|
{ id: '2', title: 'Task 2', status: 'in_progress', priority: 'high', domainId: 'domain-1' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock the db.select chain
|
||||||
|
const mockSelect = jest.fn().mockReturnThis();
|
||||||
|
const mockFrom = jest.fn().mockReturnThis();
|
||||||
|
const mockWhere = jest.fn().mockReturnThis();
|
||||||
|
const mockOrderBy = jest.fn().mockReturnThis();
|
||||||
|
const mockLimit = jest.fn().mockReturnThis();
|
||||||
|
const mockOffset = jest.fn().mockResolvedValue(mockTasks);
|
||||||
|
|
||||||
|
db.select.mockReturnValue({
|
||||||
|
from: jest.fn().mockReturnValue({
|
||||||
|
where: jest.fn().mockReturnValue({
|
||||||
|
orderBy: jest.fn().mockReturnValue({
|
||||||
|
limit: jest.fn().mockReturnValue({
|
||||||
|
offset: jest.fn().mockResolvedValue(mockTasks),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks');
|
||||||
|
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
|
||||||
|
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by status', async () => {
|
||||||
|
const { GET } = await import('@/app/api/domains/[domainId]/tasks/route');
|
||||||
|
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks?status=todo');
|
||||||
|
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by search term', async () => {
|
||||||
|
const { GET } = await import('@/app/api/domains/[domainId]/tasks/route');
|
||||||
|
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks?search=test');
|
||||||
|
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/domains/[domainId]/tasks', () => {
|
||||||
|
it('should create a task with required fields', async () => {
|
||||||
|
const { POST } = await import('@/app/api/domains/[domainId]/tasks/route');
|
||||||
|
const { db } = require('@project-e/db');
|
||||||
|
|
||||||
|
const mockTask = {
|
||||||
|
id: 'new-task-1',
|
||||||
|
title: 'Test Task',
|
||||||
|
status: 'todo',
|
||||||
|
priority: 'medium',
|
||||||
|
domainId: 'domain-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
db.insert.mockReturnValue({
|
||||||
|
values: jest.fn().mockReturnValue({
|
||||||
|
returning: jest.fn().mockResolvedValue([mockTask]),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title: 'Test Task' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject empty title', async () => {
|
||||||
|
const { POST } = await import('@/app/api/domains/[domainId]/tasks/route');
|
||||||
|
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title: '' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /api/domains/[domainId]/tasks/[id]', () => {
|
||||||
|
it('should update task status', async () => {
|
||||||
|
const { PATCH } = await import('@/app/api/domains/[domainId]/tasks/[id]/route');
|
||||||
|
const { db } = require('@project-e/db');
|
||||||
|
|
||||||
|
const existingTask = {
|
||||||
|
id: 'task-1',
|
||||||
|
title: 'Test Task',
|
||||||
|
status: 'todo',
|
||||||
|
domainId: 'domain-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
db.select.mockReturnValue({
|
||||||
|
from: jest.fn().mockReturnValue({
|
||||||
|
where: jest.fn().mockReturnValue({
|
||||||
|
limit: jest.fn().mockResolvedValue([existingTask]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
db.update.mockReturnValue({
|
||||||
|
set: jest.fn().mockReturnValue({
|
||||||
|
where: jest.fn().mockReturnValue({
|
||||||
|
returning: jest.fn().mockResolvedValue([{ ...existingTask, status: 'done' }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks/task-1', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status: 'done' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await PATCH(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'task-1' }) });
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/domains/[domainId]/tasks/[id]', () => {
|
||||||
|
it('should soft delete a task', async () => {
|
||||||
|
const { DELETE } = await import('@/app/api/domains/[domainId]/tasks/[id]/route');
|
||||||
|
const { db } = require('@project-e/db');
|
||||||
|
|
||||||
|
const existingTask = {
|
||||||
|
id: 'task-1',
|
||||||
|
title: 'Test Task',
|
||||||
|
domainId: 'domain-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
db.select.mockReturnValue({
|
||||||
|
from: jest.fn().mockReturnValue({
|
||||||
|
where: jest.fn().mockReturnValue({
|
||||||
|
limit: jest.fn().mockResolvedValue([existingTask]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
db.update.mockReturnValue({
|
||||||
|
set: jest.fn().mockReturnValue({
|
||||||
|
where: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks/task-1', {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await DELETE(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'task-1' }) });
|
||||||
|
expect(response.status).toBe(204);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Dependencies cycle detection', () => {
|
||||||
|
it('should detect direct self-loop', async () => {
|
||||||
|
const { POST } = await import('@/app/api/domains/[domainId]/tasks/[id]/dependencies/route');
|
||||||
|
const { db } = require('@project-e/db');
|
||||||
|
|
||||||
|
const mockTask = { id: 'task-1', title: 'Test', domainId: 'domain-1' };
|
||||||
|
|
||||||
|
db.select.mockReturnValue({
|
||||||
|
from: jest.fn().mockReturnValue({
|
||||||
|
where: jest.fn().mockReturnValue({
|
||||||
|
limit: jest.fn().mockResolvedValue([mockTask]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks/task-1/dependencies', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ taskId: 'task-1' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'task-1' }) });
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,19 +1,53 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { LayoutGrid, List, Plus } from "lucide-react";
|
import { LayoutGrid, List, Plus } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { TasksKanbanView } from "@/components/tasks/tasks-kanban-view";
|
import { TasksKanbanView } from "@/components/tasks/tasks-kanban-view";
|
||||||
import { TasksListView } from "@/components/tasks/tasks-list-view";
|
import { TasksListView } from "@/components/tasks/tasks-list-view";
|
||||||
import { CreateItemDialog } from "@/components/create-item-dialog";
|
import { TaskCreateDialog } from "@/components/tasks/task-create-dialog";
|
||||||
import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store";
|
import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store";
|
||||||
|
|
||||||
export default function TasksPage() {
|
export default function TasksPage() {
|
||||||
const [view, setView] = useState<"kanban" | "list">("kanban");
|
const [view, setView] = useState<"kanban" | "list">("kanban");
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const [domainId, setDomainId] = useState<string | null>(null);
|
||||||
|
const [domains, setDomains] = useState<{ id: string; name: string; color: string | null }[]>([]);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [createStatus, setCreateStatus] = useState<string>('todo');
|
||||||
const { open, openCreate, closeCreate } = useCreateDialogStore();
|
const { open, openCreate, closeCreate } = useCreateDialogStore();
|
||||||
|
|
||||||
|
// Fetch domains and select first one
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/domains?sort=sort_order')
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
const items = data.items || [];
|
||||||
|
setDomains(items);
|
||||||
|
if (items.length > 0 && !domainId) {
|
||||||
|
setDomainId(items[0].id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Listen for custom event to open create dialog with pre-filled status
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent).detail;
|
||||||
|
if (detail?.status) setCreateStatus(detail.status);
|
||||||
|
if (detail?.domainId) setDomainId(detail.domainId);
|
||||||
|
setCreateOpen(true);
|
||||||
|
};
|
||||||
|
document.addEventListener('open-create-task', handler);
|
||||||
|
return () => document.removeEventListener('open-create-task', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
setRefreshKey((k) => k + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
@@ -22,7 +56,20 @@ export default function TasksPage() {
|
|||||||
<p className="mt-1 text-muted-foreground">Move work forward without losing the thread.</p>
|
<p className="mt-1 text-muted-foreground">Move work forward without losing the thread.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button onClick={() => openCreate("task")}>
|
{/* Domain selector */}
|
||||||
|
{domains.length > 1 && (
|
||||||
|
<select
|
||||||
|
value={domainId || ''}
|
||||||
|
onChange={(e) => setDomainId(e.target.value)}
|
||||||
|
className="rounded-md border bg-background px-3 py-1.5 text-sm"
|
||||||
|
aria-label="Select domain"
|
||||||
|
>
|
||||||
|
{domains.map((d) => (
|
||||||
|
<option key={d.id} value={d.id}>{d.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<Button onClick={() => { setCreateStatus('todo'); setCreateOpen(true); }}>
|
||||||
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
|
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
New task
|
New task
|
||||||
</Button>
|
</Button>
|
||||||
@@ -34,8 +81,31 @@ export default function TasksPage() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{view === "kanban" ? <TasksKanbanView key={refreshKey} /> : <TasksListView key={refreshKey} />}
|
{domainId ? (
|
||||||
<CreateItemDialog type="task" open={open} onOpenChange={(o) => (o ? openCreate("task") : closeCreate())} onCreated={() => setRefreshKey((k) => k + 1)} />
|
view === "kanban" ? (
|
||||||
|
<TasksKanbanView key={refreshKey} domainId={domainId} onRefresh={handleRefresh} />
|
||||||
|
) : (
|
||||||
|
<TasksListView key={refreshKey} domainId={domainId} onRefresh={handleRefresh} />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<p className="text-muted-foreground">No domains found. Create one in Settings first.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create dialog */}
|
||||||
|
<TaskCreateDialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
domainId={domainId || ''}
|
||||||
|
defaultStatus={createStatus as any}
|
||||||
|
onCreated={handleRefresh}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Legacy create dialog for backward compat */}
|
||||||
|
<div style={{ display: 'none' }}>
|
||||||
|
<div onClick={() => openCreate('task')} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||||
|
// 1. Insert activity feed entry
|
||||||
|
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||||
|
// See AGENTS.md for full rules.
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { withAuth, requireWorkspaceAccess, createErrorResponse } from '@/lib/auth';
|
||||||
|
import { db, activityFeed } from '@project-e/db';
|
||||||
|
import { and, desc, eq, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ domainId: string }> };
|
||||||
|
|
||||||
|
// GET /api/domains/[domainId]/activity — List activity feed for a workspace
|
||||||
|
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||||
|
const { domainId } = await context!.params;
|
||||||
|
await requireWorkspaceAccess(domainId);
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const entityType = searchParams.get('entity_type');
|
||||||
|
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100);
|
||||||
|
const offset = parseInt(searchParams.get('offset') || '0');
|
||||||
|
|
||||||
|
const conditions: any[] = [eq(activityFeed.workspaceId, domainId)];
|
||||||
|
|
||||||
|
if (entityType) {
|
||||||
|
conditions.push(eq(activityFeed.entityType, entityType));
|
||||||
|
}
|
||||||
|
|
||||||
|
const [items, countResult] = await Promise.all([
|
||||||
|
db.select()
|
||||||
|
.from(activityFeed)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(desc(activityFeed.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset),
|
||||||
|
db.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(activityFeed)
|
||||||
|
.where(and(...conditions)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
items,
|
||||||
|
totalItems: Number(countResult[0]?.count || 0),
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||||
|
// 1. Insert activity feed entry
|
||||||
|
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||||
|
// See AGENTS.md for full rules.
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { withAuth, requireWorkspaceAccess, createErrorResponse } from '@/lib/auth';
|
||||||
|
import { recordActivity } from '@/lib/activity';
|
||||||
|
import { db, tasks } from '@project-e/db';
|
||||||
|
import { and, eq, isNull } from 'drizzle-orm';
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
|
||||||
|
|
||||||
|
// POST /api/domains/[domainId]/tasks/[id]/complete — Mark task as done
|
||||||
|
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||||
|
const { domainId, id } = await context!.params;
|
||||||
|
await requireWorkspaceAccess(domainId);
|
||||||
|
|
||||||
|
const [existing] = await db.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(eq(tasks.id, id), eq(tasks.domainId, domainId), isNull(tasks.deletedAt)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return createErrorResponse('NOT_FOUND', 'Task not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db.update(tasks)
|
||||||
|
.set({
|
||||||
|
status: 'done',
|
||||||
|
completedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(tasks.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await recordActivity({
|
||||||
|
actor: user.name,
|
||||||
|
action: 'completed',
|
||||||
|
entityType: 'task',
|
||||||
|
entityId: id,
|
||||||
|
changes: { previousStatus: existing.status },
|
||||||
|
workspaceId: domainId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
});
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||||
|
// 1. Insert activity feed entry
|
||||||
|
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||||
|
// See AGENTS.md for full rules.
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
|
||||||
|
import { recordActivity } from '@/lib/activity';
|
||||||
|
import { db, tasks, taskDependencies } from '@project-e/db';
|
||||||
|
import { and, eq, isNull } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const addDependencySchema = z.object({
|
||||||
|
taskId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cycle detection: check if adding dep (taskId -> dependsOnTaskId) would create a cycle.
|
||||||
|
* Uses BFS from dependsOnTaskId following the dependency chain.
|
||||||
|
*/
|
||||||
|
async function wouldCreateCycle(taskId: string, dependsOnTaskId: string): Promise<boolean> {
|
||||||
|
if (taskId === dependsOnTaskId) return true;
|
||||||
|
|
||||||
|
// BFS: follow dependencies from dependsOnTaskId to see if we reach taskId
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const queue = [dependsOnTaskId];
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const current = queue.shift()!;
|
||||||
|
if (current === taskId) return true;
|
||||||
|
if (visited.has(current)) continue;
|
||||||
|
visited.add(current);
|
||||||
|
|
||||||
|
const deps = await db.select({ dependsOnTaskId: taskDependencies.dependsOnTaskId })
|
||||||
|
.from(taskDependencies)
|
||||||
|
.where(eq(taskDependencies.taskId, current));
|
||||||
|
|
||||||
|
for (const dep of deps) {
|
||||||
|
if (!visited.has(dep.dependsOnTaskId)) {
|
||||||
|
queue.push(dep.dependsOnTaskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/domains/[domainId]/tasks/[id]/dependencies — Add a dependency
|
||||||
|
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||||
|
const { domainId, id } = await context!.params;
|
||||||
|
await requireWorkspaceAccess(domainId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = addDependencySchema.parse(body);
|
||||||
|
|
||||||
|
// Verify both tasks exist
|
||||||
|
const [task] = await db.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(eq(tasks.id, id), eq(tasks.domainId, domainId), isNull(tasks.deletedAt)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return createErrorResponse('NOT_FOUND', 'Task not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [depTask] = await db.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(eq(tasks.id, data.taskId), eq(tasks.domainId, domainId), isNull(tasks.deletedAt)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!depTask) {
|
||||||
|
return createErrorResponse('NOT_FOUND', 'Dependency task not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cycle detection
|
||||||
|
const cycle = await wouldCreateCycle(id, data.taskId);
|
||||||
|
if (cycle) {
|
||||||
|
return createErrorResponse('CONFLICT', 'Adding this dependency would create a cycle', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if dependency already exists
|
||||||
|
const [existing] = await db.select()
|
||||||
|
.from(taskDependencies)
|
||||||
|
.where(and(eq(taskDependencies.taskId, id), eq(taskDependencies.dependsOnTaskId, data.taskId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return createErrorResponse('CONFLICT', 'Dependency already exists', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(taskDependencies).values({
|
||||||
|
taskId: id,
|
||||||
|
dependsOnTaskId: data.taskId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordActivity({
|
||||||
|
actor: user.name,
|
||||||
|
action: 'dependency_added',
|
||||||
|
entityType: 'task',
|
||||||
|
entityId: id,
|
||||||
|
changes: { dependsOnTaskId: data.taskId, dependsOnTitle: depTask.title },
|
||||||
|
workspaceId: domainId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||||
|
}
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
return createErrorResponse(error.code, error.message, error.status);
|
||||||
|
}
|
||||||
|
console.error('[dependencies POST] error:', error);
|
||||||
|
return createErrorResponse('INTERNAL_ERROR', 'Failed to add dependency', 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/domains/[domainId]/tasks/[id]/dependencies — Remove a dependency
|
||||||
|
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||||
|
const { domainId, id } = await context!.params;
|
||||||
|
await requireWorkspaceAccess(domainId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = addDependencySchema.parse(body);
|
||||||
|
|
||||||
|
const [existing] = await db.select()
|
||||||
|
.from(taskDependencies)
|
||||||
|
.where(and(eq(taskDependencies.taskId, id), eq(taskDependencies.dependsOnTaskId, data.taskId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return createErrorResponse('NOT_FOUND', 'Dependency not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(taskDependencies)
|
||||||
|
.where(and(eq(taskDependencies.taskId, id), eq(taskDependencies.dependsOnTaskId, data.taskId)));
|
||||||
|
|
||||||
|
await recordActivity({
|
||||||
|
actor: user.name,
|
||||||
|
action: 'dependency_removed',
|
||||||
|
entityType: 'task',
|
||||||
|
entityId: id,
|
||||||
|
changes: { dependsOnTaskId: data.taskId },
|
||||||
|
workspaceId: domainId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||||
|
}
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
return createErrorResponse(error.code, error.message, error.status);
|
||||||
|
}
|
||||||
|
console.error('[dependencies DELETE] error:', error);
|
||||||
|
return createErrorResponse('INTERNAL_ERROR', 'Failed to remove dependency', 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||||
|
// 1. Insert activity feed entry
|
||||||
|
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||||
|
// See AGENTS.md for full rules.
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
|
||||||
|
import { recordActivity } from '@/lib/activity';
|
||||||
|
import { db, tasks, taskTags, tags as tagsTable, taskDependencies } from '@project-e/db';
|
||||||
|
import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const taskStatusEnum = z.enum(['todo', 'in_progress', 'done', 'cancelled']);
|
||||||
|
const taskPriorityEnum = z.enum(['low', 'medium', 'high', 'urgent']);
|
||||||
|
|
||||||
|
const updateTaskSchema = z.object({
|
||||||
|
title: z.string().min(1).optional(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
status: taskStatusEnum.optional(),
|
||||||
|
priority: taskPriorityEnum.optional(),
|
||||||
|
projectId: z.string().uuid().optional().nullable(),
|
||||||
|
sectionId: z.string().uuid().optional().nullable(),
|
||||||
|
parentId: z.string().uuid().optional().nullable(),
|
||||||
|
dueDate: z.string().datetime().optional().nullable(),
|
||||||
|
estimatedMinutes: z.number().int().positive().optional().nullable(),
|
||||||
|
order: z.number().int().optional(),
|
||||||
|
customFields: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
|
||||||
|
|
||||||
|
// GET /api/domains/[domainId]/tasks/[id] — Get a single task with subtasks + dependencies
|
||||||
|
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||||
|
const { domainId, id } = await context!.params;
|
||||||
|
await requireWorkspaceAccess(domainId);
|
||||||
|
|
||||||
|
const [task] = await db.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(eq(tasks.id, id), eq(tasks.domainId, domainId), isNull(tasks.deletedAt)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return createErrorResponse('NOT_FOUND', 'Task not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch subtasks
|
||||||
|
const subtasks = await db.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(eq(tasks.parentId, id), isNull(tasks.deletedAt)))
|
||||||
|
.orderBy(asc(tasks.order));
|
||||||
|
|
||||||
|
// Fetch tags
|
||||||
|
const tagRows = await db.select({
|
||||||
|
id: tagsTable.id,
|
||||||
|
name: tagsTable.name,
|
||||||
|
color: tagsTable.color,
|
||||||
|
})
|
||||||
|
.from(taskTags)
|
||||||
|
.innerJoin(tagsTable, eq(taskTags.tagId, tagsTable.id))
|
||||||
|
.where(eq(taskTags.taskId, id));
|
||||||
|
|
||||||
|
// Fetch dependencies (tasks this task depends on)
|
||||||
|
const depRows = await db.select({
|
||||||
|
id: tasks.id,
|
||||||
|
title: tasks.title,
|
||||||
|
status: tasks.status,
|
||||||
|
})
|
||||||
|
.from(taskDependencies)
|
||||||
|
.innerJoin(tasks, eq(taskDependencies.dependsOnTaskId, tasks.id))
|
||||||
|
.where(and(eq(taskDependencies.taskId, id), isNull(tasks.deletedAt)));
|
||||||
|
|
||||||
|
// Fetch dependents (tasks that depend on this task)
|
||||||
|
const dependentRows = await db.select({
|
||||||
|
id: tasks.id,
|
||||||
|
title: tasks.title,
|
||||||
|
status: tasks.status,
|
||||||
|
})
|
||||||
|
.from(taskDependencies)
|
||||||
|
.innerJoin(tasks, eq(taskDependencies.taskId, tasks.id))
|
||||||
|
.where(and(eq(taskDependencies.dependsOnTaskId, id), isNull(tasks.deletedAt)));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
...task,
|
||||||
|
subtasks,
|
||||||
|
tags: tagRows,
|
||||||
|
dependencies: depRows,
|
||||||
|
dependents: dependentRows,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/domains/[domainId]/tasks/[id] — Update a task
|
||||||
|
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||||
|
const { domainId, id } = await context!.params;
|
||||||
|
await requireWorkspaceAccess(domainId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = updateTaskSchema.parse(body);
|
||||||
|
|
||||||
|
// Verify task exists
|
||||||
|
const [existing] = await db.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(eq(tasks.id, id), eq(tasks.domainId, domainId), isNull(tasks.deletedAt)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return createErrorResponse('NOT_FOUND', 'Task not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cycle detection for parentId (can't set parent to self or descendant)
|
||||||
|
if (data.parentId && data.parentId === id) {
|
||||||
|
return createErrorResponse('VALIDATION_ERROR', 'A task cannot be its own parent', 400);
|
||||||
|
}
|
||||||
|
if (data.parentId) {
|
||||||
|
// Check for cycles in parent chain
|
||||||
|
let currentParentId: string | null = data.parentId;
|
||||||
|
const visited = new Set<string>([id]);
|
||||||
|
while (currentParentId) {
|
||||||
|
if (visited.has(currentParentId)) {
|
||||||
|
return createErrorResponse('VALIDATION_ERROR', 'Circular parent reference detected', 400);
|
||||||
|
}
|
||||||
|
visited.add(currentParentId);
|
||||||
|
const [parent] = await db.select({ parentId: tasks.parentId })
|
||||||
|
.from(tasks)
|
||||||
|
.where(eq(tasks.id, currentParentId))
|
||||||
|
.limit(1);
|
||||||
|
currentParentId = parent?.parentId ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update object
|
||||||
|
const updateValues: Record<string, unknown> = {};
|
||||||
|
if (data.title !== undefined) updateValues.title = data.title;
|
||||||
|
if (data.description !== undefined) updateValues.description = data.description;
|
||||||
|
if (data.status !== undefined) updateValues.status = data.status;
|
||||||
|
if (data.priority !== undefined) updateValues.priority = data.priority;
|
||||||
|
if (data.projectId !== undefined) updateValues.projectId = data.projectId;
|
||||||
|
if (data.sectionId !== undefined) updateValues.sectionId = data.sectionId;
|
||||||
|
if (data.parentId !== undefined) updateValues.parentId = data.parentId;
|
||||||
|
if (data.dueDate !== undefined) updateValues.dueDate = data.dueDate ? new Date(data.dueDate) : null;
|
||||||
|
if (data.estimatedMinutes !== undefined) updateValues.estimatedMinutes = data.estimatedMinutes;
|
||||||
|
if (data.order !== undefined) updateValues.order = data.order;
|
||||||
|
if (data.customFields !== undefined) updateValues.customFields = data.customFields;
|
||||||
|
updateValues.updatedAt = new Date();
|
||||||
|
|
||||||
|
const [updated] = await db.update(tasks)
|
||||||
|
.set(updateValues)
|
||||||
|
.where(eq(tasks.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Record activity
|
||||||
|
await recordActivity({
|
||||||
|
actor: user.name,
|
||||||
|
action: 'updated',
|
||||||
|
entityType: 'task',
|
||||||
|
entityId: id,
|
||||||
|
changes: { ...data, previousStatus: existing.status },
|
||||||
|
workspaceId: domainId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||||
|
}
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
return createErrorResponse(error.code, error.message, error.status);
|
||||||
|
}
|
||||||
|
console.error('[tasks PATCH] error:', error);
|
||||||
|
return createErrorResponse('INTERNAL_ERROR', 'Failed to update task', 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/domains/[domainId]/tasks/[id] — Soft delete a task
|
||||||
|
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||||
|
const { domainId, id } = await context!.params;
|
||||||
|
await requireWorkspaceAccess(domainId);
|
||||||
|
|
||||||
|
const [existing] = await db.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(eq(tasks.id, id), eq(tasks.domainId, domainId), isNull(tasks.deletedAt)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return createErrorResponse('NOT_FOUND', 'Task not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(tasks)
|
||||||
|
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
||||||
|
.where(eq(tasks.id, id));
|
||||||
|
|
||||||
|
// Record activity
|
||||||
|
await recordActivity({
|
||||||
|
actor: user.name,
|
||||||
|
action: 'deleted',
|
||||||
|
entityType: 'task',
|
||||||
|
entityId: id,
|
||||||
|
changes: { title: existing.title },
|
||||||
|
workspaceId: domainId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||||
|
// 1. Insert activity feed entry
|
||||||
|
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||||
|
// See AGENTS.md for full rules.
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
|
||||||
|
import { recordActivity } from '@/lib/activity';
|
||||||
|
import { db, tasks, taskTags, tags as tagsTable } from '@project-e/db';
|
||||||
|
import { and, eq, isNull } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const tagActionSchema = z.object({
|
||||||
|
tagId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
|
||||||
|
|
||||||
|
// POST /api/domains/[domainId]/tasks/[id]/tags — Add a tag to a task
|
||||||
|
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||||
|
const { domainId, id } = await context!.params;
|
||||||
|
await requireWorkspaceAccess(domainId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = tagActionSchema.parse(body);
|
||||||
|
|
||||||
|
// Verify task exists
|
||||||
|
const [task] = await db.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(eq(tasks.id, id), eq(tasks.domainId, domainId), isNull(tasks.deletedAt)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return createErrorResponse('NOT_FOUND', 'Task not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify tag exists
|
||||||
|
const [tag] = await db.select()
|
||||||
|
.from(tagsTable)
|
||||||
|
.where(eq(tagsTable.id, data.tagId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!tag) {
|
||||||
|
return createErrorResponse('NOT_FOUND', 'Tag not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already tagged
|
||||||
|
const [existing] = await db.select()
|
||||||
|
.from(taskTags)
|
||||||
|
.where(and(eq(taskTags.taskId, id), eq(taskTags.tagId, data.tagId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return createErrorResponse('CONFLICT', 'Tag already added to this task', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(taskTags).values({ taskId: id, tagId: data.tagId });
|
||||||
|
|
||||||
|
await recordActivity({
|
||||||
|
actor: user.name,
|
||||||
|
action: 'tag_added',
|
||||||
|
entityType: 'task',
|
||||||
|
entityId: id,
|
||||||
|
changes: { tagId: data.tagId, tagName: tag.name },
|
||||||
|
workspaceId: domainId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||||
|
}
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
return createErrorResponse(error.code, error.message, error.status);
|
||||||
|
}
|
||||||
|
console.error('[tags POST] error:', error);
|
||||||
|
return createErrorResponse('INTERNAL_ERROR', 'Failed to add tag', 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/domains/[domainId]/tasks/[id]/tags — Remove a tag from a task
|
||||||
|
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||||
|
const { domainId, id } = await context!.params;
|
||||||
|
await requireWorkspaceAccess(domainId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = tagActionSchema.parse(body);
|
||||||
|
|
||||||
|
const [existing] = await db.select()
|
||||||
|
.from(taskTags)
|
||||||
|
.where(and(eq(taskTags.taskId, id), eq(taskTags.tagId, data.tagId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return createErrorResponse('NOT_FOUND', 'Tag not found on this task', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(taskTags)
|
||||||
|
.where(and(eq(taskTags.taskId, id), eq(taskTags.tagId, data.tagId)));
|
||||||
|
|
||||||
|
await recordActivity({
|
||||||
|
actor: user.name,
|
||||||
|
action: 'tag_removed',
|
||||||
|
entityType: 'task',
|
||||||
|
entityId: id,
|
||||||
|
changes: { tagId: data.tagId },
|
||||||
|
workspaceId: domainId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||||
|
}
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
return createErrorResponse(error.code, error.message, error.status);
|
||||||
|
}
|
||||||
|
console.error('[tags DELETE] error:', error);
|
||||||
|
return createErrorResponse('INTERNAL_ERROR', 'Failed to remove tag', 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||||
|
// 1. Insert activity feed entry
|
||||||
|
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||||
|
// See AGENTS.md for full rules.
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { withAuth, requireWorkspaceAccess, createErrorResponse } from '@/lib/auth';
|
||||||
|
import { recordActivity } from '@/lib/activity';
|
||||||
|
import { db, tasks } from '@project-e/db';
|
||||||
|
import { and, eq, isNull } from 'drizzle-orm';
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
|
||||||
|
|
||||||
|
// POST /api/domains/[domainId]/tasks/[id]/uncomplete — Revert task from done
|
||||||
|
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||||
|
const { domainId, id } = await context!.params;
|
||||||
|
await requireWorkspaceAccess(domainId);
|
||||||
|
|
||||||
|
const [existing] = await db.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(eq(tasks.id, id), eq(tasks.domainId, domainId), isNull(tasks.deletedAt)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return createErrorResponse('NOT_FOUND', 'Task not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db.update(tasks)
|
||||||
|
.set({
|
||||||
|
status: 'todo',
|
||||||
|
completedAt: null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(tasks.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await recordActivity({
|
||||||
|
actor: user.name,
|
||||||
|
action: 'uncompleted',
|
||||||
|
entityType: 'task',
|
||||||
|
entityId: id,
|
||||||
|
changes: { previousStatus: existing.status },
|
||||||
|
workspaceId: domainId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||||
|
// 1. Insert activity feed entry
|
||||||
|
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||||
|
// See AGENTS.md for full rules.
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
|
||||||
|
import { recordActivity } from '@/lib/activity';
|
||||||
|
import { db, tasks } from '@project-e/db';
|
||||||
|
import { and, eq, inArray, isNull } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const bulkUpdateSchema = z.object({
|
||||||
|
ids: z.array(z.string().uuid()).min(1).max(200),
|
||||||
|
updates: z.object({
|
||||||
|
status: z.enum(['todo', 'in_progress', 'done', 'cancelled']).optional(),
|
||||||
|
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
|
||||||
|
order: z.number().int().optional(),
|
||||||
|
projectId: z.string().uuid().optional().nullable(),
|
||||||
|
sectionId: z.string().uuid().optional().nullable(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ domainId: string }> };
|
||||||
|
|
||||||
|
// POST /api/domains/[domainId]/tasks/bulk — Bulk update tasks (order, status)
|
||||||
|
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||||
|
const { domainId } = await context!.params;
|
||||||
|
await requireWorkspaceAccess(domainId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = bulkUpdateSchema.parse(body);
|
||||||
|
|
||||||
|
// Verify all tasks belong to this domain
|
||||||
|
const existingTasks = await db.select({ id: tasks.id })
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(inArray(tasks.id, data.ids), eq(tasks.domainId, domainId), isNull(tasks.deletedAt)));
|
||||||
|
|
||||||
|
if (existingTasks.length !== data.ids.length) {
|
||||||
|
return createErrorResponse('NOT_FOUND', 'One or more tasks not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateValues: Record<string, unknown> = { updatedAt: new Date() };
|
||||||
|
if (data.updates.status !== undefined) updateValues.status = data.updates.status;
|
||||||
|
if (data.updates.priority !== undefined) updateValues.priority = data.updates.priority;
|
||||||
|
if (data.updates.order !== undefined) updateValues.order = data.updates.order;
|
||||||
|
if (data.updates.projectId !== undefined) updateValues.projectId = data.updates.projectId;
|
||||||
|
if (data.updates.sectionId !== undefined) updateValues.sectionId = data.updates.sectionId;
|
||||||
|
|
||||||
|
const updated = await db.update(tasks)
|
||||||
|
.set(updateValues)
|
||||||
|
.where(inArray(tasks.id, data.ids))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Record activity for each task
|
||||||
|
for (const task of updated) {
|
||||||
|
await recordActivity({
|
||||||
|
actor: user.name,
|
||||||
|
action: 'bulk_updated',
|
||||||
|
entityType: 'task',
|
||||||
|
entityId: task.id,
|
||||||
|
changes: data.updates,
|
||||||
|
workspaceId: domainId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ updated: updated.length, items: updated });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||||
|
}
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
return createErrorResponse(error.code, error.message, error.status);
|
||||||
|
}
|
||||||
|
console.error('[tasks bulk POST] error:', error);
|
||||||
|
return createErrorResponse('INTERNAL_ERROR', 'Failed to bulk update tasks', 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||||
|
// 1. Insert activity feed entry
|
||||||
|
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||||
|
// See AGENTS.md for full rules.
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
|
||||||
|
import { recordActivity } from '@/lib/activity';
|
||||||
|
import { db, tasks, taskTags, tags as tagsTable, taskDependencies, domains } from '@project-e/db';
|
||||||
|
import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const taskStatusEnum = z.enum(['todo', 'in_progress', 'done', 'cancelled']);
|
||||||
|
const taskPriorityEnum = z.enum(['low', 'medium', 'high', 'urgent']);
|
||||||
|
|
||||||
|
const createTaskSchema = z.object({
|
||||||
|
title: z.string().min(1, 'Title is required'),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
status: taskStatusEnum.optional().default('todo'),
|
||||||
|
priority: taskPriorityEnum.optional().default('medium'),
|
||||||
|
projectId: z.string().uuid().optional().nullable(),
|
||||||
|
sectionId: z.string().uuid().optional().nullable(),
|
||||||
|
parentId: z.string().uuid().optional().nullable(),
|
||||||
|
dueDate: z.string().datetime().optional().nullable(),
|
||||||
|
estimatedMinutes: z.number().int().positive().optional().nullable(),
|
||||||
|
order: z.number().int().optional(),
|
||||||
|
customFields: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
tagIds: z.array(z.string().uuid()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ domainId: string }> };
|
||||||
|
|
||||||
|
// GET /api/domains/[domainId]/tasks — List tasks with filtering, sorting, pagination
|
||||||
|
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||||
|
const { domainId } = await context!.params;
|
||||||
|
await requireWorkspaceAccess(domainId);
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const status = searchParams.get('status');
|
||||||
|
const priority = searchParams.get('priority');
|
||||||
|
const tag = searchParams.get('tag');
|
||||||
|
const search = searchParams.get('search');
|
||||||
|
const parentId = searchParams.get('parent_id');
|
||||||
|
const projectId = searchParams.get('project_id');
|
||||||
|
const sectionId = searchParams.get('section_id');
|
||||||
|
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
|
||||||
|
const offset = parseInt(searchParams.get('offset') || '0');
|
||||||
|
const sort = searchParams.get('sort') || 'order';
|
||||||
|
const order = searchParams.get('order') || 'asc';
|
||||||
|
|
||||||
|
// Build where conditions
|
||||||
|
const conditions: any[] = [
|
||||||
|
eq(tasks.domainId, domainId),
|
||||||
|
isNull(tasks.deletedAt),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
const statuses = status.split(',');
|
||||||
|
conditions.push(inArray(tasks.status, statuses as any));
|
||||||
|
}
|
||||||
|
if (priority) {
|
||||||
|
const priorities = priority.split(',');
|
||||||
|
conditions.push(inArray(tasks.priority, priorities as any));
|
||||||
|
}
|
||||||
|
if (search) {
|
||||||
|
conditions.push(ilike(tasks.title, `%${search}%`));
|
||||||
|
}
|
||||||
|
if (parentId === 'null') {
|
||||||
|
conditions.push(isNull(tasks.parentId));
|
||||||
|
} else if (parentId) {
|
||||||
|
conditions.push(eq(tasks.parentId, parentId));
|
||||||
|
}
|
||||||
|
if (projectId) {
|
||||||
|
conditions.push(eq(tasks.projectId, projectId));
|
||||||
|
}
|
||||||
|
if (sectionId) {
|
||||||
|
conditions.push(eq(tasks.sectionId, sectionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build order
|
||||||
|
const orderFn = order === 'desc' ? desc : asc;
|
||||||
|
let orderColumn;
|
||||||
|
switch (sort) {
|
||||||
|
case 'title': orderColumn = orderFn(tasks.title); break;
|
||||||
|
case 'status': orderColumn = orderFn(tasks.status); break;
|
||||||
|
case 'priority': orderColumn = orderFn(tasks.priority); break;
|
||||||
|
case 'due_date': orderColumn = orderFn(tasks.dueDate); break;
|
||||||
|
case 'created_at': orderColumn = orderFn(tasks.createdAt); break;
|
||||||
|
case 'updated_at': orderColumn = orderFn(tasks.updatedAt); break;
|
||||||
|
default: orderColumn = orderFn(tasks.order); break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [items, countResult] = await Promise.all([
|
||||||
|
db.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(orderColumn)
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset),
|
||||||
|
db.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(...conditions)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalItems = Number(countResult[0]?.count || 0);
|
||||||
|
|
||||||
|
// If tag filter is specified, filter in-memory (or we could do a subquery)
|
||||||
|
let filteredItems = items;
|
||||||
|
if (tag) {
|
||||||
|
const tagIds = tag.split(',');
|
||||||
|
const taskTagRows = await db.select({ taskId: taskTags.taskId })
|
||||||
|
.from(taskTags)
|
||||||
|
.where(inArray(taskTags.tagId, tagIds));
|
||||||
|
const matchingTaskIds = new Set(taskTagRows.map(r => r.taskId));
|
||||||
|
filteredItems = items.filter(t => matchingTaskIds.has(t.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch tags for all tasks
|
||||||
|
let taskTagMap = new Map<string, { id: string; name: string; color: string | null }[]>();
|
||||||
|
if (filteredItems.length > 0) {
|
||||||
|
const taskIds = filteredItems.map(t => t.id);
|
||||||
|
const tagRows = await db.select({
|
||||||
|
taskId: taskTags.taskId,
|
||||||
|
id: tagsTable.id,
|
||||||
|
name: tagsTable.name,
|
||||||
|
color: tagsTable.color,
|
||||||
|
})
|
||||||
|
.from(taskTags)
|
||||||
|
.innerJoin(tagsTable, eq(taskTags.tagId, tagsTable.id))
|
||||||
|
.where(inArray(taskTags.taskId, taskIds));
|
||||||
|
|
||||||
|
for (const row of tagRows) {
|
||||||
|
if (!taskTagMap.has(row.taskId)) taskTagMap.set(row.taskId, []);
|
||||||
|
taskTagMap.get(row.taskId)!.push({ id: row.id, name: row.name, color: row.color });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsWithTags = filteredItems.map(t => ({
|
||||||
|
...t,
|
||||||
|
tags: taskTagMap.get(t.id) || [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
items: itemsWithTags,
|
||||||
|
totalItems,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/domains/[domainId]/tasks — Create a task
|
||||||
|
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||||
|
const { domainId } = await context!.params;
|
||||||
|
await requireWorkspaceAccess(domainId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = createTaskSchema.parse(body);
|
||||||
|
|
||||||
|
// Validate domain_id matches route param
|
||||||
|
// domainId is already validated via requireWorkspaceAccess
|
||||||
|
|
||||||
|
// Cycle detection for parentId (subtask)
|
||||||
|
if (data.parentId) {
|
||||||
|
// Verify parent exists and is not deleted
|
||||||
|
const [parent] = await db.select({ id: tasks.id })
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(eq(tasks.id, data.parentId), isNull(tasks.deletedAt)))
|
||||||
|
.limit(1);
|
||||||
|
if (!parent) {
|
||||||
|
return createErrorResponse('NOT_FOUND', 'Parent task not found', 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [task] = await db.insert(tasks).values({
|
||||||
|
title: data.title,
|
||||||
|
description: data.description ?? null,
|
||||||
|
status: data.status,
|
||||||
|
priority: data.priority,
|
||||||
|
domainId,
|
||||||
|
projectId: data.projectId ?? null,
|
||||||
|
sectionId: data.sectionId ?? null,
|
||||||
|
parentId: data.parentId ?? null,
|
||||||
|
dueDate: data.dueDate ? new Date(data.dueDate) : null,
|
||||||
|
estimatedMinutes: data.estimatedMinutes ?? null,
|
||||||
|
order: data.order ?? 0,
|
||||||
|
customFields: data.customFields ?? {},
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
// Insert tags if provided
|
||||||
|
if (data.tagIds && data.tagIds.length > 0) {
|
||||||
|
await db.insert(taskTags).values(
|
||||||
|
data.tagIds.map(tagId => ({ taskId: task.id, tagId }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record activity
|
||||||
|
await recordActivity({
|
||||||
|
actor: user.name,
|
||||||
|
action: 'created',
|
||||||
|
entityType: 'task',
|
||||||
|
entityId: task.id,
|
||||||
|
changes: { title: task.title, status: task.status, priority: task.priority },
|
||||||
|
workspaceId: domainId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(task, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||||
|
}
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
return createErrorResponse(error.code, error.message, error.status);
|
||||||
|
}
|
||||||
|
console.error('[tasks POST] error:', error);
|
||||||
|
return createErrorResponse('INTERNAL_ERROR', 'Failed to create task', 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ActivityEntry {
|
||||||
|
id: string;
|
||||||
|
actor: string;
|
||||||
|
action: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
changes?: Record<string, unknown> | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionLabels: Record<string, string> = {
|
||||||
|
created: 'created',
|
||||||
|
updated: 'updated',
|
||||||
|
deleted: 'deleted',
|
||||||
|
completed: 'completed',
|
||||||
|
uncompleted: 'reverted',
|
||||||
|
bulk_updated: 'bulk updated',
|
||||||
|
dependency_added: 'added dependency to',
|
||||||
|
dependency_removed: 'removed dependency from',
|
||||||
|
tag_added: 'added tag to',
|
||||||
|
tag_removed: 'removed tag from',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TaskActivityFeed({ domainId }: { domainId: string }) {
|
||||||
|
const [activities, setActivities] = useState<ActivityEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!domainId) return;
|
||||||
|
fetch(`/api/domains/${domainId}/activity?entity_type=task&limit=10`)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error('Failed to load activity');
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
setActivities(data.items || []);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Failed to load activity feed:', err);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [domainId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activities.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
<p>No recent activity</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className="h-[400px]">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{activities.map((entry) => (
|
||||||
|
<div key={entry.id} className="flex items-start gap-2 text-sm">
|
||||||
|
<Badge variant="outline" className="mt-0.5 shrink-0 text-xs capitalize">
|
||||||
|
{actionLabels[entry.action] || entry.action}
|
||||||
|
</Badge>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">{entry.actor}</span>
|
||||||
|
{' '}
|
||||||
|
{actionLabels[entry.action] || entry.action}
|
||||||
|
{' '}
|
||||||
|
{entry.changes && typeof entry.changes === 'object' && 'title' in entry.changes
|
||||||
|
? `"${entry.changes.title}"`
|
||||||
|
: entry.entityId.slice(0, 8)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{new Date(entry.createdAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface TaskCreateDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
domainId: string;
|
||||||
|
defaultStatus?: 'todo' | 'in_progress' | 'done' | 'cancelled';
|
||||||
|
onCreated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskCreateDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
domainId,
|
||||||
|
defaultStatus = 'todo',
|
||||||
|
onCreated,
|
||||||
|
}: TaskCreateDialogProps) {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [status, setStatus] = useState(defaultStatus);
|
||||||
|
const [priority, setPriority] = useState<'low' | 'medium' | 'high' | 'urgent'>('medium');
|
||||||
|
const [dueDate, setDueDate] = useState('');
|
||||||
|
const [estimatedMinutes, setEstimatedMinutes] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Reset form when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setTitle('');
|
||||||
|
setDescription('');
|
||||||
|
setStatus(defaultStatus);
|
||||||
|
setPriority('medium');
|
||||||
|
setDueDate('');
|
||||||
|
setEstimatedMinutes('');
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
}, [open, defaultStatus]);
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!domainId) {
|
||||||
|
setError('No domain selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
};
|
||||||
|
if (description) body.description = description;
|
||||||
|
if (dueDate) body.dueDate = new Date(dueDate).toISOString();
|
||||||
|
if (estimatedMinutes) body.estimatedMinutes = parseInt(estimatedMinutes, 10);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/domains/${domainId}/tasks`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json();
|
||||||
|
throw new Error(err.error?.message || 'Unable to create task');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('Task created');
|
||||||
|
onOpenChange(false);
|
||||||
|
onCreated();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unable to create task');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New Task</DialogTitle>
|
||||||
|
<DialogDescription>Create a new task to track your work.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="task-title">Title *</Label>
|
||||||
|
<Input
|
||||||
|
id="task-title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="What needs to be done?"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="task-description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="task-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Add details..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="task-status">Status</Label>
|
||||||
|
<Select value={status} onValueChange={(v) => setStatus(v as any)}>
|
||||||
|
<SelectTrigger id="task-status">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="todo">To Do</SelectItem>
|
||||||
|
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||||
|
<SelectItem value="done">Done</SelectItem>
|
||||||
|
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="task-priority">Priority</Label>
|
||||||
|
<Select value={priority} onValueChange={(v) => setPriority(v as any)}>
|
||||||
|
<SelectTrigger id="task-priority">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="low">Low</SelectItem>
|
||||||
|
<SelectItem value="medium">Medium</SelectItem>
|
||||||
|
<SelectItem value="high">High</SelectItem>
|
||||||
|
<SelectItem value="urgent">Urgent</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="task-due-date">Due Date</Label>
|
||||||
|
<Input
|
||||||
|
id="task-due-date"
|
||||||
|
type="date"
|
||||||
|
value={dueDate}
|
||||||
|
onChange={(e) => setDueDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="task-estimate">Est. Minutes</Label>
|
||||||
|
<Input
|
||||||
|
id="task-estimate"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={estimatedMinutes}
|
||||||
|
onChange={(e) => setEstimatedMinutes(e.target.value)}
|
||||||
|
placeholder="e.g. 30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-destructive" role="alert">{error}</p>}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={submitting || !title || !domainId}>
|
||||||
|
{submitting ? 'Creating...' : 'Create Task'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle
|
SheetTitle,
|
||||||
} from '@/components/ui/sheet';
|
} from '@/components/ui/sheet';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -27,63 +27,108 @@ import {
|
|||||||
AlertDialogDescription,
|
AlertDialogDescription,
|
||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Calendar, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
interface Task {
|
interface TaskDetail {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string | null;
|
||||||
status: 'todo' | 'in_progress' | 'done';
|
status: 'todo' | 'in_progress' | 'done' | 'cancelled';
|
||||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||||
domain: string;
|
domainId: string;
|
||||||
due_date?: string;
|
projectId?: string | null;
|
||||||
project_id?: string;
|
sectionId?: string | null;
|
||||||
tags: string[];
|
parentId?: string | null;
|
||||||
|
dueDate?: string | null;
|
||||||
|
completedAt?: string | null;
|
||||||
|
estimatedMinutes?: number | null;
|
||||||
|
trackedMinutes?: number | null;
|
||||||
|
order: number;
|
||||||
|
customFields?: Record<string, unknown> | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
subtasks: any[];
|
||||||
|
tags: { id: string; name: string; color: string | null }[];
|
||||||
|
dependencies: { id: string; title: string; status: string }[];
|
||||||
|
dependents: { id: string; title: string; status: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TaskDetailPanelProps {
|
interface TaskDetailPanelProps {
|
||||||
task: Task;
|
taskId: string;
|
||||||
|
domainId: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onUpdate: () => void;
|
onUpdate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TaskDetailPanel({
|
export function TaskDetailPanel({
|
||||||
task,
|
taskId,
|
||||||
|
domainId,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onUpdate
|
onUpdate,
|
||||||
}: TaskDetailPanelProps) {
|
}: TaskDetailPanelProps) {
|
||||||
const [title, setTitle] = useState(task.title);
|
const [task, setTask] = useState<TaskDetail | null>(null);
|
||||||
const [description, setDescription] = useState(task.description || '');
|
const [loading, setLoading] = useState(true);
|
||||||
const [status, setStatus] = useState(task.status);
|
const [title, setTitle] = useState('');
|
||||||
const [priority, setPriority] = useState(task.priority);
|
const [description, setDescription] = useState('');
|
||||||
const [domain, setDomain] = useState(task.domain);
|
const [status, setStatus] = useState<'todo' | 'in_progress' | 'done' | 'cancelled'>('todo');
|
||||||
const [domains, setDomains] = useState<{id: string; name: string}[]>([]);
|
const [priority, setPriority] = useState<'low' | 'medium' | 'high' | 'urgent'>('medium');
|
||||||
|
const [dueDate, setDueDate] = useState('');
|
||||||
useEffect(() => {
|
const [estimatedMinutes, setEstimatedMinutes] = useState('');
|
||||||
fetch('/api/domains?sort=sort_order').then(r => r.json()).then(data => setDomains(data.items || [])).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
const [dueDate, setDueDate] = useState(task.due_date || '');
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
// Fetch task details when panel opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !taskId || !domainId) return;
|
||||||
|
setLoading(true);
|
||||||
|
fetch(`/api/domains/${domainId}/tasks/${taskId}`)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error('Unable to load task');
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((data: TaskDetail) => {
|
||||||
|
setTask(data);
|
||||||
|
setTitle(data.title);
|
||||||
|
setDescription(data.description || '');
|
||||||
|
setStatus(data.status);
|
||||||
|
setPriority(data.priority);
|
||||||
|
setDueDate(data.dueDate ? data.dueDate.split('T')[0] : '');
|
||||||
|
setEstimatedMinutes(data.estimatedMinutes?.toString() || '');
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Failed to load task:', err);
|
||||||
|
toast.error('Unable to load task details');
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [open, taskId, domainId]);
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
|
if (!task || !domainId) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/tasks/${task.id}`, {
|
const body: Record<string, unknown> = {
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
};
|
||||||
|
if (description !== (task.description || '')) body.description = description || null;
|
||||||
|
if (dueDate !== (task.dueDate ? task.dueDate.split('T')[0] : '')) {
|
||||||
|
body.dueDate = dueDate ? new Date(dueDate).toISOString() : null;
|
||||||
|
}
|
||||||
|
if (estimatedMinutes !== (task.estimatedMinutes?.toString() || '')) {
|
||||||
|
body.estimatedMinutes = estimatedMinutes ? parseInt(estimatedMinutes, 10) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/domains/${domainId}/tasks/${task.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(body),
|
||||||
title,
|
|
||||||
description,
|
|
||||||
status,
|
|
||||||
priority,
|
|
||||||
domain,
|
|
||||||
due_date: dueDate || undefined
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('Unable to save task');
|
if (!response.ok) throw new Error('Unable to save task');
|
||||||
onUpdate();
|
onUpdate();
|
||||||
@@ -98,10 +143,11 @@ export function TaskDetailPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
|
if (!task || !domainId) return;
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/tasks/${task.id}`, {
|
const response = await fetch(`/api/domains/${domainId}/tasks/${task.id}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('Unable to delete task');
|
if (!response.ok) throw new Error('Unable to delete task');
|
||||||
onUpdate();
|
onUpdate();
|
||||||
@@ -123,114 +169,183 @@ export function TaskDetailPanel({
|
|||||||
<SheetTitle>Task Details</SheetTitle>
|
<SheetTitle>Task Details</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="mt-6 space-y-6">
|
{loading ? (
|
||||||
<div className="space-y-2">
|
<div className="mt-12 flex justify-center">
|
||||||
<Label htmlFor="title">Title</Label>
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder="Task title"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : !task ? (
|
||||||
<div className="space-y-2">
|
<div className="mt-12 text-center text-muted-foreground">
|
||||||
<Label htmlFor="description">Description</Label>
|
<p>Task not found</p>
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder="Add a description..."
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="mt-6 space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="task-status">Status</Label>
|
<Label htmlFor="title">Title</Label>
|
||||||
<Select
|
|
||||||
value={status}
|
|
||||||
onValueChange={(v) => setStatus(v as Task['status'])}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="task-status">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="todo">To Do</SelectItem>
|
|
||||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
|
||||||
<SelectItem value="done">Done</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="task-priority">Priority</Label>
|
|
||||||
<Select
|
|
||||||
value={priority}
|
|
||||||
onValueChange={(v) => setPriority(v as Task['priority'])}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="task-priority">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="low">Low</SelectItem>
|
|
||||||
<SelectItem value="medium">Medium</SelectItem>
|
|
||||||
<SelectItem value="high">High</SelectItem>
|
|
||||||
<SelectItem value="urgent">Urgent</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="task-domain">Domain</Label>
|
|
||||||
<Select value={domain} onValueChange={setDomain}>
|
|
||||||
<SelectTrigger id="task-domain">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{domains.map((d) => (
|
|
||||||
<SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="task-due-date">Due Date</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="task-due-date"
|
id="title"
|
||||||
type="date"
|
value={title}
|
||||||
value={dueDate}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
onChange={(e) => setDueDate(e.target.value)}
|
placeholder="Task title"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-4">
|
<div className="space-y-2">
|
||||||
<Button onClick={handleSave} disabled={saving}>
|
<Label htmlFor="description">Description</Label>
|
||||||
{saving ? 'Saving...' : 'Save Changes'}
|
<Textarea
|
||||||
</Button>
|
id="description"
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
value={description}
|
||||||
Cancel
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
</Button>
|
placeholder="Add a description..."
|
||||||
<Button
|
rows={4}
|
||||||
variant="destructive"
|
/>
|
||||||
onClick={() => setDeleteOpen(true)}
|
</div>
|
||||||
className="ml-auto"
|
|
||||||
>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
Delete
|
<div className="space-y-2">
|
||||||
</Button>
|
<Label htmlFor="task-status">Status</Label>
|
||||||
|
<Select
|
||||||
|
value={status}
|
||||||
|
onValueChange={(v) => setStatus(v as any)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="task-status">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="todo">To Do</SelectItem>
|
||||||
|
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||||
|
<SelectItem value="done">Done</SelectItem>
|
||||||
|
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="task-priority">Priority</Label>
|
||||||
|
<Select
|
||||||
|
value={priority}
|
||||||
|
onValueChange={(v) => setPriority(v as any)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="task-priority">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="low">Low</SelectItem>
|
||||||
|
<SelectItem value="medium">Medium</SelectItem>
|
||||||
|
<SelectItem value="high">High</SelectItem>
|
||||||
|
<SelectItem value="urgent">Urgent</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="task-due-date">Due Date</Label>
|
||||||
|
<Input
|
||||||
|
id="task-due-date"
|
||||||
|
type="date"
|
||||||
|
value={dueDate}
|
||||||
|
onChange={(e) => setDueDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="task-estimate">Est. Minutes</Label>
|
||||||
|
<Input
|
||||||
|
id="task-estimate"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={estimatedMinutes}
|
||||||
|
onChange={(e) => setEstimatedMinutes(e.target.value)}
|
||||||
|
placeholder="e.g. 30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{task.tags && task.tags.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Tags</Label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{task.tags.map((tag) => (
|
||||||
|
<Badge
|
||||||
|
key={tag.id}
|
||||||
|
variant="outline"
|
||||||
|
style={tag.color ? { borderColor: tag.color, color: tag.color } : {}}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dependencies */}
|
||||||
|
{task.dependencies && task.dependencies.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Depends on</Label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{task.dependencies.map((dep) => (
|
||||||
|
<div key={dep.id} className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-muted-foreground">•</span>
|
||||||
|
<span>{dep.title}</span>
|
||||||
|
<Badge variant="outline" className="text-xs capitalize">{dep.status}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Subtasks */}
|
||||||
|
{task.subtasks && task.subtasks.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Subtasks ({task.subtasks.length})</Label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{task.subtasks.map((sub: any) => (
|
||||||
|
<div key={sub.id} className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-muted-foreground">•</span>
|
||||||
|
<span className={sub.status === 'done' ? 'line-through text-muted-foreground' : ''}>
|
||||||
|
{sub.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div className="space-y-1 text-xs text-muted-foreground">
|
||||||
|
<p>Created: {new Date(task.createdAt).toLocaleString()}</p>
|
||||||
|
<p>Updated: {new Date(task.updatedAt).toLocaleString()}</p>
|
||||||
|
{task.completedAt && (
|
||||||
|
<p>Completed: {new Date(task.completedAt).toLocaleString()}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
className="ml-auto"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete task?</AlertDialogTitle>
|
<AlertDialogTitle>Delete task?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This permanently deletes "{task.title}".
|
This permanently deletes "{task?.title}".
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
KeyboardSensor,
|
|
||||||
DndContext,
|
DndContext,
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
@@ -10,146 +9,116 @@ import {
|
|||||||
PointerSensor,
|
PointerSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
useDraggable,
|
|
||||||
useDroppable
|
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import { Calendar, GripVertical, Plus } from 'lucide-react';
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { Calendar, GripVertical } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { TaskDetailPanel } from './task-detail-panel';
|
import { TaskDetailPanel } from './task-detail-panel';
|
||||||
|
import { useRealtimeContext } from '@/components/realtime-provider';
|
||||||
|
|
||||||
interface Task {
|
interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string | null;
|
||||||
status: 'todo' | 'in_progress' | 'done';
|
status: 'todo' | 'in_progress' | 'done' | 'cancelled';
|
||||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||||
domain: string;
|
domainId: string;
|
||||||
due_date?: string;
|
dueDate?: string | null;
|
||||||
project_id?: string;
|
projectId?: string | null;
|
||||||
tags: string[];
|
sectionId?: string | null;
|
||||||
|
parentId?: string | null;
|
||||||
|
order: number;
|
||||||
|
tags: { id: string; name: string; color: string | null }[];
|
||||||
|
completedAt?: string | null;
|
||||||
|
estimatedMinutes?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Domain {
|
interface Domain {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ id: 'todo', title: 'To Do', color: 'bg-slate-500' },
|
{ id: 'todo', title: 'To Do', color: 'bg-slate-500' },
|
||||||
{ id: 'in_progress', title: 'In Progress', color: 'bg-blue-500' },
|
{ id: 'in_progress', title: 'In Progress', color: 'bg-blue-500' },
|
||||||
{ id: 'done', title: 'Done', color: 'bg-green-500' }
|
{ id: 'done', title: 'Done', color: 'bg-green-500' },
|
||||||
|
{ id: 'cancelled', title: 'Cancelled', color: 'bg-red-500' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function DraggableTask({
|
const priorityColors: Record<string, string> = {
|
||||||
|
urgent: 'destructive',
|
||||||
|
high: 'default',
|
||||||
|
medium: 'secondary',
|
||||||
|
low: 'secondary',
|
||||||
|
};
|
||||||
|
|
||||||
|
function TaskCard({
|
||||||
task,
|
task,
|
||||||
domainName,
|
domainName,
|
||||||
|
domainColor,
|
||||||
onClick,
|
onClick,
|
||||||
onStatusChange
|
|
||||||
}: {
|
}: {
|
||||||
task: Task;
|
task: Task;
|
||||||
domainName: string;
|
domainName: string;
|
||||||
|
domainColor: string | null;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
onStatusChange: (task: Task, status: Task['status']) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
const subtaskCount = 0; // Will be populated from API
|
||||||
useDraggable({
|
|
||||||
id: task.id,
|
|
||||||
data: { task }
|
|
||||||
});
|
|
||||||
|
|
||||||
const style = transform
|
|
||||||
? {
|
|
||||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Card className="mb-2 cursor-pointer hover:shadow-md transition-shadow" onClick={onClick}>
|
||||||
ref={setNodeRef}
|
<CardContent className="p-3">
|
||||||
style={style}
|
<div className="mb-2 flex items-start justify-between gap-2">
|
||||||
className={isDragging ? 'opacity-50' : ''}
|
<span className="flex-1 text-sm font-medium leading-tight">
|
||||||
>
|
{task.title}
|
||||||
<Card className="mb-2 hover:shadow-md transition-shadow">
|
</span>
|
||||||
<CardContent className="p-4">
|
</div>
|
||||||
<div className="mb-2 flex items-start justify-between gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<button
|
<Badge
|
||||||
onClick={(e) => {
|
variant={priorityColors[task.priority] as any || 'secondary'}
|
||||||
e.stopPropagation();
|
className="text-xs"
|
||||||
onClick();
|
|
||||||
}}
|
|
||||||
className="flex-1 text-left text-sm font-medium hover:underline"
|
|
||||||
>
|
|
||||||
{task.title}
|
|
||||||
</button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-11 w-11 shrink-0 cursor-grab active:cursor-grabbing"
|
|
||||||
aria-label={`Drag ${task.title}`}
|
|
||||||
{...listeners}
|
|
||||||
{...attributes}
|
|
||||||
>
|
|
||||||
<GripVertical className="h-4 w-4" aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
value={task.status}
|
|
||||||
onValueChange={(status) =>
|
|
||||||
onStatusChange(task, status as Task['status'])
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
{task.priority}
|
||||||
className="mb-2 h-9"
|
</Badge>
|
||||||
aria-label={`Move ${task.title} to a status`}
|
{domainColor && (
|
||||||
>
|
<span
|
||||||
<SelectValue />
|
className="inline-block h-2 w-2 rounded-full"
|
||||||
</SelectTrigger>
|
style={{ backgroundColor: domainColor }}
|
||||||
<SelectContent>
|
aria-hidden="true"
|
||||||
<SelectItem value="todo">To Do</SelectItem>
|
/>
|
||||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
)}
|
||||||
<SelectItem value="done">Done</SelectItem>
|
{domainName && (
|
||||||
</SelectContent>
|
<span className="text-xs text-muted-foreground">{domainName}</span>
|
||||||
</Select>
|
)}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
{task.dueDate && (
|
||||||
<Badge
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
variant={
|
<Calendar className="h-3 w-3" />
|
||||||
task.priority === 'urgent'
|
{new Date(task.dueDate).toLocaleDateString()}
|
||||||
? 'destructive'
|
</span>
|
||||||
: task.priority === 'high'
|
)}
|
||||||
? 'default'
|
{task.tags?.length > 0 && (
|
||||||
: 'secondary'
|
<div className="flex gap-1 flex-wrap">
|
||||||
}
|
{task.tags.slice(0, 3).map((tag) => (
|
||||||
className="text-xs"
|
<Badge
|
||||||
>
|
key={tag.id}
|
||||||
{task.priority}
|
variant="outline"
|
||||||
</Badge>
|
className="text-xs"
|
||||||
{domainName && (
|
style={tag.color ? { borderColor: tag.color, color: tag.color } : {}}
|
||||||
<Badge variant="outline" className="text-xs">
|
>
|
||||||
{domainName}
|
{tag.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
))}
|
||||||
{task.due_date && (
|
{task.tags.length > 3 && (
|
||||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">+{task.tags.length - 3}</span>
|
||||||
<Calendar className="h-3 w-3" />
|
)}
|
||||||
{new Date(task.due_date).toLocaleDateString()}
|
</div>
|
||||||
</span>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,71 +129,81 @@ function DroppableColumn({
|
|||||||
tasks,
|
tasks,
|
||||||
domainMap,
|
domainMap,
|
||||||
onTaskClick,
|
onTaskClick,
|
||||||
onStatusChange
|
onAddTask,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
color: string;
|
color: string;
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
domainMap: Map<string, string>;
|
domainMap: Map<string, { name: string; color: string | null }>;
|
||||||
onTaskClick: (task: Task) => void;
|
onTaskClick: (task: Task) => void;
|
||||||
onStatusChange: (task: Task, status: Task['status']) => void;
|
onAddTask: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { setNodeRef, isOver } = useDroppable({ id });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="mb-3 flex items-center justify-between gap-2">
|
||||||
<div className={`h-2 w-2 rounded-full ${color}`} aria-hidden="true" />
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="font-semibold">{title}</h3>
|
<div className={`h-2 w-2 rounded-full ${color}`} aria-hidden="true" />
|
||||||
<span className="text-sm text-muted-foreground">({tasks.length})</span>
|
<h3 className="font-semibold text-sm">{title}</h3>
|
||||||
|
<span className="text-sm text-muted-foreground">({tasks.length})</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={onAddTask}
|
||||||
|
aria-label={`Add task to ${title}`}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
|
||||||
role="list"
|
role="list"
|
||||||
aria-label={`${title} tasks (${tasks.length} items)`}
|
aria-label={`${title} tasks (${tasks.length} items)`}
|
||||||
className={`flex-1 rounded-lg border-2 border-dashed p-3 min-h-[400px] transition-colors ${
|
className="flex-1 rounded-lg border-2 border-dashed p-2 min-h-[200px] transition-colors border-muted"
|
||||||
isOver ? 'border-primary bg-primary/5' : 'border-muted'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{tasks.map((task) => (
|
{tasks.length === 0 ? (
|
||||||
<DraggableTask
|
<div className="flex h-full items-center justify-center">
|
||||||
key={task.id}
|
<p className="text-sm text-muted-foreground">No tasks</p>
|
||||||
task={task}
|
</div>
|
||||||
domainName={domainMap.get(task.domain) || task.domain}
|
) : (
|
||||||
onClick={() => onTaskClick(task)}
|
tasks.map((task) => (
|
||||||
onStatusChange={onStatusChange}
|
<TaskCard
|
||||||
/>
|
key={task.id}
|
||||||
))}
|
task={task}
|
||||||
|
domainName={domainMap.get(task.domainId)?.name || ''}
|
||||||
|
domainColor={domainMap.get(task.domainId)?.color || null}
|
||||||
|
onClick={() => onTaskClick(task)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TasksKanbanView() {
|
export function TasksKanbanView({
|
||||||
|
domainId,
|
||||||
|
onRefresh,
|
||||||
|
}: {
|
||||||
|
domainId: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
}) {
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
const [domainMap, setDomainMap] = useState<Map<string, string>>(new Map());
|
const [domainMap, setDomainMap] = useState<Map<string, { name: string; color: string | null }>>(new Map());
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
|
||||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||||
|
const { subscribe } = useRealtimeContext();
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
|
||||||
useSensor(KeyboardSensor, {
|
|
||||||
coordinateGetter: sortableKeyboardCoordinates
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchTasks = useCallback(async () => {
|
||||||
fetchTasks();
|
if (!domainId) return;
|
||||||
fetchDomains();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function fetchTasks() {
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/tasks?sort=-created');
|
const response = await fetch(`/api/domains/${domainId}/tasks?sort=order&limit=200`);
|
||||||
if (!response.ok) {
|
if (!response.ok) throw new Error('Unable to load tasks');
|
||||||
throw new Error('Unable to load tasks');
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setTasks(data.items || []);
|
setTasks(data.items || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -233,113 +212,105 @@ export function TasksKanbanView() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}, [domainId]);
|
||||||
|
|
||||||
async function fetchDomains() {
|
const fetchDomains = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/domains?sort=sort_order');
|
const response = await fetch('/api/domains?sort=sort_order');
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, { name: string; color: string | null }>();
|
||||||
for (const d of data.items || []) {
|
for (const d of data.items || []) {
|
||||||
map.set(d.id, d.name);
|
map.set(d.id, { name: d.name, color: d.color || null });
|
||||||
}
|
}
|
||||||
setDomainMap(map);
|
setDomainMap(map);
|
||||||
} catch {
|
} catch {
|
||||||
// Non-critical — domains will show as raw IDs
|
// Non-critical
|
||||||
}
|
}
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!domainId) return;
|
||||||
|
setLoading(true);
|
||||||
|
fetchTasks();
|
||||||
|
fetchDomains();
|
||||||
|
}, [domainId, fetchTasks, fetchDomains]);
|
||||||
|
|
||||||
|
// Subscribe to realtime updates
|
||||||
|
useEffect(() => {
|
||||||
|
if (!domainId) return;
|
||||||
|
const unsubscribe = subscribe(['task'], (event: any) => {
|
||||||
|
if (event.type === 'task') {
|
||||||
|
fetchTasks();
|
||||||
|
onRefresh?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
}, [domainId, subscribe, fetchTasks, onRefresh]);
|
||||||
|
|
||||||
async function handleDragEnd(event: DragEndEvent) {
|
async function handleDragEnd(event: DragEndEvent) {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (!over) {
|
if (!over) return;
|
||||||
setActiveTask(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const task = active.data.current?.task as Task;
|
const taskId = active.id as string;
|
||||||
const newStatus = over.id as Task['status'];
|
const newStatus = over.id as Task['status'];
|
||||||
|
const task = tasks.find((t) => t.id === taskId);
|
||||||
|
if (!task || task.status === newStatus) return;
|
||||||
|
|
||||||
if (task.status !== newStatus) {
|
// Optimistic update
|
||||||
await updateTaskStatus(task, newStatus);
|
setTasks((prev) =>
|
||||||
}
|
prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t))
|
||||||
setActiveTask(null);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragStart(event: DragStartEvent) {
|
|
||||||
const task = event.active.data.current?.task as Task;
|
|
||||||
setActiveTask(task);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateTaskStatus(task: Task, status: Task['status']) {
|
|
||||||
if (task.status === status) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/tasks/${task.id}`, {
|
const response = await fetch(`/api/domains/${domainId}/tasks/${taskId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ status })
|
body: JSON.stringify({ status: newStatus }),
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('Unable to move task');
|
if (!response.ok) throw new Error('Unable to move task');
|
||||||
|
|
||||||
await fetchTasks();
|
|
||||||
toast.success(
|
toast.success(
|
||||||
`Moved ${task.title} to ${columns.find((column) => column.id === status)?.title}`
|
`Moved "${task.title}" to ${columns.find((c) => c.id === newStatus)?.title}`
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update task status:', error);
|
console.error('Failed to update task status:', error);
|
||||||
toast.error(`Unable to move ${task.title}`);
|
toast.error(`Unable to move "${task.title}"`);
|
||||||
|
fetchTasks(); // Revert
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAddTask(status: string) {
|
||||||
|
// Open create dialog with pre-filled status
|
||||||
|
document.dispatchEvent(
|
||||||
|
new CustomEvent('open-create-task', { detail: { status, domainId } })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <p className="text-muted-foreground">Loading tasks...</p>;
|
return <p className="text-muted-foreground">Loading tasks...</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tasks.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="py-12 text-center">
|
|
||||||
<p className="text-muted-foreground">No tasks yet. Create your first task to get started!</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DndContext
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
sensors={sensors}
|
{columns.map((column) => (
|
||||||
onDragStart={handleDragStart}
|
<DroppableColumn
|
||||||
onDragEnd={handleDragEnd}
|
key={column.id}
|
||||||
onDragCancel={() => setActiveTask(null)}
|
id={column.id}
|
||||||
>
|
title={column.title}
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
color={column.color}
|
||||||
{columns.map((column) => (
|
tasks={tasks.filter((t) => t.status === column.id)}
|
||||||
<DroppableColumn
|
domainMap={domainMap}
|
||||||
key={column.id}
|
onTaskClick={setSelectedTask}
|
||||||
id={column.id}
|
onAddTask={() => handleAddTask(column.id)}
|
||||||
title={column.title}
|
/>
|
||||||
color={column.color}
|
))}
|
||||||
tasks={tasks.filter((t) => t.status === column.id)}
|
</div>
|
||||||
domainMap={domainMap}
|
|
||||||
onTaskClick={setSelectedTask}
|
|
||||||
onStatusChange={updateTaskStatus}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<DragOverlay>
|
|
||||||
{activeTask ? (
|
|
||||||
<Card className="rotate-3 shadow-xl">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="text-sm font-medium">{activeTask.title}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
</DragOverlay>
|
|
||||||
</DndContext>
|
|
||||||
|
|
||||||
{selectedTask && (
|
{selectedTask && (
|
||||||
<TaskDetailPanel
|
<TaskDetailPanel
|
||||||
task={selectedTask}
|
taskId={selectedTask.id}
|
||||||
|
domainId={domainId}
|
||||||
open={!!selectedTask}
|
open={!!selectedTask}
|
||||||
onOpenChange={(open) => !open && setSelectedTask(null)}
|
onOpenChange={(open) => !open && setSelectedTask(null)}
|
||||||
onUpdate={fetchTasks}
|
onUpdate={fetchTasks}
|
||||||
@@ -347,4 +318,4 @@ export function TasksKanbanView() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -32,33 +32,59 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { useRealtimeContext } from '@/components/realtime-provider';
|
||||||
|
|
||||||
interface Task {
|
interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string | null;
|
||||||
status: 'todo' | 'in_progress' | 'done';
|
status: 'todo' | 'in_progress' | 'done' | 'cancelled';
|
||||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||||
domain: string;
|
domainId: string;
|
||||||
due_date?: string;
|
dueDate?: string | null;
|
||||||
project_id?: string;
|
projectId?: string | null;
|
||||||
tags: string[];
|
order: number;
|
||||||
|
tags: { id: string; name: string; color: string | null }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TasksListView() {
|
const priorityColors: Record<string, string> = {
|
||||||
|
urgent: 'destructive',
|
||||||
|
high: 'default',
|
||||||
|
medium: 'secondary',
|
||||||
|
low: 'secondary',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TasksListView({
|
||||||
|
domainId,
|
||||||
|
onRefresh,
|
||||||
|
}: {
|
||||||
|
domainId: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
}) {
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [domainMap, setDomainMap] = useState<Map<string, string>>(new Map());
|
const [domainMap, setDomainMap] = useState<Map<string, string>>(new Map());
|
||||||
|
const { subscribe } = useRealtimeContext();
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchTasks = useCallback(async () => {
|
||||||
fetchTasks();
|
if (!domainId) return;
|
||||||
fetchDomains();
|
try {
|
||||||
}, []);
|
const response = await fetch(`/api/domains/${domainId}/tasks?sort=order&limit=200`);
|
||||||
|
if (!response.ok) throw new Error('Unable to load tasks');
|
||||||
|
const data = await response.json();
|
||||||
|
setTasks(data.items || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch tasks:', error);
|
||||||
|
toast.error('Unable to load tasks');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [domainId]);
|
||||||
|
|
||||||
async function fetchDomains() {
|
const fetchDomains = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/domains?sort=sort_order');
|
const res = await fetch('/api/domains?sort=sort_order');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -68,41 +94,43 @@ export function TasksListView() {
|
|||||||
setDomainMap(map);
|
setDomainMap(map);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
}
|
useEffect(() => {
|
||||||
|
if (!domainId) return;
|
||||||
|
setLoading(true);
|
||||||
|
fetchTasks();
|
||||||
|
fetchDomains();
|
||||||
|
}, [domainId, fetchTasks, fetchDomains]);
|
||||||
|
|
||||||
async function fetchTasks() {
|
// Subscribe to realtime updates
|
||||||
try {
|
useEffect(() => {
|
||||||
const response = await fetch('/api/tasks?sort=-created');
|
if (!domainId) return;
|
||||||
if (!response.ok) {
|
const unsubscribe = subscribe(['task'], (event: any) => {
|
||||||
throw new Error('Unable to load tasks');
|
if (event.type === 'task') {
|
||||||
|
fetchTasks();
|
||||||
|
onRefresh?.();
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
});
|
||||||
setTasks(data.items || []);
|
return unsubscribe;
|
||||||
} catch (error) {
|
}, [domainId, subscribe, fetchTasks, onRefresh]);
|
||||||
console.error('Failed to fetch tasks:', error);
|
|
||||||
toast.error('Unable to load tasks');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleTaskComplete(task: Task) {
|
async function toggleTaskComplete(task: Task) {
|
||||||
const newStatus = task.status === 'done' ? 'todo' : 'done';
|
const newStatus = task.status === 'done' ? 'todo' : 'done';
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/tasks/${task.id}`, {
|
const response = await fetch(`/api/domains/${domainId}/tasks/${task.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ status: newStatus })
|
body: JSON.stringify({ status: newStatus }),
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('Unable to update task');
|
if (!response.ok) throw new Error('Unable to update task');
|
||||||
await fetchTasks();
|
await fetchTasks();
|
||||||
toast.success(
|
toast.success(
|
||||||
`Marked ${task.title} as ${newStatus === 'done' ? 'complete' : 'incomplete'}`
|
`Marked "${task.title}" as ${newStatus === 'done' ? 'complete' : 'incomplete'}`
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to toggle task:', error);
|
console.error('Failed to toggle task:', error);
|
||||||
toast.error(`Unable to update ${task.title}`);
|
toast.error(`Unable to update "${task.title}"`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +155,7 @@ export function TasksListView() {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[50px]"></TableHead>
|
<TableHead className="w-[50px]"></TableHead>
|
||||||
<TableHead>Task</TableHead>
|
<TableHead>Task</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
<TableHead>Priority</TableHead>
|
<TableHead>Priority</TableHead>
|
||||||
<TableHead>Domain</TableHead>
|
<TableHead>Domain</TableHead>
|
||||||
<TableHead>Due Date</TableHead>
|
<TableHead>Due Date</TableHead>
|
||||||
@@ -155,27 +184,26 @@ export function TasksListView() {
|
|||||||
{task.title}
|
{task.title}
|
||||||
</button>
|
</button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="text-xs capitalize">
|
||||||
|
{task.status.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={(priorityColors[task.priority] as any) || 'secondary'}
|
||||||
task.priority === 'urgent'
|
|
||||||
? 'destructive'
|
|
||||||
: task.priority === 'high'
|
|
||||||
? 'default'
|
|
||||||
: 'secondary'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{task.priority}
|
{task.priority}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline">{domainMap.get(task.domain) || task.domain}</Badge>
|
<Badge variant="outline">{domainMap.get(task.domainId) || task.domainId.slice(0, 8)}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{task.due_date && (
|
{task.dueDate && (
|
||||||
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
<Calendar className="h-3 w-3" />
|
<Calendar className="h-3 w-3" />
|
||||||
{new Date(task.due_date).toLocaleDateString()}
|
{new Date(task.dueDate).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -219,18 +247,25 @@ export function TasksListView() {
|
|||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
|
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={async () => {
|
<AlertDialogAction
|
||||||
if (!deleteId) return;
|
onClick={async () => {
|
||||||
setDeleting(true);
|
if (!deleteId || !domainId) return;
|
||||||
try {
|
setDeleting(true);
|
||||||
const res = await fetch("/api/tasks/" + deleteId, { method: 'DELETE' });
|
try {
|
||||||
if (!res.ok) throw new Error();
|
const res = await fetch(`/api/domains/${domainId}/tasks/${deleteId}`, { method: 'DELETE' });
|
||||||
toast.success("Task deleted");
|
if (!res.ok) throw new Error();
|
||||||
setDeleteId(null);
|
toast.success('Task deleted');
|
||||||
fetchTasks();
|
setDeleteId(null);
|
||||||
} catch { toast.error("Unable to delete task"); }
|
fetchTasks();
|
||||||
finally { setDeleting(false); setDeleteId(null); }
|
} catch {
|
||||||
}} disabled={deleting}>
|
toast.error('Unable to delete task');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
setDeleteId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
{deleting ? 'Deleting...' : 'Delete'}
|
{deleting ? 'Deleting...' : 'Delete'}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
@@ -239,7 +274,8 @@ export function TasksListView() {
|
|||||||
|
|
||||||
{selectedTask && (
|
{selectedTask && (
|
||||||
<TaskDetailPanel
|
<TaskDetailPanel
|
||||||
task={selectedTask}
|
taskId={selectedTask.id}
|
||||||
|
domainId={domainId}
|
||||||
open={!!selectedTask}
|
open={!!selectedTask}
|
||||||
onOpenChange={(open) => !open && setSelectedTask(null)}
|
onOpenChange={(open) => !open && setSelectedTask(null)}
|
||||||
onUpdate={fetchTasks}
|
onUpdate={fetchTasks}
|
||||||
|
|||||||
@@ -66,6 +66,63 @@ export function useKeyboardShortcuts() {
|
|||||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }));
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }));
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
break;
|
break;
|
||||||
|
case 'c': {
|
||||||
|
// c t — new task (Cmd palette → "New task")
|
||||||
|
// Check if we're on the tasks page
|
||||||
|
if (window.location.pathname.startsWith('/tasks')) {
|
||||||
|
document.dispatchEvent(new CustomEvent('open-create-task', { detail: { status: 'todo' } }));
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'e': {
|
||||||
|
// e — edit selected task (when task is focused)
|
||||||
|
const focusedTask = document.querySelector('[data-task-id]:focus');
|
||||||
|
if (focusedTask) {
|
||||||
|
(focusedTask as HTMLElement).click();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'd': {
|
||||||
|
// d — delete selected task
|
||||||
|
const deleteBtn = document.querySelector('[data-delete-task]');
|
||||||
|
if (deleteBtn) {
|
||||||
|
(deleteBtn as HTMLElement).click();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ' ': {
|
||||||
|
// Space — open task detail panel
|
||||||
|
const firstTask = document.querySelector('[data-task-id]');
|
||||||
|
if (firstTask && window.location.pathname.startsWith('/tasks')) {
|
||||||
|
(firstTask as HTMLElement).click();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'escape': {
|
||||||
|
// Esc — close detail panel
|
||||||
|
const closeBtn = document.querySelector('[data-close-panel]');
|
||||||
|
if (closeBtn) {
|
||||||
|
(closeBtn as HTMLElement).click();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case '1':
|
||||||
|
case '2':
|
||||||
|
case '3':
|
||||||
|
case '4': {
|
||||||
|
// 1/2/3/4 — filter kanban column
|
||||||
|
if (window.location.pathname.startsWith('/tasks')) {
|
||||||
|
const statuses: Record<string, string> = { '1': 'todo', '2': 'in_progress', '3': 'done', '4': 'cancelled' };
|
||||||
|
document.dispatchEvent(new CustomEvent('filter-kanban', { detail: { status: statuses[key] } }));
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/** @type {import('jest').Config} */
|
||||||
|
const config = {
|
||||||
|
testEnvironment: 'node',
|
||||||
|
transform: {
|
||||||
|
'^.+\\.(t|j)sx?$': ['@swc/jest'],
|
||||||
|
},
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^@/(.*)$': '<rootDir>/$1',
|
||||||
|
'^@project-e/db$': '<rootDir>/../../packages/db/src',
|
||||||
|
'^@project-e/shared$': '<rootDir>/../../packages/shared/src',
|
||||||
|
},
|
||||||
|
testMatch: ['**/__tests__/**/*.test.(ts|tsx|js)'],
|
||||||
|
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
@@ -15,5 +15,5 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules", "__tests__"]
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user