Wire AssignmentPicker into TaskDetailPanel for task assignment

TaskDetailPanel now shows an interactive AssignmentPicker (click to
open dropdown, select members, remove with X) when the user has
create_modify permission or is the owner. Read-only users see static
chips. Owner is included as a synthetic entry in the picker so they
can self-assign. Both assign and unassign mutations invalidate the
project query for immediate UI refresh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-17 04:31:39 +08:00
parent 957939a165
commit 7eac213c20
2 changed files with 60 additions and 2 deletions

View File

@ -710,6 +710,10 @@ export default function ProjectDetail() {
<TaskDetailPanel <TaskDetailPanel
task={selectedTask} task={selectedTask}
projectId={parseInt(id!)} projectId={parseInt(id!)}
members={acceptedMembers}
currentUserId={currentUserId}
ownerId={project?.user_id ?? 0}
canAssign={isOwner || acceptedMembers.some(m => m.user_id === currentUserId && m.permission === 'create_modify')}
onDelete={handleDeleteTask} onDelete={handleDeleteTask}
onAddSubtask={(parentId) => openTaskForm(null, parentId)} onAddSubtask={(parentId) => openTaskForm(null, parentId)}
onClose={() => setSelectedTaskId(null)} onClose={() => setSelectedTaskId(null)}

View File

@ -9,7 +9,8 @@ import {
import axios from 'axios'; import axios from 'axios';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import { formatUpdatedAt } from '@/components/shared/utils'; import { formatUpdatedAt } from '@/components/shared/utils';
import type { ProjectTask, TaskComment } from '@/types'; import type { ProjectTask, TaskComment, ProjectMember } from '@/types';
import { AssignmentPicker } from './AssignmentPicker';
import { Button } from '@/components/ui/button'; 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';
@ -46,6 +47,10 @@ const priorityColors: Record<string, string> = {
interface TaskDetailPanelProps { interface TaskDetailPanelProps {
task: ProjectTask | null; task: ProjectTask | null;
projectId: number; projectId: number;
members?: ProjectMember[];
currentUserId?: number;
ownerId?: number;
canAssign?: boolean;
onDelete: (taskId: number) => void; onDelete: (taskId: number) => void;
onAddSubtask: (parentId: number) => void; onAddSubtask: (parentId: number) => void;
onClose?: () => void; onClose?: () => void;
@ -82,6 +87,10 @@ function buildEditState(task: ProjectTask): EditState {
export default function TaskDetailPanel({ export default function TaskDetailPanel({
task, task,
projectId, projectId,
members = [],
currentUserId = 0,
ownerId = 0,
canAssign = false,
onDelete, onDelete,
onAddSubtask, onAddSubtask,
onClose, onClose,
@ -94,6 +103,17 @@ export default function TaskDetailPanel({
task ? buildEditState(task) : { title: '', status: 'pending', priority: 'none', due_date: todayLocal(), person_id: '', description: '' } task ? buildEditState(task) : { title: '', status: 'pending', priority: 'none', due_date: todayLocal(), person_id: '', description: '' }
); );
// Build a combined members list that includes the owner for the AssignmentPicker
const allMembers: ProjectMember[] = [
// Synthetic owner entry so they appear in the picker
...(ownerId ? [{
id: 0, project_id: projectId, user_id: ownerId, invited_by: ownerId,
permission: 'create_modify' as const, status: 'accepted' as const,
source: 'invited' as const, user_name: currentUserId === ownerId ? 'Me (Owner)' : 'Owner',
inviter_name: null, created_at: '', updated_at: '', accepted_at: null,
}] : []),
...members.filter(m => m.status === 'accepted'),
];
// --- Mutations --- // --- Mutations ---
@ -168,6 +188,30 @@ export default function TaskDetailPanel({
}, },
}); });
const assignMutation = useMutation({
mutationFn: async (userIds: number[]) => {
await api.post(`/projects/${projectId}/tasks/${task!.id}/assignments`, { user_ids: userIds });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to assign'));
},
});
const unassignMutation = useMutation({
mutationFn: async (userId: number) => {
await api.delete(`/projects/${projectId}/tasks/${task!.id}/assignments/${userId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to unassign'));
},
});
// --- Handlers --- // --- Handlers ---
const handleAddComment = () => { const handleAddComment = () => {
@ -375,7 +419,17 @@ export default function TaskDetailPanel({
<User className="h-3 w-3" /> <User className="h-3 w-3" />
Assigned Assigned
</div> </div>
{task.assignments && task.assignments.length > 0 ? ( {canAssign ? (
<AssignmentPicker
currentAssignments={task.assignments ?? []}
members={allMembers}
currentUserId={currentUserId}
ownerId={ownerId}
onAssign={(userIds) => assignMutation.mutate(userIds)}
onUnassign={(userId) => unassignMutation.mutate(userId)}
disabled={assignMutation.isPending || unassignMutation.isPending}
/>
) : task.assignments && task.assignments.length > 0 ? (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{task.assignments.map((a) => ( {task.assignments.map((a) => (
<span <span