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:
parent
4169c245c2
commit
a11fcbcbcc
@ -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"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user