Fix kanban subtask view, project statuses, column order

- Add blocked/review/on_hold to ProjectStatus (backend + frontend)
- ProjectForm: add new status options to dropdown
- ProjectDetail: add status colors/labels for new statuses
- KanbanBoard: reorder columns (review before completed)
- KanbanBoard: decouple subtask view from selectedTaskId via
  kanbanParentTaskId — closing task panel stays in subtask view,
  "Back to all tasks" button now works
- TaskDetailPanel: show status badge on subtask rows so kanban
  drag-and-drop status changes are visible

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-23 00:35:46 +08:00
parent 3764d3e2ab
commit b5ec38f4b8
6 changed files with 57 additions and 14 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.project_task import ProjectTaskResponse from app.schemas.project_task import ProjectTaskResponse
ProjectStatus = Literal["not_started", "in_progress", "completed"] ProjectStatus = Literal["not_started", "in_progress", "completed", "blocked", "review", "on_hold"]
class ProjectCreate(BaseModel): class ProjectCreate(BaseModel):

View File

@ -16,8 +16,8 @@ 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: '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: 'on_hold', label: 'On Hold', color: 'text-orange-400' },
{ id: 'review', label: 'Review', color: 'text-yellow-400' },
{ id: 'completed', label: 'Completed', color: 'text-green-400' }, { id: 'completed', label: 'Completed', color: 'text-green-400' },
]; ];
@ -31,9 +31,10 @@ const priorityColors: Record<string, string> = {
interface KanbanBoardProps { interface KanbanBoardProps {
tasks: ProjectTask[]; tasks: ProjectTask[];
selectedTaskId: number | null; selectedTaskId: number | null;
selectedTask?: ProjectTask | null; kanbanParentTask?: ProjectTask | null;
onSelectTask: (taskId: number) => void; onSelectTask: (taskId: number) => void;
onStatusChange: (taskId: number, status: string) => void; onStatusChange: (taskId: number, status: string) => void;
onBackToAllTasks?: () => void;
} }
function KanbanColumn({ function KanbanColumn({
@ -145,17 +146,18 @@ function KanbanCard({
export default function KanbanBoard({ export default function KanbanBoard({
tasks, tasks,
selectedTaskId, selectedTaskId,
selectedTask, kanbanParentTask,
onSelectTask, onSelectTask,
onStatusChange, onStatusChange,
onBackToAllTasks,
}: KanbanBoardProps) { }: KanbanBoardProps) {
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
); );
// When a task is selected and has subtasks, show subtask kanban // Subtask view is driven by kanbanParentTask (decoupled from selected task)
const isSubtaskView = selectedTask != null && (selectedTask.subtasks?.length ?? 0) > 0; const isSubtaskView = kanbanParentTask != null && (kanbanParentTask.subtasks?.length ?? 0) > 0;
const activeTasks: ProjectTask[] = isSubtaskView ? (selectedTask.subtasks ?? []) : tasks; const activeTasks: ProjectTask[] = isSubtaskView ? (kanbanParentTask.subtasks ?? []) : tasks;
const handleDragEnd = (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
@ -178,17 +180,17 @@ export default function KanbanBoard({
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{/* Subtask view header */} {/* Subtask view header */}
{isSubtaskView && ( {isSubtaskView && kanbanParentTask && (
<div className="flex items-center gap-3 px-1"> <div className="flex items-center gap-3 px-1">
<button <button
onClick={() => onSelectTask(selectedTask.id)} onClick={onBackToAllTasks}
className="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2" className="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
> >
Back to all tasks Back to all tasks
</button> </button>
<span className="text-muted-foreground text-xs">/</span> <span className="text-muted-foreground text-xs">/</span>
<span className="text-xs text-foreground font-medium"> <span className="text-xs text-foreground font-medium">
Subtasks of: {selectedTask.title} Subtasks of: {kanbanParentTask.title}
</span> </span>
</div> </div>
)} )}

View File

@ -42,12 +42,18 @@ const statusColors: Record<string, string> = {
not_started: 'bg-gray-500/10 text-gray-400 border-gray-500/20', not_started: 'bg-gray-500/10 text-gray-400 border-gray-500/20',
in_progress: 'bg-purple-500/10 text-purple-400 border-purple-500/20', in_progress: 'bg-purple-500/10 text-purple-400 border-purple-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 statusLabels: Record<string, string> = { const statusLabels: Record<string, string> = {
not_started: 'Not Started', not_started: 'Not Started',
in_progress: 'In Progress', in_progress: 'In Progress',
completed: 'Completed', completed: 'Completed',
blocked: 'Blocked',
review: 'Review',
on_hold: 'On Hold',
}; };
type SortMode = 'manual' | 'priority' | 'due_date'; type SortMode = 'manual' | 'priority' | 'due_date';
@ -117,6 +123,7 @@ export default function ProjectDetail() {
const [subtaskParentId, setSubtaskParentId] = useState<number | null>(null); const [subtaskParentId, setSubtaskParentId] = useState<number | null>(null);
const [expandedTasks, setExpandedTasks] = useState<Set<number>>(new Set()); const [expandedTasks, setExpandedTasks] = useState<Set<number>>(new Set());
const [selectedTaskId, setSelectedTaskId] = useState<number | null>(null); const [selectedTaskId, setSelectedTaskId] = useState<number | null>(null);
const [kanbanParentTaskId, setKanbanParentTaskId] = useState<number | null>(null);
const [sortMode, setSortMode] = useState<SortMode>('manual'); const [sortMode, setSortMode] = useState<SortMode>('manual');
const [viewMode, setViewMode] = useState<ViewMode>('list'); const [viewMode, setViewMode] = useState<ViewMode>('list');
@ -264,6 +271,31 @@ export default function ProjectDetail() {
return null; return null;
}, [selectedTaskId, allTasks]); }, [selectedTaskId, allTasks]);
const kanbanParentTask = useMemo(() => {
if (!kanbanParentTaskId) return null;
return topLevelTasks.find((t) => t.id === kanbanParentTaskId) || null;
}, [kanbanParentTaskId, topLevelTasks]);
const handleKanbanSelectTask = useCallback(
(taskId: number) => {
setSelectedTaskId(taskId);
// Only enter subtask view when clicking a top-level task with subtasks
// and we're not already in subtask view
if (!kanbanParentTaskId) {
const task = topLevelTasks.find((t) => t.id === taskId);
if (task && task.subtasks && task.subtasks.length > 0) {
setKanbanParentTaskId(taskId);
}
}
},
[kanbanParentTaskId, topLevelTasks]
);
const handleBackToAllTasks = useCallback(() => {
setKanbanParentTaskId(null);
setSelectedTaskId(null);
}, []);
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
(event: DragEndEvent) => { (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
@ -455,7 +487,7 @@ export default function ProjectDetail() {
{/* View toggle */} {/* View toggle */}
<div className="flex items-center rounded-md border border-border overflow-hidden"> <div className="flex items-center rounded-md border border-border overflow-hidden">
<button <button
onClick={() => setViewMode('list')} onClick={() => { setViewMode('list'); setKanbanParentTaskId(null); }}
className={`px-2.5 py-1.5 transition-colors ${ className={`px-2.5 py-1.5 transition-colors ${
viewMode === 'list' viewMode === 'list'
? 'bg-accent/15 text-accent' ? 'bg-accent/15 text-accent'
@ -516,11 +548,12 @@ export default function ProjectDetail() {
<KanbanBoard <KanbanBoard
tasks={topLevelTasks} tasks={topLevelTasks}
selectedTaskId={selectedTaskId} selectedTaskId={selectedTaskId}
selectedTask={selectedTask} kanbanParentTask={kanbanParentTask}
onSelectTask={(taskId) => setSelectedTaskId(taskId)} onSelectTask={handleKanbanSelectTask}
onStatusChange={(taskId, status) => onStatusChange={(taskId, status) =>
updateTaskStatusMutation.mutate({ taskId, status }) updateTaskStatusMutation.mutate({ taskId, status })
} }
onBackToAllTasks={handleBackToAllTasks}
/> />
) : ( ) : (
<DndContext <DndContext

View File

@ -112,6 +112,9 @@ export default function ProjectForm({ project, onClose }: ProjectFormProps) {
> >
<option value="not_started">Not Started</option> <option value="not_started">Not Started</option>
<option value="in_progress">In Progress</option> <option value="in_progress">In Progress</option>
<option value="blocked">Blocked</option>
<option value="on_hold">On Hold</option>
<option value="review">Review</option>
<option value="completed">Completed</option> <option value="completed">Completed</option>
</Select> </Select>
</div> </div>

View File

@ -462,6 +462,11 @@ export default function TaskDetailPanel({
> >
{subtask.title} {subtask.title}
</span> </span>
<Badge
className={`text-[9px] px-1.5 py-0.5 ${taskStatusColors[subtask.status] ?? ''}`}
>
{taskStatusLabels[subtask.status] ?? subtask.status}
</Badge>
<Badge <Badge
className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[subtask.priority] ?? ''}`} className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[subtask.priority] ?? ''}`}
> >

View File

@ -91,7 +91,7 @@ export interface Project {
id: number; id: number;
name: string; name: string;
description?: string; description?: string;
status: 'not_started' | 'in_progress' | 'completed'; status: 'not_started' | 'in_progress' | 'completed' | 'blocked' | 'review' | 'on_hold';
color?: string; color?: string;
due_date?: string; due_date?: string;
created_at: string; created_at: string;