Kyle Pope a11fcbcbcc 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>
2026-02-22 12:04:10 +08:00

210 lines
7.2 KiB
TypeScript

import { useState, FormEvent } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
import type { ProjectTask, Person } from '@/types';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
SheetClose,
} from '@/components/ui/sheet';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
interface TaskFormProps {
projectId: number;
task: ProjectTask | null;
parentTaskId?: number | null;
defaultDueDate?: string;
onClose: () => void;
}
export default function TaskForm({ projectId, task, parentTaskId, defaultDueDate, onClose }: TaskFormProps) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
title: task?.title || '',
description: task?.description || '',
status: task?.status || 'pending',
priority: task?.priority || 'medium',
due_date: task?.due_date ? task.due_date.slice(0, 10) : (!task && defaultDueDate ? defaultDueDate.slice(0, 10) : ''),
person_id: task?.person_id?.toString() || '',
});
const { data: people = [] } = useQuery({
queryKey: ['people'],
queryFn: async () => {
const { data } = await api.get<Person[]>('/people');
return data;
},
});
const mutation = useMutation({
mutationFn: async (data: typeof formData) => {
const payload: Record<string, unknown> = {
...data,
person_id: data.person_id ? parseInt(data.person_id) : null,
};
if (task) {
const response = await api.put(`/projects/${projectId}/tasks/${task.id}`, payload);
return response.data;
} else {
if (parentTaskId) {
payload.parent_task_id = parentTaskId;
}
const response = await api.post(`/projects/${projectId}/tasks`, payload);
return response.data;
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
toast.success(task ? 'Task updated' : 'Task created');
onClose();
},
onError: (error) => {
toast.error(getErrorMessage(error, task ? 'Failed to update task' : 'Failed to create task'));
},
});
const deleteMutation = useMutation({
mutationFn: async () => {
await api.delete(`/projects/${projectId}/tasks/${task!.id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
toast.success('Task deleted');
onClose();
},
onError: () => {
toast.error('Failed to delete task');
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
mutation.mutate(formData);
};
return (
<Sheet open={true} onOpenChange={onClose}>
<SheetContent>
<SheetClose onClick={onClose} />
<SheetHeader>
<SheetTitle>{task ? 'Edit Task' : parentTaskId ? 'New Subtask' : 'New Task'}</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden">
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-4">
<div className="space-y-2">
<Label htmlFor="title" required>Title</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="min-h-[80px] flex-1"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
id="status"
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as ProjectTask['status'] })}
>
<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>
</div>
<div className="space-y-2">
<Label htmlFor="priority">Priority</Label>
<Select
id="priority"
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value as ProjectTask['priority'] })}
>
<option value="none">None</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="due_date">Due Date</Label>
<Input
id="due_date"
type="date"
value={formData.due_date}
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="person">Assign To</Label>
<Select
id="person"
value={formData.person_id}
onChange={(e) => setFormData({ ...formData, person_id: e.target.value })}
>
<option value="">Unassigned</option>
{people.map((person) => (
<option key={person.id} value={person.id}>
{person.name}
</option>
))}
</Select>
</div>
</div>
</div>
<SheetFooter>
{task && (
<Button
type="button"
variant="destructive"
className="mr-auto"
onClick={() => {
if (!window.confirm('Delete this task?')) return;
deleteMutation.mutate();
}}
disabled={deleteMutation.isPending}
>
Delete
</Button>
)}
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : task ? 'Update' : 'Create'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}