diff --git a/backend/app/schemas/project_task.py b/backend/app/schemas/project_task.py index 4d0cd32..760fdcb 100644 --- a/backend/app/schemas/project_task.py +++ b/backend/app/schemas/project_task.py @@ -3,7 +3,7 @@ from datetime import datetime, date from typing import Optional, List, Literal 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"] diff --git a/frontend/src/components/projects/KanbanBoard.tsx b/frontend/src/components/projects/KanbanBoard.tsx index f3cec74..8654572 100644 --- a/frontend/src/components/projects/KanbanBoard.tsx +++ b/frontend/src/components/projects/KanbanBoard.tsx @@ -15,6 +15,9 @@ import { Badge } from '@/components/ui/badge'; const COLUMNS: { id: string; label: string; color: string }[] = [ { id: 'pending', label: 'Pending', color: 'text-gray-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' }, ]; @@ -28,6 +31,7 @@ const priorityColors: Record = { interface KanbanBoardProps { tasks: ProjectTask[]; selectedTaskId: number | null; + selectedTask?: ProjectTask | null; onSelectTask: (taskId: number) => void; onStatusChange: (taskId: number, status: string) => void; } @@ -119,7 +123,7 @@ function KanbanCard({

{task.title}

{task.priority} @@ -141,6 +145,7 @@ function KanbanCard({ export default function KanbanBoard({ tasks, selectedTaskId, + selectedTask, onSelectTask, onStatusChange, }: KanbanBoardProps) { @@ -148,6 +153,10 @@ export default function KanbanBoard({ 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 { active, over } = event; if (!over) return; @@ -155,8 +164,7 @@ export default function KanbanBoard({ const taskId = active.id as number; const newStatus = over.id as string; - // Only change if dropped on a different column - const task = tasks.find((t) => t.id === taskId); + const task = activeTasks.find((t) => t.id === taskId); if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) { onStatusChange(taskId, newStatus); } @@ -164,26 +172,44 @@ export default function KanbanBoard({ const tasksByStatus = COLUMNS.map((col) => ({ column: col, - tasks: tasks.filter((t) => t.status === col.id), + tasks: activeTasks.filter((t) => t.status === col.id), })); return ( - -
- {tasksByStatus.map(({ column, tasks: colTasks }) => ( - - ))} -
-
+
+ {/* Subtask view header */} + {isSubtaskView && ( +
+ + / + + Subtasks of: {selectedTask.title} + +
+ )} + + +
+ {tasksByStatus.map(({ column, tasks: colTasks }) => ( + + ))} +
+
+
); } diff --git a/frontend/src/components/projects/ProjectDetail.tsx b/frontend/src/components/projects/ProjectDetail.tsx index c13a3e9..215810a 100644 --- a/frontend/src/components/projects/ProjectDetail.tsx +++ b/frontend/src/components/projects/ProjectDetail.tsx @@ -209,12 +209,34 @@ export default function ProjectDetail() { [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 tasks = [...topLevelTasks]; + const tasks = [...topLevelTasks].map((t) => ({ + ...t, + subtasks: sortSubtasks(t.subtasks || []), + })); switch (sortMode) { case 'priority': 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': return tasks.sort((a, b) => { @@ -227,7 +249,7 @@ export default function ProjectDetail() { default: return tasks.sort((a, b) => a.sort_order - b.sort_order); } - }, [topLevelTasks, sortMode]); + }, [topLevelTasks, sortMode, sortSubtasks]); const selectedTask = useMemo(() => { if (!selectedTaskId) return null; @@ -494,6 +516,7 @@ export default function ProjectDetail() { setSelectedTaskId(taskId)} onStatusChange={(taskId, status) => updateTaskStatusMutation.mutate({ taskId, status }) @@ -572,10 +595,10 @@ export default function ProjectDetail() { openTaskForm(task, null)} onDelete={handleDeleteTask} onAddSubtask={(parentId) => openTaskForm(null, parentId)} onClose={() => setSelectedTaskId(null)} + onSelectTask={setSelectedTaskId} />
@@ -601,10 +624,10 @@ export default function ProjectDetail() { openTaskForm(task, null)} onDelete={handleDeleteTask} onAddSubtask={(parentId) => openTaskForm(null, parentId)} onClose={() => setSelectedTaskId(null)} + onSelectTask={setSelectedTaskId} /> @@ -616,6 +639,11 @@ export default function ProjectDetail() { projectId={parseInt(id!)} task={editingTask} parentTaskId={subtaskParentId} + defaultDueDate={ + subtaskParentId + ? allTasks.find((t) => t.id === subtaskParentId)?.due_date + : project?.due_date + } onClose={closeTaskForm} /> )} diff --git a/frontend/src/components/projects/TaskDetailPanel.tsx b/frontend/src/components/projects/TaskDetailPanel.tsx index 3fe9c62..6b48041 100644 --- a/frontend/src/components/projects/TaskDetailPanel.tsx +++ b/frontend/src/components/projects/TaskDetailPanel.tsx @@ -4,7 +4,7 @@ import { toast } from 'sonner'; import { format, formatDistanceToNow, parseISO } from 'date-fns'; import { Pencil, Trash2, Plus, MessageSquare, ClipboardList, - Calendar, User, Flag, Activity, Send, X, + Calendar, User, Flag, Activity, Send, X, Save, } from 'lucide-react'; import api, { getErrorMessage } from '@/lib/api'; import type { ProjectTask, TaskComment, Person } from '@/types'; @@ -12,17 +12,25 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Checkbox } from '@/components/ui/checkbox'; import { Textarea } from '@/components/ui/textarea'; +import { Input } from '@/components/ui/input'; +import { Select } from '@/components/ui/select'; const taskStatusColors: Record = { 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', 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 = { pending: 'Pending', in_progress: 'In Progress', completed: 'Completed', + blocked: 'Blocked', + review: 'Review', + on_hold: 'On Hold', }; const priorityColors: Record = { @@ -35,22 +43,47 @@ const priorityColors: Record = { interface TaskDetailPanelProps { task: ProjectTask | null; projectId: number; - onEdit: (task: ProjectTask) => void; onDelete: (taskId: number) => void; onAddSubtask: (parentId: number) => 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({ task, projectId, - onEdit, onDelete, onAddSubtask, onClose, + onSelectTask, }: TaskDetailPanelProps) { const queryClient = useQueryClient(); const [commentText, setCommentText] = useState(''); + const [isEditing, setIsEditing] = useState(false); + const [editState, setEditState] = useState(() => + task ? buildEditState(task) : { title: '', status: 'pending', priority: 'none', due_date: '', person_id: '', description: '' } + ); const { data: people = [] } = useQuery({ queryKey: ['people'], @@ -60,12 +93,12 @@ export default function TaskDetailPanel({ }, }); + // --- Mutations --- + const toggleSubtaskMutation = useMutation({ mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => { const newStatus = status === 'completed' ? 'pending' : 'completed'; - const { data } = await api.put(`/projects/${projectId}/tasks/${taskId}`, { - status: newStatus, - }); + const { data } = await api.put(`/projects/${projectId}/tasks/${taskId}`, { status: newStatus }); return data; }, onSuccess: () => { @@ -73,6 +106,34 @@ export default function TaskDetailPanel({ }, }); + const updateTaskMutation = useMutation({ + mutationFn: async (payload: Record) => { + 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({ mutationFn: async (content: string) => { const { data } = await api.post( @@ -100,12 +161,45 @@ export default function TaskDetailPanel({ }, }); + // --- Handlers --- + const handleAddComment = () => { const trimmed = commentText.trim(); if (!trimmed) return; 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 = { + 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) { return (
@@ -115,10 +209,7 @@ export default function TaskDetailPanel({ ); } - const assignedPerson = task.person_id - ? people.find((p) => p.id === task.person_id) - : null; - + const assignedPerson = task.person_id ? people.find((p) => p.id === task.person_id) : null; const comments = task.comments || []; return ( @@ -126,38 +217,72 @@ export default function TaskDetailPanel({ {/* Header */}
-

- {task.title} -

+ {isEditing ? ( + setEditState((s) => ({ ...s, title: e.target.value }))} + className="h-8 text-base font-semibold flex-1" + autoFocus + /> + ) : ( +

{task.title}

+ )} +
- - - {onClose && ( - + {isEditing ? ( + <> + + + + ) : ( + <> + + + {onClose && ( + + )} + )}
@@ -167,62 +292,121 @@ export default function TaskDetailPanel({
{/* Fields grid */}
+ {/* Status */}
Status
- - {taskStatusLabels[task.status]} - + {isEditing ? ( + + ) : ( + + {taskStatusLabels[task.status] ?? task.status} + + )}
+ {/* Priority */}
Priority
- - {task.priority} - + {isEditing ? ( + + ) : ( + + {task.priority} + + )}
+ {/* Due Date */}
Due Date
-

- {task.due_date - ? format(parseISO(task.due_date), 'MMM d, yyyy') - : '—'} -

+ {isEditing ? ( + setEditState((s) => ({ ...s, due_date: e.target.value }))} + className="h-8 text-xs" + /> + ) : ( +

+ {task.due_date ? format(parseISO(task.due_date), 'MMM d, yyyy') : '—'} +

+ )}
+ {/* Assigned */}
Assigned
-

- {assignedPerson ? assignedPerson.name : '—'} -

+ {isEditing ? ( + + ) : ( +

{assignedPerson ? assignedPerson.name : '—'}

+ )}
{/* Description */} - {task.description && ( + {isEditing ? (
-

- Description -

+

Description

+