feat: Phase 2 - Tasks CRUD API, kanban/list UI, dialogs, activity feed, keyboard shortcuts
- Tasks REST API under /api/domains/[domainId]/tasks/ with full CRUD, filtering, pagination - Complete/uncomplete endpoints - Bulk update endpoint for drag-to-reorder - Dependencies API with cycle detection - Tags API for task tagging - Activity feed API scoped to workspace - Updated kanban board view with 4 columns (todo/in_progress/done/cancelled) - Updated list view with status column and workspace-scoped API calls - Task create dialog with title, description, status, priority, due date, estimate - Task detail panel (sheet) with full edit capabilities - Task activity feed widget - Keyboard shortcuts: c t (new task), e (edit), d (delete), Space (open), Esc (close), 1-4 (filter) - All routes follow AGENTS.md contract: Drizzle writes + activity feed + pg_notify
This commit is contained in:
@@ -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,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -27,63 +27,108 @@ import {
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Calendar, Loader2 } from 'lucide-react';
|
||||
|
||||
interface Task {
|
||||
interface TaskDetail {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: 'todo' | 'in_progress' | 'done';
|
||||
description?: string | null;
|
||||
status: 'todo' | 'in_progress' | 'done' | 'cancelled';
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
domain: string;
|
||||
due_date?: string;
|
||||
project_id?: string;
|
||||
tags: string[];
|
||||
domainId: string;
|
||||
projectId?: string | null;
|
||||
sectionId?: string | null;
|
||||
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 {
|
||||
task: Task;
|
||||
taskId: string;
|
||||
domainId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
export function TaskDetailPanel({
|
||||
task,
|
||||
taskId,
|
||||
domainId,
|
||||
open,
|
||||
onOpenChange,
|
||||
onUpdate
|
||||
onUpdate,
|
||||
}: TaskDetailPanelProps) {
|
||||
const [title, setTitle] = useState(task.title);
|
||||
const [description, setDescription] = useState(task.description || '');
|
||||
const [status, setStatus] = useState(task.status);
|
||||
const [priority, setPriority] = useState(task.priority);
|
||||
const [domain, setDomain] = useState(task.domain);
|
||||
const [domains, setDomains] = useState<{id: string; name: string}[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/domains?sort=sort_order').then(r => r.json()).then(data => setDomains(data.items || [])).catch(() => {});
|
||||
}, []);
|
||||
const [dueDate, setDueDate] = useState(task.due_date || '');
|
||||
const [task, setTask] = useState<TaskDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [status, setStatus] = useState<'todo' | 'in_progress' | 'done' | 'cancelled'>('todo');
|
||||
const [priority, setPriority] = useState<'low' | 'medium' | 'high' | 'urgent'>('medium');
|
||||
const [dueDate, setDueDate] = useState('');
|
||||
const [estimatedMinutes, setEstimatedMinutes] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = 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() {
|
||||
if (!task || !domainId) return;
|
||||
setSaving(true);
|
||||
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',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
priority,
|
||||
domain,
|
||||
due_date: dueDate || undefined
|
||||
})
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) throw new Error('Unable to save task');
|
||||
onUpdate();
|
||||
@@ -98,10 +143,11 @@ export function TaskDetailPanel({
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!task || !domainId) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const response = await fetch(`/api/tasks/${task.id}`, {
|
||||
method: 'DELETE'
|
||||
const response = await fetch(`/api/domains/${domainId}/tasks/${task.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) throw new Error('Unable to delete task');
|
||||
onUpdate();
|
||||
@@ -123,114 +169,183 @@ export function TaskDetailPanel({
|
||||
<SheetTitle>Task Details</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-6 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Task title"
|
||||
/>
|
||||
{loading ? (
|
||||
<div className="mt-12 flex justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Add a description..."
|
||||
rows={4}
|
||||
/>
|
||||
) : !task ? (
|
||||
<div className="mt-12 text-center text-muted-foreground">
|
||||
<p>Task not found</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
) : (
|
||||
<div className="mt-6 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-status">Status</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>
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
id="task-due-date"
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Task title"
|
||||
/>
|
||||
</div>
|
||||
</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 className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Add a description..."
|
||||
rows={4}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete task?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This permanently deletes "{task.title}".
|
||||
This permanently deletes "{task?.title}".
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
KeyboardSensor,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
@@ -10,146 +9,116 @@ import {
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
useDraggable,
|
||||
useDroppable
|
||||
} from '@dnd-kit/core';
|
||||
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
import { Calendar, GripVertical } from 'lucide-react';
|
||||
import { Calendar, GripVertical, Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { TaskDetailPanel } from './task-detail-panel';
|
||||
import { useRealtimeContext } from '@/components/realtime-provider';
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: 'todo' | 'in_progress' | 'done';
|
||||
description?: string | null;
|
||||
status: 'todo' | 'in_progress' | 'done' | 'cancelled';
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
domain: string;
|
||||
due_date?: string;
|
||||
project_id?: string;
|
||||
tags: string[];
|
||||
domainId: string;
|
||||
dueDate?: string | null;
|
||||
projectId?: string | null;
|
||||
sectionId?: string | null;
|
||||
parentId?: string | null;
|
||||
order: number;
|
||||
tags: { id: string; name: string; color: string | null }[];
|
||||
completedAt?: string | null;
|
||||
estimatedMinutes?: number | null;
|
||||
}
|
||||
|
||||
interface Domain {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
color: string | null;
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ id: 'todo', title: 'To Do', color: 'bg-slate-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,
|
||||
domainName,
|
||||
domainColor,
|
||||
onClick,
|
||||
onStatusChange
|
||||
}: {
|
||||
task: Task;
|
||||
domainName: string;
|
||||
domainColor: string | null;
|
||||
onClick: () => void;
|
||||
onStatusChange: (task: Task, status: Task['status']) => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||
useDraggable({
|
||||
id: task.id,
|
||||
data: { task }
|
||||
});
|
||||
|
||||
const style = transform
|
||||
? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`
|
||||
}
|
||||
: undefined;
|
||||
const subtaskCount = 0; // Will be populated from API
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={isDragging ? 'opacity-50' : ''}
|
||||
>
|
||||
<Card className="mb-2 hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
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'])
|
||||
}
|
||||
<Card className="mb-2 cursor-pointer hover:shadow-md transition-shadow" onClick={onClick}>
|
||||
<CardContent className="p-3">
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<span className="flex-1 text-sm font-medium leading-tight">
|
||||
{task.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge
|
||||
variant={priorityColors[task.priority] as any || 'secondary'}
|
||||
className="text-xs"
|
||||
>
|
||||
<SelectTrigger
|
||||
className="mb-2 h-9"
|
||||
aria-label={`Move ${task.title} to a 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 className="flex items-center gap-2 flex-wrap">
|
||||
<Badge
|
||||
variant={
|
||||
task.priority === 'urgent'
|
||||
? 'destructive'
|
||||
: task.priority === 'high'
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
{domainName && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{domainName}
|
||||
</Badge>
|
||||
)}
|
||||
{task.due_date && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{new Date(task.due_date).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
{domainColor && (
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: domainColor }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{domainName && (
|
||||
<span className="text-xs text-muted-foreground">{domainName}</span>
|
||||
)}
|
||||
{task.dueDate && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{new Date(task.dueDate).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
{task.tags?.length > 0 && (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{task.tags.slice(0, 3).map((tag) => (
|
||||
<Badge
|
||||
key={tag.id}
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
style={tag.color ? { borderColor: tag.color, color: tag.color } : {}}
|
||||
>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
{task.tags.length > 3 && (
|
||||
<span className="text-xs text-muted-foreground">+{task.tags.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -160,71 +129,81 @@ function DroppableColumn({
|
||||
tasks,
|
||||
domainMap,
|
||||
onTaskClick,
|
||||
onStatusChange
|
||||
onAddTask,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
color: string;
|
||||
tasks: Task[];
|
||||
domainMap: Map<string, string>;
|
||||
domainMap: Map<string, { name: string; color: string | null }>;
|
||||
onTaskClick: (task: Task) => void;
|
||||
onStatusChange: (task: Task, status: Task['status']) => void;
|
||||
onAddTask: () => void;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<div className={`h-2 w-2 rounded-full ${color}`} aria-hidden="true" />
|
||||
<h3 className="font-semibold">{title}</h3>
|
||||
<span className="text-sm text-muted-foreground">({tasks.length})</span>
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`h-2 w-2 rounded-full ${color}`} aria-hidden="true" />
|
||||
<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
|
||||
ref={setNodeRef}
|
||||
role="list"
|
||||
aria-label={`${title} tasks (${tasks.length} items)`}
|
||||
className={`flex-1 rounded-lg border-2 border-dashed p-3 min-h-[400px] transition-colors ${
|
||||
isOver ? 'border-primary bg-primary/5' : 'border-muted'
|
||||
}`}
|
||||
className="flex-1 rounded-lg border-2 border-dashed p-2 min-h-[200px] transition-colors border-muted"
|
||||
>
|
||||
{tasks.map((task) => (
|
||||
<DraggableTask
|
||||
key={task.id}
|
||||
task={task}
|
||||
domainName={domainMap.get(task.domain) || task.domain}
|
||||
onClick={() => onTaskClick(task)}
|
||||
onStatusChange={onStatusChange}
|
||||
/>
|
||||
))}
|
||||
{tasks.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">No tasks</p>
|
||||
</div>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
domainName={domainMap.get(task.domainId)?.name || ''}
|
||||
domainColor={domainMap.get(task.domainId)?.color || null}
|
||||
onClick={() => onTaskClick(task)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TasksKanbanView() {
|
||||
export function TasksKanbanView({
|
||||
domainId,
|
||||
onRefresh,
|
||||
}: {
|
||||
domainId: string;
|
||||
onRefresh?: () => void;
|
||||
}) {
|
||||
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 [activeTask, setActiveTask] = useState<Task | null>(null);
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
const { subscribe } = useRealtimeContext();
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates
|
||||
})
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
fetchDomains();
|
||||
}, []);
|
||||
|
||||
async function fetchTasks() {
|
||||
const fetchTasks = useCallback(async () => {
|
||||
if (!domainId) return;
|
||||
try {
|
||||
const response = await fetch('/api/tasks?sort=-created');
|
||||
if (!response.ok) {
|
||||
throw new Error('Unable to load tasks');
|
||||
}
|
||||
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) {
|
||||
@@ -233,113 +212,105 @@ export function TasksKanbanView() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [domainId]);
|
||||
|
||||
async function fetchDomains() {
|
||||
const fetchDomains = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/domains?sort=sort_order');
|
||||
if (!response.ok) return;
|
||||
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 || []) {
|
||||
map.set(d.id, d.name);
|
||||
map.set(d.id, { name: d.name, color: d.color || null });
|
||||
}
|
||||
setDomainMap(map);
|
||||
} 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) {
|
||||
const { active, over } = event;
|
||||
if (!over) {
|
||||
setActiveTask(null);
|
||||
return;
|
||||
}
|
||||
if (!over) return;
|
||||
|
||||
const task = active.data.current?.task as Task;
|
||||
const taskId = active.id as string;
|
||||
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) {
|
||||
await updateTaskStatus(task, newStatus);
|
||||
}
|
||||
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;
|
||||
// Optimistic update
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t))
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/tasks/${task.id}`, {
|
||||
const response = await fetch(`/api/domains/${domainId}/tasks/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status })
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Unable to move task');
|
||||
|
||||
await fetchTasks();
|
||||
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) {
|
||||
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) {
|
||||
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 (
|
||||
<>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={() => setActiveTask(null)}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{columns.map((column) => (
|
||||
<DroppableColumn
|
||||
key={column.id}
|
||||
id={column.id}
|
||||
title={column.title}
|
||||
color={column.color}
|
||||
tasks={tasks.filter((t) => t.status === column.id)}
|
||||
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>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{columns.map((column) => (
|
||||
<DroppableColumn
|
||||
key={column.id}
|
||||
id={column.id}
|
||||
title={column.title}
|
||||
color={column.color}
|
||||
tasks={tasks.filter((t) => t.status === column.id)}
|
||||
domainMap={domainMap}
|
||||
onTaskClick={setSelectedTask}
|
||||
onAddTask={() => handleAddTask(column.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedTask && (
|
||||
<TaskDetailPanel
|
||||
task={selectedTask}
|
||||
taskId={selectedTask.id}
|
||||
domainId={domainId}
|
||||
open={!!selectedTask}
|
||||
onOpenChange={(open) => !open && setSelectedTask(null)}
|
||||
onUpdate={fetchTasks}
|
||||
@@ -347,4 +318,4 @@ export function TasksKanbanView() {
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -32,33 +32,59 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { useRealtimeContext } from '@/components/realtime-provider';
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: 'todo' | 'in_progress' | 'done';
|
||||
description?: string | null;
|
||||
status: 'todo' | 'in_progress' | 'done' | 'cancelled';
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
domain: string;
|
||||
due_date?: string;
|
||||
project_id?: string;
|
||||
tags: string[];
|
||||
domainId: string;
|
||||
dueDate?: string | null;
|
||||
projectId?: string | null;
|
||||
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 [loading, setLoading] = useState(true);
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [domainMap, setDomainMap] = useState<Map<string, string>>(new Map());
|
||||
const { subscribe } = useRealtimeContext();
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
fetchDomains();
|
||||
}, []);
|
||||
const fetchTasks = useCallback(async () => {
|
||||
if (!domainId) return;
|
||||
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 {
|
||||
const res = await fetch('/api/domains?sort=sort_order');
|
||||
if (res.ok) {
|
||||
@@ -68,41 +94,43 @@ export function TasksListView() {
|
||||
setDomainMap(map);
|
||||
}
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!domainId) return;
|
||||
setLoading(true);
|
||||
fetchTasks();
|
||||
fetchDomains();
|
||||
}, [domainId, fetchTasks, fetchDomains]);
|
||||
|
||||
async function fetchTasks() {
|
||||
try {
|
||||
const response = await fetch('/api/tasks?sort=-created');
|
||||
if (!response.ok) {
|
||||
throw new Error('Unable to load tasks');
|
||||
// Subscribe to realtime updates
|
||||
useEffect(() => {
|
||||
if (!domainId) return;
|
||||
const unsubscribe = subscribe(['task'], (event: any) => {
|
||||
if (event.type === 'task') {
|
||||
fetchTasks();
|
||||
onRefresh?.();
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [domainId, subscribe, fetchTasks, onRefresh]);
|
||||
|
||||
async function toggleTaskComplete(task: Task) {
|
||||
const newStatus = task.status === 'done' ? 'todo' : 'done';
|
||||
try {
|
||||
const response = await fetch(`/api/tasks/${task.id}`, {
|
||||
const response = await fetch(`/api/domains/${domainId}/tasks/${task.id}`, {
|
||||
method: 'PATCH',
|
||||
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');
|
||||
await fetchTasks();
|
||||
toast.success(
|
||||
`Marked ${task.title} as ${newStatus === 'done' ? 'complete' : 'incomplete'}`
|
||||
`Marked "${task.title}" as ${newStatus === 'done' ? 'complete' : 'incomplete'}`
|
||||
);
|
||||
} catch (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>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
<TableHead>Task</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Priority</TableHead>
|
||||
<TableHead>Domain</TableHead>
|
||||
<TableHead>Due Date</TableHead>
|
||||
@@ -155,27 +184,26 @@ export function TasksListView() {
|
||||
{task.title}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{task.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
task.priority === 'urgent'
|
||||
? 'destructive'
|
||||
: task.priority === 'high'
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
variant={(priorityColors[task.priority] as any) || 'secondary'}
|
||||
>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
</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>
|
||||
{task.due_date && (
|
||||
{task.dueDate && (
|
||||
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{new Date(task.due_date).toLocaleDateString()}
|
||||
{new Date(task.dueDate).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
@@ -219,18 +247,25 @@ export function TasksListView() {
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={async () => {
|
||||
if (!deleteId) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await fetch("/api/tasks/" + deleteId, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error();
|
||||
toast.success("Task deleted");
|
||||
setDeleteId(null);
|
||||
fetchTasks();
|
||||
} catch { toast.error("Unable to delete task"); }
|
||||
finally { setDeleting(false); setDeleteId(null); }
|
||||
}} disabled={deleting}>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
if (!deleteId || !domainId) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/domains/${domainId}/tasks/${deleteId}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error();
|
||||
toast.success('Task deleted');
|
||||
setDeleteId(null);
|
||||
fetchTasks();
|
||||
} catch {
|
||||
toast.error('Unable to delete task');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setDeleteId(null);
|
||||
}
|
||||
}}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
@@ -239,7 +274,8 @@ export function TasksListView() {
|
||||
|
||||
{selectedTask && (
|
||||
<TaskDetailPanel
|
||||
task={selectedTask}
|
||||
taskId={selectedTask.id}
|
||||
domainId={domainId}
|
||||
open={!!selectedTask}
|
||||
onOpenChange={(open) => !open && setSelectedTask(null)}
|
||||
onUpdate={fetchTasks}
|
||||
|
||||
Reference in New Issue
Block a user