LinkDesk/frontend/src/components/shot/EditableTaskStatus.vue

414 lines
14 KiB
Vue

<template>
<div class="relative flex items-center gap-1" v-memo="[currentStatusId, isUpdating, isLoadingStatuses, assignedUserId]" data-testid="editable-task-status">
<!-- Status Selector -->
<Select
:model-value="currentStatusId"
@update:model-value="handleStatusChange"
:disabled="isUpdating || isLoadingStatuses"
>
<SelectTrigger class="h-6 w-[130px] font-semibold text-xs"
:style="{ backgroundColor: currentStatusObject.color }"
>
<SelectValue>
<!-- <TaskStatusBadge :status="currentStatusObject"/> -->
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="statusOption in allStatusOptions"
:key="statusOption.id"
:value="statusOption.id"
>
<div class="flex items-center gap-2">
<!-- Color indicator -->
<!-- <div
v-if="statusOption.color"
class="w-3 h-3 rounded-full border border-border"
:style="{ backgroundColor: statusOption.color }"
/> -->
<TaskStatusBadge :status="statusOption" compact />
</div>
</SelectItem>
</SelectContent>
</Select>
<!-- User Assignment Button -->
<div @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"
class="absolute inset-0 bg-background/50 flex items-center justify-center rounded"
>
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import {
Select,
SelectContent,
SelectItem,
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, UserX, Search, Check, X } from 'lucide-vue-next'
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import { TaskStatus } from '@/services/shot'
import { taskService } from '@/services/task'
import { projectService, type ProjectMember } from '@/services/project'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
interface StatusOption {
id: string
name: string
color?: string
is_system?: boolean
}
interface Props {
shotId: number
taskType: string
status: TaskStatus | string
taskId?: number | null
projectId: number
assignedUserId?: number | null
}
interface Emits {
(e: 'status-updated', shotId: number, taskType: string, newStatus: string): void
(e: 'assignment-updated', shotId: number, taskType: string, userId: number | null): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// Use the shared task statuses store instead of direct API calls
const taskStatusesStore = useTaskStatusesStore()
const isUpdating = ref(false)
const isAssigning = ref(false)
const isLoadingMembers = ref(false)
const projectMembers = ref<ProjectMember[]>([])
const searchQuery = ref('')
// Filtered project members based on search query
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)
})
})
// Get loading state from store
const isLoadingStatuses = computed(() => taskStatusesStore.isLoading(props.projectId))
// Get all status options from store
const allStatusOptions = computed(() => taskStatusesStore.getAllStatusOptions(props.projectId))
// Get current status ID (handle both TaskStatus enum and custom status strings)
const currentStatusId = computed(() => {
if (typeof props.status === 'string') {
return props.status
}
return props.status as string
})
// Get current status object for display using store
const currentStatusObject = computed((): StatusOption => {
const statusFromStore = taskStatusesStore.getStatusById(props.projectId, currentStatusId.value)
if (statusFromStore) {
return {
id: statusFromStore.id,
name: statusFromStore.name,
color: statusFromStore.color,
is_system: 'is_system' in statusFromStore ? statusFromStore.is_system : false
}
}
// Fallback to current status as-is
return {
id: currentStatusId.value,
name: formatStatusName(currentStatusId.value)
}
})
// Get assigned user info
const assignedUserId = computed(() => props.assignedUserId)
const assignedUser = computed(() => {
if (!assignedUserId.value) return null
return projectMembers.value.find(member => member.user_id === assignedUserId.value) || null
})
// Format status name for display
const formatStatusName = (status: string): string => {
switch (status) {
case 'not_started':
return 'Not Started'
case 'in_progress':
return 'In Progress'
case 'submitted':
return 'Submitted'
case 'approved':
return 'Approved'
case 'retake':
return 'Retake'
default:
// Convert snake_case to Title Case
return status.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ')
}
}
// Format department role for display
const formatDepartmentRole = (role: string): string => {
return role.charAt(0).toUpperCase() + role.slice(1)
}
// Get user initials
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()
}
import { useAvatarUrl } from '@/composables/useAvatarUrl'
const { getAvatarUrl, getInitialsAvatarUrl } = useAvatarUrl()
// Fetch custom statuses for the project using store
const fetchStatuses = async () => {
if (!props.projectId) return
try {
await taskStatusesStore.fetchProjectStatuses(props.projectId)
} catch (error) {
console.error('Failed to fetch task statuses:', error)
}
}
// Load project members
const loadProjectMembers = async () => {
if (projectMembers.value.length > 0) return // Already loaded
isLoadingMembers.value = true
try {
console.log('Loading project members for project:', props.projectId)
projectMembers.value = await projectService.getProjectMembers(props.projectId)
console.log('Loaded project members:', projectMembers.value)
} catch (error) {
console.error('Failed to load project members:', error)
} finally {
isLoadingMembers.value = false
}
}
// Ensure members are loaded when popover is about to open
const ensureMembersLoaded = () => {
console.log('Ensuring project members are loaded')
if (projectMembers.value.length === 0) {
console.log('Loading project members on button click')
loadProjectMembers()
}
}
const handleStatusChange = async (newStatusId: any) => {
if (!newStatusId || newStatusId === currentStatusId.value) return
const statusId = newStatusId as string
isUpdating.value = true
try {
let taskId = props.taskId
// If no task exists, create one first
if (!taskId) {
const newTask = await taskService.createShotTask(props.shotId, props.taskType)
taskId = newTask.task_id
}
// Update the task status
if (taskId) {
await taskService.updateTaskStatus(taskId, statusId as TaskStatus)
emit('status-updated', props.shotId, props.taskType, statusId)
}
} catch (error) {
console.error('Failed to update task status:', error)
// Revert the status change by emitting the original status
// This will cause the parent component to refresh the data
emit('status-updated', props.shotId, props.taskType, currentStatusId.value)
} finally {
isUpdating.value = false
}
}
const handleAssignUser = async (userId: number | null) => {
isAssigning.value = true
try {
let taskId = props.taskId
// If no task exists, create one first
if (!taskId) {
const newTask = await taskService.createShotTask(props.shotId, props.taskType)
taskId = newTask.task_id
}
// Assign or unassign the task
if (taskId) {
if (userId) {
// Use the assignment endpoint for assigning to a user
await taskService.assignTask(taskId, userId)
} else {
// Use the update endpoint for unassignment (set assigned_user_id to 0)
await taskService.updateTask(taskId, { assigned_user_id: 0 })
}
emit('assignment-updated', props.shotId, props.taskType, 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
}
}
// Fetch statuses on mount
onMounted(() => {
fetchStatuses()
// Preload project members to ensure they're available when needed
loadProjectMembers()
})
// Refetch statuses when projectId changes
watch(() => props.projectId, () => {
fetchStatuses()
// Clear project members when project changes
projectMembers.value = []
})
</script>