feat(web): task management with list view and kanban board #86
72
apps/web/src/app/(dashboard)/tasks/page.tsx
Normal file
72
apps/web/src/app/(dashboard)/tasks/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Task } from '@/lib/types';
|
||||
import { KanbanBoard } from '@/components/tasks/kanban-board';
|
||||
import { TaskListView } from '@/components/tasks/task-list-view';
|
||||
|
||||
type ViewMode = 'list' | 'kanban';
|
||||
|
||||
export default function TasksPage(): React.ReactElement {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [view, setView] = useState<ViewMode>('kanban');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api<Task[]>('/api/tasks')
|
||||
.then(setTasks)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleTaskClick = useCallback((task: Task) => {
|
||||
// Task detail view will be added in future iteration
|
||||
console.log('Task clicked:', task.id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">Tasks</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex rounded-lg border border-surface-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView('list')}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-xs transition-colors',
|
||||
view === 'list'
|
||||
? 'bg-surface-elevated text-text-primary'
|
||||
: 'text-text-muted hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView('kanban')}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-xs transition-colors',
|
||||
view === 'kanban'
|
||||
? 'bg-surface-elevated text-text-primary'
|
||||
: 'text-text-muted hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
Kanban
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-text-muted">Loading tasks...</p>
|
||||
) : view === 'kanban' ? (
|
||||
<KanbanBoard tasks={tasks} onTaskClick={handleTaskClick} />
|
||||
) : (
|
||||
<TaskListView tasks={tasks} onTaskClick={handleTaskClick} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
apps/web/src/components/tasks/kanban-board.tsx
Normal file
47
apps/web/src/components/tasks/kanban-board.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import type { Task, TaskStatus } from '@/lib/types';
|
||||
import { TaskCard } from './task-card';
|
||||
|
||||
interface KanbanBoardProps {
|
||||
tasks: Task[];
|
||||
onTaskClick: (task: Task) => void;
|
||||
}
|
||||
|
||||
const columns: { id: TaskStatus; label: string }[] = [
|
||||
{ id: 'not-started', label: 'Not Started' },
|
||||
{ id: 'in-progress', label: 'In Progress' },
|
||||
{ id: 'blocked', label: 'Blocked' },
|
||||
{ id: 'done', label: 'Done' },
|
||||
];
|
||||
|
||||
export function KanbanBoard({ tasks, onTaskClick }: KanbanBoardProps): React.ReactElement {
|
||||
return (
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
{columns.map((col) => {
|
||||
const columnTasks = tasks.filter((t) => t.status === col.id);
|
||||
return (
|
||||
<div
|
||||
key={col.id}
|
||||
className="flex w-72 shrink-0 flex-col rounded-lg border border-surface-border bg-surface-elevated"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-surface-border px-3 py-2">
|
||||
<h3 className="text-sm font-medium text-text-secondary">{col.label}</h3>
|
||||
<span className="rounded-full bg-surface-card px-2 py-0.5 text-xs text-text-muted">
|
||||
{columnTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 p-2">
|
||||
{columnTasks.length === 0 && (
|
||||
<p className="py-4 text-center text-xs text-text-muted">No tasks</p>
|
||||
)}
|
||||
{columnTasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} onClick={onTaskClick} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
apps/web/src/components/tasks/task-card.tsx
Normal file
57
apps/web/src/components/tasks/task-card.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Task } from '@/lib/types';
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task;
|
||||
onClick: (task: Task) => void;
|
||||
}
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
critical: 'text-error',
|
||||
high: 'text-warning',
|
||||
medium: 'text-blue-400',
|
||||
low: 'text-text-muted',
|
||||
};
|
||||
|
||||
const statusBadgeColors: Record<string, string> = {
|
||||
'not-started': 'bg-gray-600/20 text-gray-300',
|
||||
'in-progress': 'bg-blue-600/20 text-blue-400',
|
||||
blocked: 'bg-error/20 text-error',
|
||||
done: 'bg-success/20 text-success',
|
||||
cancelled: 'bg-gray-600/20 text-gray-500',
|
||||
};
|
||||
|
||||
export function TaskCard({ task, onClick }: TaskCardProps): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick(task)}
|
||||
className="w-full rounded-lg border border-surface-border bg-surface-card p-3 text-left transition-colors hover:border-gray-500"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-sm font-medium text-text-primary">{task.title}</span>
|
||||
<span className={cn('text-xs', priorityColors[task.priority])}>{task.priority}</span>
|
||||
</div>
|
||||
{task.description && (
|
||||
<p className="mt-1 line-clamp-2 text-xs text-text-muted">{task.description}</p>
|
||||
)}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2 py-0.5 text-xs',
|
||||
statusBadgeColors[task.status] ?? 'bg-gray-600/20 text-gray-400',
|
||||
)}
|
||||
>
|
||||
{task.status}
|
||||
</span>
|
||||
{task.dueDate && (
|
||||
<span className="text-xs text-text-muted">
|
||||
{new Date(task.dueDate).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
67
apps/web/src/components/tasks/task-list-view.tsx
Normal file
67
apps/web/src/components/tasks/task-list-view.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Task } from '@/lib/types';
|
||||
|
||||
interface TaskListViewProps {
|
||||
tasks: Task[];
|
||||
onTaskClick: (task: Task) => void;
|
||||
}
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
critical: 'text-error',
|
||||
high: 'text-warning',
|
||||
medium: 'text-blue-400',
|
||||
low: 'text-text-muted',
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
'not-started': 'text-gray-400',
|
||||
'in-progress': 'text-blue-400',
|
||||
blocked: 'text-error',
|
||||
done: 'text-success',
|
||||
cancelled: 'text-gray-500',
|
||||
};
|
||||
|
||||
export function TaskListView({ tasks, onTaskClick }: TaskListViewProps): React.ReactElement {
|
||||
if (tasks.length === 0) {
|
||||
return <p className="py-8 text-center text-sm text-text-muted">No tasks found</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-surface-border">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-border bg-surface-elevated text-left text-xs text-text-muted">
|
||||
<th className="px-4 py-2 font-medium">Title</th>
|
||||
<th className="px-4 py-2 font-medium">Status</th>
|
||||
<th className="px-4 py-2 font-medium">Priority</th>
|
||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Due</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((task) => (
|
||||
<tr
|
||||
key={task.id}
|
||||
onClick={() => onTaskClick(task)}
|
||||
className="cursor-pointer border-b border-surface-border transition-colors last:border-b-0 hover:bg-surface-elevated"
|
||||
>
|
||||
<td className="px-4 py-3 text-sm text-text-primary">{task.title}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={cn('text-xs', statusColors[task.status])}>{task.status}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={cn('text-xs', priorityColors[task.priority])}>
|
||||
{task.priority}
|
||||
</span>
|
||||
</td>
|
||||
<td className="hidden px-4 py-3 text-xs text-text-muted md:table-cell">
|
||||
{task.dueDate ? new Date(task.dueDate).toLocaleDateString() : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,3 +17,41 @@ export interface Message {
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Task statuses. */
|
||||
export type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
|
||||
/** Task priorities. */
|
||||
export type TaskPriority = 'critical' | 'high' | 'medium' | 'low';
|
||||
|
||||
/** Task returned by the gateway API. */
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: TaskStatus;
|
||||
priority: TaskPriority;
|
||||
projectId: string | null;
|
||||
missionId: string | null;
|
||||
assignee: string | null;
|
||||
tags: string[] | null;
|
||||
dueDate: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Project statuses. */
|
||||
export type ProjectStatus = 'active' | 'paused' | 'completed' | 'archived';
|
||||
|
||||
/** Project returned by the gateway API. */
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: ProjectStatus;
|
||||
userId: string;
|
||||
metadata: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
| P2-007 | done | Phase 2 | Verify Phase 2 — multi-provider routing works | #79 | #25 |
|
||||
| P3-001 | done | Phase 3 | apps/web scaffold — Next.js 16 + BetterAuth + Tailwind | #82 | #26 |
|
||||
| P3-002 | done | Phase 3 | Auth pages — login, registration, SSO redirect | #83 | #27 |
|
||||
| P3-003 | in-progress | Phase 3 | Chat UI — conversations, messages, streaming | — | #28 |
|
||||
| P3-004 | not-started | Phase 3 | Task management — list view + kanban board | — | #29 |
|
||||
| P3-003 | done | Phase 3 | Chat UI — conversations, messages, streaming | #84 | #28 |
|
||||
| P3-004 | in-progress | Phase 3 | Task management — list view + kanban board | — | #29 |
|
||||
| P3-005 | not-started | Phase 3 | Project & mission views — dashboard + PRD viewer | — | #30 |
|
||||
| P3-006 | not-started | Phase 3 | Settings — provider config, profile, integrations | — | #31 |
|
||||
| P3-007 | not-started | Phase 3 | Admin panel — user management, RBAC | — | #32 |
|
||||
|
||||
Reference in New Issue
Block a user