Add assignee popover to Gantt chart status cells
The Gantt used a status-only EditableTaskStatus, unlike the shot/asset data tables which pair status with an avatar-popover assignee picker. Bring the Gantt's task-level EditableTaskStatus to parity (opt-in via a showAssignee prop so the flat Tasks table's separate assignee column is unaffected), and widen the frozen status column to fit both controls.
This commit is contained in:
@@ -139,7 +139,10 @@
|
||||
:task-id="task.id"
|
||||
:status="task.status"
|
||||
:project-id="projectId"
|
||||
show-assignee
|
||||
:assigned-user-id="task.assigned_user_id"
|
||||
@status-updated="handleStatusUpdated"
|
||||
@assignment-updated="handleAssignmentUpdated"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -306,7 +309,7 @@ const episodeFilter = ref<number | null>(null)
|
||||
const collapsedGroups = ref<Set<string>>(new Set())
|
||||
|
||||
const LABEL_COLUMN_WIDTH = 224 // matches w-56
|
||||
const STATUS_COLUMN_WIDTH = 176 // matches w-44
|
||||
const STATUS_COLUMN_WIDTH = 200 // status select (130px) + assignee avatar button, with padding
|
||||
const FROZEN_WIDTH = LABEL_COLUMN_WIDTH + STATUS_COLUMN_WIDTH
|
||||
const HEADER_HEIGHT = 44
|
||||
const GROUP_ROW_HEIGHT = 32
|
||||
@@ -646,6 +649,11 @@ function handleStatusUpdated(taskId: number, newStatus: string) {
|
||||
if (task) task.status = newStatus
|
||||
}
|
||||
|
||||
function handleAssignmentUpdated(taskId: number, userId: number | null) {
|
||||
const task = tasks.value.find(t => t.id === taskId)
|
||||
if (task) task.assigned_user_id = userId ?? undefined
|
||||
}
|
||||
|
||||
// --- Drag to reschedule ---
|
||||
|
||||
interface DragState {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="relative"
|
||||
<div class="relative flex items-center gap-1"
|
||||
|
||||
>
|
||||
<Select
|
||||
@@ -37,6 +37,112 @@
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- User Assignment Button -->
|
||||
<div v-if="showAssignee" @click.stop>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 w-6 p-0 hover:bg-accent relative"
|
||||
:disabled="isUpdating"
|
||||
@click.stop="ensureMembersLoaded"
|
||||
>
|
||||
<Avatar class="h-4 w-4" v-if="assignedUser">
|
||||
<AvatarImage :src="getAvatarUrl(assignedUser?.user_avatar_url, assignedUser?.user_first_name, assignedUser?.user_last_name)" />
|
||||
<AvatarFallback class="text-[8px]">{{ getUserInitials(assignedUser) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<User class="h-3 w-3" v-else />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-64 p-2" align="start" side="bottom" :side-offset="4">
|
||||
<div class="space-y-2">
|
||||
<div class="px-2 py-1.5 text-sm font-semibold">Assign Task</div>
|
||||
|
||||
<!-- Current Assignment Display with X button -->
|
||||
<div v-if="assignedUser" class="px-2 py-2 bg-muted rounded-md flex items-center gap-2">
|
||||
<Avatar class="h-8 w-8">
|
||||
<AvatarImage :src="getAvatarUrl(assignedUser?.user_avatar_url, assignedUser?.user_first_name, assignedUser?.user_last_name)" />
|
||||
<AvatarFallback class="text-[8px]">{{ getUserInitials(assignedUser) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
<span class="text-xs font-medium truncate">{{ assignedUser.user_first_name }} {{ assignedUser.user_last_name }}</span>
|
||||
<span class="text-[10px] text-muted-foreground">Current</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 w-6 p-0 hover:bg-destructive hover:text-destructive-foreground"
|
||||
@click.stop="handleAssignUser(null)"
|
||||
:disabled="isAssigning"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Search Input -->
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2 top-1/2 transform -translate-y-1/2 h-3 w-3 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
placeholder="Search members..."
|
||||
class="pl-7 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="isLoadingMembers" class="flex items-center justify-center py-4">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
|
||||
<span class="ml-2 text-sm">Loading members...</span>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="projectMembers.length === 0" class="px-2 py-4 text-sm text-muted-foreground text-center">
|
||||
No project members found
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-2"
|
||||
@click="loadProjectMembers"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Content when members are loaded -->
|
||||
<template v-else>
|
||||
<!-- Project members list -->
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
<div v-if="filteredProjectMembers.length === 0" class="py-2 text-xs text-muted-foreground text-center">
|
||||
No matching members found
|
||||
</div>
|
||||
<Button
|
||||
v-else
|
||||
v-for="member in filteredProjectMembers"
|
||||
:key="member.user_id"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="w-full justify-start h-10"
|
||||
@click="handleAssignUser(member.user_id)"
|
||||
:disabled="isAssigning"
|
||||
>
|
||||
<Avatar class="h-8 w-8 mr-2">
|
||||
<AvatarImage :src="getAvatarUrl(member.user_avatar_url, member.user_first_name, member.user_last_name)" />
|
||||
<AvatarFallback class="text-[8px]">{{ getUserInitials(member) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="flex flex-col items-start flex-1 min-w-0">
|
||||
<span class="text-xs truncate">{{ member.user_first_name }} {{ member.user_last_name }}</span>
|
||||
<span class="text-[10px] text-muted-foreground" v-if="member.department_role">{{ formatDepartmentRole(member.department_role) }}</span>
|
||||
</div>
|
||||
<Check v-if="assignedUserId === member.user_id" class="h-4 w-4 text-green-500 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div
|
||||
v-if="isUpdating || isLoadingStatuses"
|
||||
@@ -56,11 +162,23 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { User, Search, Check, X } from 'lucide-vue-next'
|
||||
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
||||
import { TaskStatus } from '@/services/asset'
|
||||
import { taskService } from '@/services/task'
|
||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||
import { useProjectMembersStore } from '@/stores/projectMembers'
|
||||
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||
import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus'
|
||||
import type { ProjectMember } from '@/services/project'
|
||||
|
||||
interface StatusOption {
|
||||
id: string
|
||||
@@ -73,10 +191,13 @@ interface Props {
|
||||
taskId: number
|
||||
status: TaskStatus | string
|
||||
projectId: number
|
||||
showAssignee?: boolean
|
||||
assignedUserId?: number | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'status-updated', taskId: number, newStatus: string): void
|
||||
(e: 'assignment-updated', taskId: number, userId: number | null): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -84,8 +205,78 @@ const emit = defineEmits<Emits>()
|
||||
|
||||
// Use the shared task statuses store instead of direct API calls
|
||||
const taskStatusesStore = useTaskStatusesStore()
|
||||
const projectMembersStore = useProjectMembersStore()
|
||||
const { getAvatarUrl } = useAvatarUrl()
|
||||
|
||||
const isUpdating = ref(false)
|
||||
const isAssigning = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const projectMembers = computed<ProjectMember[]>(() => projectMembersStore.getMembers(props.projectId) || [])
|
||||
const isLoadingMembers = computed(() => projectMembersStore.isLoading(props.projectId))
|
||||
|
||||
const filteredProjectMembers = computed(() => {
|
||||
if (!searchQuery.value.trim()) {
|
||||
return projectMembers.value
|
||||
}
|
||||
const query = searchQuery.value.toLowerCase().trim()
|
||||
return projectMembers.value.filter(member => {
|
||||
const fullName = `${member.user_first_name || ''} ${member.user_last_name || ''}`.toLowerCase()
|
||||
const departmentRole = member.department_role?.toLowerCase() || ''
|
||||
return fullName.includes(query) || departmentRole.includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
const assignedUserId = computed(() => props.assignedUserId)
|
||||
const assignedUser = computed(() => {
|
||||
if (!assignedUserId.value) return null
|
||||
return projectMembers.value.find(member => member.user_id === assignedUserId.value) || null
|
||||
})
|
||||
|
||||
const formatDepartmentRole = (role: string): string => {
|
||||
return role.charAt(0).toUpperCase() + role.slice(1)
|
||||
}
|
||||
|
||||
const getUserInitials = (member: ProjectMember): string => {
|
||||
const first = member.user_first_name?.charAt(0) || ''
|
||||
const last = member.user_last_name?.charAt(0) || ''
|
||||
return (first + last).toUpperCase()
|
||||
}
|
||||
|
||||
const loadProjectMembers = async () => {
|
||||
try {
|
||||
await projectMembersStore.fetchProjectMembers(props.projectId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load project members:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const ensureMembersLoaded = () => {
|
||||
if (projectMembers.value.length === 0) {
|
||||
loadProjectMembers()
|
||||
}
|
||||
}
|
||||
|
||||
const handleAssignUser = async (userId: number | null) => {
|
||||
isAssigning.value = true
|
||||
|
||||
try {
|
||||
if (userId) {
|
||||
await taskService.assignTask(props.taskId, userId)
|
||||
} else {
|
||||
await taskService.updateTask(props.taskId, { assigned_user_id: 0 })
|
||||
}
|
||||
emit('assignment-updated', props.taskId, userId)
|
||||
|
||||
// Close popover by simulating click outside after assignment
|
||||
setTimeout(() => {
|
||||
document.querySelector('[data-state="open"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
}, 100)
|
||||
} catch (error) {
|
||||
console.error('Failed to assign task:', error)
|
||||
} finally {
|
||||
isAssigning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Get loading state from store
|
||||
const isLoadingStatuses = computed(() => taskStatusesStore.isLoading(props.projectId))
|
||||
|
||||
Reference in New Issue
Block a user