Projects enhancements: inline editing, extended statuses, subtask interactions, kanban subtask view

- Inline task editing in TaskDetailPanel (replaces sheet-based edit flow)
- Extended task statuses: blocked, review, on_hold with color maps everywhere
- Click subtasks to navigate, delete subtasks from detail pane
- Kanban shows subtasks when a task with subtasks is selected
- Subtask sorting follows parent sort mode (priority/due_date)
- Progress bar on task rows showing subtask completion
- Default due date inheritance from parent task or project
- New status options in TaskForm select dropdown

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-22 12:04:10 +08:00
parent 4169c245c2
commit a11fcbcbcc
7 changed files with 383 additions and 117 deletions

View File

@ -3,7 +3,7 @@ from datetime import datetime, date
from typing import Optional, List, Literal from typing import Optional, List, Literal
from app.schemas.task_comment import TaskCommentResponse from app.schemas.task_comment import TaskCommentResponse
TaskStatus = Literal["pending", "in_progress", "completed"] TaskStatus = Literal["pending", "in_progress", "completed", "blocked", "review", "on_hold"]
TaskPriority = Literal["none", "low", "medium", "high"] TaskPriority = Literal["none", "low", "medium", "high"]

View File

@ -15,6 +15,9 @@ import { Badge } from '@/components/ui/badge';
const COLUMNS: { id: string; label: string; color: string }[] = [ const COLUMNS: { id: string; label: string; color: string }[] = [
{ id: 'pending', label: 'Pending', color: 'text-gray-400' }, { id: 'pending', label: 'Pending', color: 'text-gray-400' },
{ id: 'in_progress', label: 'In Progress', color: 'text-blue-400' }, { id: 'in_progress', label: 'In Progress', color: 'text-blue-400' },
{ id: 'blocked', label: 'Blocked', color: 'text-red-400' },
{ id: 'review', label: 'Review', color: 'text-yellow-400' },
{ id: 'on_hold', label: 'On Hold', color: 'text-orange-400' },
{ id: 'completed', label: 'Completed', color: 'text-green-400' }, { id: 'completed', label: 'Completed', color: 'text-green-400' },
]; ];
@ -28,6 +31,7 @@ const priorityColors: Record<string, string> = {
interface KanbanBoardProps { interface KanbanBoardProps {
tasks: ProjectTask[]; tasks: ProjectTask[];
selectedTaskId: number | null; selectedTaskId: number | null;
selectedTask?: ProjectTask | null;
onSelectTask: (taskId: number) => void; onSelectTask: (taskId: number) => void;
onStatusChange: (taskId: number, status: string) => void; onStatusChange: (taskId: number, status: string) => void;
} }
@ -119,7 +123,7 @@ function KanbanCard({
<p className="text-sm font-medium leading-tight mb-2">{task.title}</p> <p className="text-sm font-medium leading-tight mb-2">{task.title}</p>
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
<Badge <Badge
className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[task.priority]}`} className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[task.priority] ?? priorityColors.none}`}
> >
{task.priority} {task.priority}
</Badge> </Badge>
@ -141,6 +145,7 @@ function KanbanCard({
export default function KanbanBoard({ export default function KanbanBoard({
tasks, tasks,
selectedTaskId, selectedTaskId,
selectedTask,
onSelectTask, onSelectTask,
onStatusChange, onStatusChange,
}: KanbanBoardProps) { }: KanbanBoardProps) {
@ -148,6 +153,10 @@ export default function KanbanBoard({
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
); );
// When a task is selected and has subtasks, show subtask kanban
const isSubtaskView = selectedTask != null && (selectedTask.subtasks?.length ?? 0) > 0;
const activeTasks: ProjectTask[] = isSubtaskView ? (selectedTask.subtasks ?? []) : tasks;
const handleDragEnd = (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
if (!over) return; if (!over) return;
@ -155,8 +164,7 @@ export default function KanbanBoard({
const taskId = active.id as number; const taskId = active.id as number;
const newStatus = over.id as string; const newStatus = over.id as string;
// Only change if dropped on a different column const task = activeTasks.find((t) => t.id === taskId);
const task = tasks.find((t) => t.id === taskId);
if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) { if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) {
onStatusChange(taskId, newStatus); onStatusChange(taskId, newStatus);
} }
@ -164,26 +172,44 @@ export default function KanbanBoard({
const tasksByStatus = COLUMNS.map((col) => ({ const tasksByStatus = COLUMNS.map((col) => ({
column: col, column: col,
tasks: tasks.filter((t) => t.status === col.id), tasks: activeTasks.filter((t) => t.status === col.id),
})); }));
return ( return (
<DndContext <div className="flex flex-col gap-3">
sensors={sensors} {/* Subtask view header */}
collisionDetection={closestCorners} {isSubtaskView && (
onDragEnd={handleDragEnd} <div className="flex items-center gap-3 px-1">
> <button
<div className="flex gap-3 overflow-x-auto pb-2"> onClick={() => onSelectTask(selectedTask.id)}
{tasksByStatus.map(({ column, tasks: colTasks }) => ( className="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
<KanbanColumn >
key={column.id} Back to all tasks
column={column} </button>
tasks={colTasks} <span className="text-muted-foreground text-xs">/</span>
selectedTaskId={selectedTaskId} <span className="text-xs text-foreground font-medium">
onSelectTask={onSelectTask} Subtasks of: {selectedTask.title}
/> </span>
))} </div>
</div> )}
</DndContext>
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragEnd={handleDragEnd}
>
<div className="flex gap-3 overflow-x-auto pb-2">
{tasksByStatus.map(({ column, tasks: colTasks }) => (
<KanbanColumn
key={column.id}
column={column}
tasks={colTasks}
selectedTaskId={selectedTaskId}
onSelectTask={onSelectTask}
/>
))}
</div>
</DndContext>
</div>
); );
} }

View File

@ -209,12 +209,34 @@ export default function ProjectDetail() {
[allTasks] [allTasks]
); );
const sortSubtasks = useCallback(
(subtasks: ProjectTask[]): ProjectTask[] => {
if (sortMode === 'manual') return subtasks;
const sorted = [...subtasks];
if (sortMode === 'priority') {
sorted.sort((a, b) => (PRIORITY_ORDER[a.priority] ?? 3) - (PRIORITY_ORDER[b.priority] ?? 3));
} else if (sortMode === 'due_date') {
sorted.sort((a, b) => {
if (!a.due_date && !b.due_date) return 0;
if (!a.due_date) return 1;
if (!b.due_date) return -1;
return a.due_date.localeCompare(b.due_date);
});
}
return sorted;
},
[sortMode]
);
const sortedTasks = useMemo(() => { const sortedTasks = useMemo(() => {
const tasks = [...topLevelTasks]; const tasks = [...topLevelTasks].map((t) => ({
...t,
subtasks: sortSubtasks(t.subtasks || []),
}));
switch (sortMode) { switch (sortMode) {
case 'priority': case 'priority':
return tasks.sort( return tasks.sort(
(a, b) => (PRIORITY_ORDER[a.priority] ?? 1) - (PRIORITY_ORDER[b.priority] ?? 1) (a, b) => (PRIORITY_ORDER[a.priority] ?? 3) - (PRIORITY_ORDER[b.priority] ?? 3)
); );
case 'due_date': case 'due_date':
return tasks.sort((a, b) => { return tasks.sort((a, b) => {
@ -227,7 +249,7 @@ export default function ProjectDetail() {
default: default:
return tasks.sort((a, b) => a.sort_order - b.sort_order); return tasks.sort((a, b) => a.sort_order - b.sort_order);
} }
}, [topLevelTasks, sortMode]); }, [topLevelTasks, sortMode, sortSubtasks]);
const selectedTask = useMemo(() => { const selectedTask = useMemo(() => {
if (!selectedTaskId) return null; if (!selectedTaskId) return null;
@ -494,6 +516,7 @@ export default function ProjectDetail() {
<KanbanBoard <KanbanBoard
tasks={topLevelTasks} tasks={topLevelTasks}
selectedTaskId={selectedTaskId} selectedTaskId={selectedTaskId}
selectedTask={selectedTask}
onSelectTask={(taskId) => setSelectedTaskId(taskId)} onSelectTask={(taskId) => setSelectedTaskId(taskId)}
onStatusChange={(taskId, status) => onStatusChange={(taskId, status) =>
updateTaskStatusMutation.mutate({ taskId, status }) updateTaskStatusMutation.mutate({ taskId, status })
@ -572,10 +595,10 @@ export default function ProjectDetail() {
<TaskDetailPanel <TaskDetailPanel
task={selectedTask} task={selectedTask}
projectId={parseInt(id!)} projectId={parseInt(id!)}
onEdit={(task) => openTaskForm(task, null)}
onDelete={handleDeleteTask} onDelete={handleDeleteTask}
onAddSubtask={(parentId) => openTaskForm(null, parentId)} onAddSubtask={(parentId) => openTaskForm(null, parentId)}
onClose={() => setSelectedTaskId(null)} onClose={() => setSelectedTaskId(null)}
onSelectTask={setSelectedTaskId}
/> />
</div> </div>
</div> </div>
@ -601,10 +624,10 @@ export default function ProjectDetail() {
<TaskDetailPanel <TaskDetailPanel
task={selectedTask} task={selectedTask}
projectId={parseInt(id!)} projectId={parseInt(id!)}
onEdit={(task) => openTaskForm(task, null)}
onDelete={handleDeleteTask} onDelete={handleDeleteTask}
onAddSubtask={(parentId) => openTaskForm(null, parentId)} onAddSubtask={(parentId) => openTaskForm(null, parentId)}
onClose={() => setSelectedTaskId(null)} onClose={() => setSelectedTaskId(null)}
onSelectTask={setSelectedTaskId}
/> />
</div> </div>
</div> </div>
@ -616,6 +639,11 @@ export default function ProjectDetail() {
projectId={parseInt(id!)} projectId={parseInt(id!)}
task={editingTask} task={editingTask}
parentTaskId={subtaskParentId} parentTaskId={subtaskParentId}
defaultDueDate={
subtaskParentId
? allTasks.find((t) => t.id === subtaskParentId)?.due_date
: project?.due_date
}
onClose={closeTaskForm} onClose={closeTaskForm}
/> />
)} )}

View File

@ -4,7 +4,7 @@ import { toast } from 'sonner';
import { format, formatDistanceToNow, parseISO } from 'date-fns'; import { format, formatDistanceToNow, parseISO } from 'date-fns';
import { import {
Pencil, Trash2, Plus, MessageSquare, ClipboardList, Pencil, Trash2, Plus, MessageSquare, ClipboardList,
Calendar, User, Flag, Activity, Send, X, Calendar, User, Flag, Activity, Send, X, Save,
} from 'lucide-react'; } from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import type { ProjectTask, TaskComment, Person } from '@/types'; import type { ProjectTask, TaskComment, Person } from '@/types';
@ -12,17 +12,25 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { Select } from '@/components/ui/select';
const taskStatusColors: Record<string, string> = { const taskStatusColors: Record<string, string> = {
pending: 'bg-gray-500/10 text-gray-400 border-gray-500/20', pending: 'bg-gray-500/10 text-gray-400 border-gray-500/20',
in_progress: 'bg-blue-500/10 text-blue-400 border-blue-500/20', in_progress: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
completed: 'bg-green-500/10 text-green-400 border-green-500/20', completed: 'bg-green-500/10 text-green-400 border-green-500/20',
blocked: 'bg-red-500/10 text-red-400 border-red-500/20',
review: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
on_hold: 'bg-orange-500/10 text-orange-400 border-orange-500/20',
}; };
const taskStatusLabels: Record<string, string> = { const taskStatusLabels: Record<string, string> = {
pending: 'Pending', pending: 'Pending',
in_progress: 'In Progress', in_progress: 'In Progress',
completed: 'Completed', completed: 'Completed',
blocked: 'Blocked',
review: 'Review',
on_hold: 'On Hold',
}; };
const priorityColors: Record<string, string> = { const priorityColors: Record<string, string> = {
@ -35,22 +43,47 @@ const priorityColors: Record<string, string> = {
interface TaskDetailPanelProps { interface TaskDetailPanelProps {
task: ProjectTask | null; task: ProjectTask | null;
projectId: number; projectId: number;
onEdit: (task: ProjectTask) => void;
onDelete: (taskId: number) => void; onDelete: (taskId: number) => void;
onAddSubtask: (parentId: number) => void; onAddSubtask: (parentId: number) => void;
onClose?: () => void; onClose?: () => void;
onSelectTask?: (taskId: number) => void;
}
interface EditState {
title: string;
status: string;
priority: string;
due_date: string;
person_id: string;
description: string;
}
function buildEditState(task: ProjectTask): EditState {
return {
title: task.title,
status: task.status,
priority: task.priority,
// Slice to YYYY-MM-DD for date input; backend may return full ISO string
due_date: task.due_date ? task.due_date.slice(0, 10) : '',
person_id: task.person_id != null ? String(task.person_id) : '',
description: task.description ?? '',
};
} }
export default function TaskDetailPanel({ export default function TaskDetailPanel({
task, task,
projectId, projectId,
onEdit,
onDelete, onDelete,
onAddSubtask, onAddSubtask,
onClose, onClose,
onSelectTask,
}: TaskDetailPanelProps) { }: TaskDetailPanelProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [commentText, setCommentText] = useState(''); const [commentText, setCommentText] = useState('');
const [isEditing, setIsEditing] = useState(false);
const [editState, setEditState] = useState<EditState>(() =>
task ? buildEditState(task) : { title: '', status: 'pending', priority: 'none', due_date: '', person_id: '', description: '' }
);
const { data: people = [] } = useQuery({ const { data: people = [] } = useQuery({
queryKey: ['people'], queryKey: ['people'],
@ -60,12 +93,12 @@ export default function TaskDetailPanel({
}, },
}); });
// --- Mutations ---
const toggleSubtaskMutation = useMutation({ const toggleSubtaskMutation = useMutation({
mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => { mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => {
const newStatus = status === 'completed' ? 'pending' : 'completed'; const newStatus = status === 'completed' ? 'pending' : 'completed';
const { data } = await api.put(`/projects/${projectId}/tasks/${taskId}`, { const { data } = await api.put(`/projects/${projectId}/tasks/${taskId}`, { status: newStatus });
status: newStatus,
});
return data; return data;
}, },
onSuccess: () => { onSuccess: () => {
@ -73,6 +106,34 @@ export default function TaskDetailPanel({
}, },
}); });
const updateTaskMutation = useMutation({
mutationFn: async (payload: Record<string, unknown>) => {
const { data } = await api.put(`/projects/${projectId}/tasks/${task!.id}`, payload);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
setIsEditing(false);
toast.success('Task updated');
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to update task'));
},
});
const deleteSubtaskMutation = useMutation({
mutationFn: async (subtaskId: number) => {
await api.delete(`/projects/${projectId}/tasks/${subtaskId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
toast.success('Subtask deleted');
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to delete subtask'));
},
});
const addCommentMutation = useMutation({ const addCommentMutation = useMutation({
mutationFn: async (content: string) => { mutationFn: async (content: string) => {
const { data } = await api.post<TaskComment>( const { data } = await api.post<TaskComment>(
@ -100,12 +161,45 @@ export default function TaskDetailPanel({
}, },
}); });
// --- Handlers ---
const handleAddComment = () => { const handleAddComment = () => {
const trimmed = commentText.trim(); const trimmed = commentText.trim();
if (!trimmed) return; if (!trimmed) return;
addCommentMutation.mutate(trimmed); addCommentMutation.mutate(trimmed);
}; };
const handleEditStart = () => {
if (!task) return;
setEditState(buildEditState(task));
setIsEditing(true);
};
const handleEditCancel = () => {
setIsEditing(false);
if (task) setEditState(buildEditState(task));
};
const handleEditSave = () => {
if (!task) return;
const payload: Record<string, unknown> = {
title: editState.title.trim() || task.title,
status: editState.status,
priority: editState.priority,
due_date: editState.due_date || null,
person_id: editState.person_id ? Number(editState.person_id) : null,
description: editState.description || null,
};
updateTaskMutation.mutate(payload);
};
const handleDeleteSubtask = (subtaskId: number, subtaskTitle: string) => {
if (!window.confirm(`Delete subtask "${subtaskTitle}"?`)) return;
deleteSubtaskMutation.mutate(subtaskId);
};
// --- Empty state ---
if (!task) { if (!task) {
return ( return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground"> <div className="flex flex-col items-center justify-center h-full text-muted-foreground">
@ -115,10 +209,7 @@ export default function TaskDetailPanel({
); );
} }
const assignedPerson = task.person_id const assignedPerson = task.person_id ? people.find((p) => p.id === task.person_id) : null;
? people.find((p) => p.id === task.person_id)
: null;
const comments = task.comments || []; const comments = task.comments || [];
return ( return (
@ -126,38 +217,72 @@ export default function TaskDetailPanel({
{/* Header */} {/* Header */}
<div className="px-5 py-4 border-b border-border shrink-0"> <div className="px-5 py-4 border-b border-border shrink-0">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<h3 className="font-heading text-lg font-semibold leading-tight"> {isEditing ? (
{task.title} <Input
</h3> value={editState.title}
onChange={(e) => setEditState((s) => ({ ...s, title: e.target.value }))}
className="h-8 text-base font-semibold flex-1"
autoFocus
/>
) : (
<h3 className="font-heading text-lg font-semibold leading-tight">{task.title}</h3>
)}
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<Button {isEditing ? (
variant="ghost" <>
size="icon" <Button
className="h-7 w-7" variant="ghost"
onClick={() => onEdit(task)} size="icon"
title="Edit task" className="h-7 w-7 text-green-400 hover:text-green-300"
> onClick={handleEditSave}
<Pencil className="h-3.5 w-3.5" /> disabled={updateTaskMutation.isPending}
</Button> title="Save changes"
<Button >
variant="ghost" <Save className="h-3.5 w-3.5" />
size="icon" </Button>
className="h-7 w-7 text-muted-foreground hover:text-destructive" <Button
onClick={() => onDelete(task.id)} variant="ghost"
title="Delete task" size="icon"
> className="h-7 w-7"
<Trash2 className="h-3.5 w-3.5" /> onClick={handleEditCancel}
</Button> title="Cancel editing"
{onClose && ( >
<Button <X className="h-3.5 w-3.5" />
variant="ghost" </Button>
size="icon" </>
className="h-7 w-7" ) : (
onClick={onClose} <>
title="Close panel" <Button
> variant="ghost"
<X className="h-3.5 w-3.5" /> size="icon"
</Button> className="h-7 w-7"
onClick={handleEditStart}
title="Edit task"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => onDelete(task.id)}
title="Delete task"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
{onClose && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onClose}
title="Close panel"
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</>
)} )}
</div> </div>
</div> </div>
@ -167,62 +292,121 @@ export default function TaskDetailPanel({
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5"> <div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
{/* Fields grid */} {/* Fields grid */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{/* Status */}
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider"> <div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Activity className="h-3 w-3" /> <Activity className="h-3 w-3" />
Status Status
</div> </div>
<Badge className={`text-[9px] px-1.5 py-0.5 ${taskStatusColors[task.status]}`}> {isEditing ? (
{taskStatusLabels[task.status]} <Select
</Badge> value={editState.status}
onChange={(e) => setEditState((s) => ({ ...s, status: e.target.value }))}
className="h-8 text-xs"
>
<option value="pending">Pending</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
<option value="blocked">Blocked</option>
<option value="review">Review</option>
<option value="on_hold">On Hold</option>
</Select>
) : (
<Badge className={`text-[9px] px-1.5 py-0.5 ${taskStatusColors[task.status] ?? ''}`}>
{taskStatusLabels[task.status] ?? task.status}
</Badge>
)}
</div> </div>
{/* Priority */}
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider"> <div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Flag className="h-3 w-3" /> <Flag className="h-3 w-3" />
Priority Priority
</div> </div>
<Badge {isEditing ? (
className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[task.priority]}`} <Select
> value={editState.priority}
{task.priority} onChange={(e) => setEditState((s) => ({ ...s, priority: e.target.value }))}
</Badge> className="h-8 text-xs"
>
<option value="none">None</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</Select>
) : (
<Badge className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[task.priority] ?? ''}`}>
{task.priority}
</Badge>
)}
</div> </div>
{/* Due Date */}
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider"> <div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Calendar className="h-3 w-3" /> <Calendar className="h-3 w-3" />
Due Date Due Date
</div> </div>
<p className="text-sm"> {isEditing ? (
{task.due_date <Input
? format(parseISO(task.due_date), 'MMM d, yyyy') type="date"
: '—'} value={editState.due_date}
</p> onChange={(e) => setEditState((s) => ({ ...s, due_date: e.target.value }))}
className="h-8 text-xs"
/>
) : (
<p className="text-sm">
{task.due_date ? format(parseISO(task.due_date), 'MMM d, yyyy') : '—'}
</p>
)}
</div> </div>
{/* Assigned */}
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider"> <div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<User className="h-3 w-3" /> <User className="h-3 w-3" />
Assigned Assigned
</div> </div>
<p className="text-sm"> {isEditing ? (
{assignedPerson ? assignedPerson.name : '—'} <Select
</p> value={editState.person_id}
onChange={(e) => setEditState((s) => ({ ...s, person_id: e.target.value }))}
className="h-8 text-xs"
>
<option value="">Unassigned</option>
{people.map((p) => (
<option key={p.id} value={String(p.id)}>
{p.name}
</option>
))}
</Select>
) : (
<p className="text-sm">{assignedPerson ? assignedPerson.name : '—'}</p>
)}
</div> </div>
</div> </div>
{/* Description */} {/* Description */}
{task.description && ( {isEditing ? (
<div className="space-y-1.5"> <div className="space-y-1.5">
<h4 className="text-[11px] text-muted-foreground uppercase tracking-wider"> <h4 className="text-[11px] text-muted-foreground uppercase tracking-wider">Description</h4>
Description <Textarea
</h4> value={editState.description}
onChange={(e) => setEditState((s) => ({ ...s, description: e.target.value }))}
placeholder="Add a description..."
rows={3}
className="text-sm resize-none"
/>
</div>
) : task.description ? (
<div className="space-y-1.5">
<h4 className="text-[11px] text-muted-foreground uppercase tracking-wider">Description</h4>
<p className="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap"> <p className="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">
{task.description} {task.description}
</p> </p>
</div> </div>
)} ) : null}
{/* Subtasks */} {/* Subtasks */}
<div className="space-y-2"> <div className="space-y-2">
@ -236,6 +420,7 @@ export default function TaskDetailPanel({
</span> </span>
)} )}
</h4> </h4>
{/* Hide "Add subtask" for subtasks (tasks that already have a parent) */}
{!task.parent_task_id && ( {!task.parent_task_id && (
<Button <Button
variant="ghost" variant="ghost"
@ -248,39 +433,54 @@ export default function TaskDetailPanel({
</Button> </Button>
)} )}
</div> </div>
{task.subtasks.length > 0 ? ( {task.subtasks.length > 0 ? (
<div className="space-y-1"> <div className="space-y-1">
{task.subtasks.map((subtask) => ( {task.subtasks.map((subtask) => (
<div <div
key={subtask.id} key={subtask.id}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150" className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150 cursor-pointer group"
onClick={() => onSelectTask?.(subtask.id)}
> >
<Checkbox {/* Checkbox stops propagation so clicking it doesn't navigate */}
checked={subtask.status === 'completed'} <span onClick={(e) => e.stopPropagation()}>
onChange={() => <Checkbox
toggleSubtaskMutation.mutate({ checked={subtask.status === 'completed'}
taskId: subtask.id, onChange={() =>
status: subtask.status, toggleSubtaskMutation.mutate({
}) taskId: subtask.id,
} status: subtask.status,
disabled={toggleSubtaskMutation.isPending} })
/> }
disabled={toggleSubtaskMutation.isPending}
/>
</span>
<span <span
className={`text-sm flex-1 ${ className={`text-sm flex-1 ${
subtask.status === 'completed' subtask.status === 'completed' ? 'line-through text-muted-foreground' : ''
? 'line-through text-muted-foreground'
: ''
}`} }`}
> >
{subtask.title} {subtask.title}
</span> </span>
<Badge <Badge
className={`text-[9px] px-1.5 py-0.5 rounded-full ${ className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[subtask.priority] ?? ''}`}
priorityColors[subtask.priority]
}`}
> >
{subtask.priority} {subtask.priority}
</Badge> </Badge>
{/* Delete subtask — stops propagation to avoid navigation */}
<Button
variant="ghost"
size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive shrink-0"
onClick={(e) => {
e.stopPropagation();
handleDeleteSubtask(subtask.id, subtask.title);
}}
disabled={deleteSubtaskMutation.isPending}
title="Delete subtask"
>
<Trash2 className="h-3 w-3" />
</Button>
</div> </div>
))} ))}
</div> </div>
@ -305,16 +505,11 @@ export default function TaskDetailPanel({
{comments.length > 0 && ( {comments.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
{comments.map((comment) => ( {comments.map((comment) => (
<div <div key={comment.id} className="group rounded-md bg-secondary/50 px-3 py-2">
key={comment.id}
className="group rounded-md bg-secondary/50 px-3 py-2"
>
<p className="text-sm whitespace-pre-wrap">{comment.content}</p> <p className="text-sm whitespace-pre-wrap">{comment.content}</p>
<div className="flex items-center justify-between mt-1.5"> <div className="flex items-center justify-between mt-1.5">
<span className="text-[11px] text-muted-foreground"> <span className="text-[11px] text-muted-foreground">
{formatDistanceToNow(parseISO(comment.created_at), { {formatDistanceToNow(parseISO(comment.created_at), { addSuffix: true })}
addSuffix: true,
})}
</span> </span>
<Button <Button
variant="ghost" variant="ghost"

View File

@ -21,17 +21,18 @@ interface TaskFormProps {
projectId: number; projectId: number;
task: ProjectTask | null; task: ProjectTask | null;
parentTaskId?: number | null; parentTaskId?: number | null;
defaultDueDate?: string;
onClose: () => void; onClose: () => void;
} }
export default function TaskForm({ projectId, task, parentTaskId, onClose }: TaskFormProps) { export default function TaskForm({ projectId, task, parentTaskId, defaultDueDate, onClose }: TaskFormProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
title: task?.title || '', title: task?.title || '',
description: task?.description || '', description: task?.description || '',
status: task?.status || 'pending', status: task?.status || 'pending',
priority: task?.priority || 'medium', priority: task?.priority || 'medium',
due_date: task?.due_date ? task.due_date.slice(0, 10) : '', due_date: task?.due_date ? task.due_date.slice(0, 10) : (!task && defaultDueDate ? defaultDueDate.slice(0, 10) : ''),
person_id: task?.person_id?.toString() || '', person_id: task?.person_id?.toString() || '',
}); });
@ -129,6 +130,9 @@ export default function TaskForm({ projectId, task, parentTaskId, onClose }: Tas
<option value="pending">Pending</option> <option value="pending">Pending</option>
<option value="in_progress">In Progress</option> <option value="in_progress">In Progress</option>
<option value="completed">Completed</option> <option value="completed">Completed</option>
<option value="blocked">Blocked</option>
<option value="review">Review</option>
<option value="on_hold">On Hold</option>
</Select> </Select>
</div> </div>

View File

@ -8,6 +8,9 @@ const taskStatusColors: Record<string, string> = {
pending: 'bg-gray-500/10 text-gray-400 border-gray-500/20', pending: 'bg-gray-500/10 text-gray-400 border-gray-500/20',
in_progress: 'bg-blue-500/10 text-blue-400 border-blue-500/20', in_progress: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
completed: 'bg-green-500/10 text-green-400 border-green-500/20', completed: 'bg-green-500/10 text-green-400 border-green-500/20',
blocked: 'bg-red-500/10 text-red-400 border-red-500/20',
review: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
on_hold: 'bg-orange-500/10 text-orange-400 border-orange-500/20',
}; };
const priorityColors: Record<string, string> = { const priorityColors: Record<string, string> = {
@ -49,7 +52,7 @@ export default function TaskRow({
return ( return (
<div <div
className={`flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-colors duration-150 ${ className={`relative flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-colors duration-150 ${
isSelected isSelected
? 'bg-accent/5 border-l-2 border-accent' ? 'bg-accent/5 border-l-2 border-accent'
: 'border-l-2 border-transparent hover:bg-card-elevated' : 'border-l-2 border-transparent hover:bg-card-elevated'
@ -130,6 +133,16 @@ export default function TaskRow({
}`}> }`}>
{hasSubtasks ? `${completedSubtasks}/${task.subtasks.length}` : '—'} {hasSubtasks ? `${completedSubtasks}/${task.subtasks.length}` : '—'}
</span> </span>
{/* Subtask progress bar */}
{hasSubtasks && (
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-secondary/50 rounded-full overflow-hidden">
<div
className="h-full bg-accent rounded-full transition-all duration-300"
style={{ width: `${(completedSubtasks / task.subtasks.length) * 100}%` }}
/>
</div>
)}
</div> </div>
); );
} }

View File

@ -112,7 +112,7 @@ export interface ProjectTask {
parent_task_id?: number | null; parent_task_id?: number | null;
title: string; title: string;
description?: string; description?: string;
status: 'pending' | 'in_progress' | 'completed'; status: 'pending' | 'in_progress' | 'completed' | 'blocked' | 'review' | 'on_hold';
priority: 'none' | 'low' | 'medium' | 'high'; priority: 'none' | 'low' | 'medium' | 'high';
due_date?: string; due_date?: string;
person_id?: number; person_id?: number;