- 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>
210 lines
7.2 KiB
TypeScript
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>
|
|
);
|
|
}
|