4f9deeb57a
Fixes a real N+1 API-call bug (per-cell project-member fetches in shot/asset task-status cells, now a shared cached store), replaces a polling-based in-flight request de-dup with promise memoization, adds markRaw() around icon components in reactive state, and fixes a deep watcher that re-scanned the whole projects array just to trigger thumbnail loading. Introduces useAsyncAction and usePermission composables to cut duplicated store boilerplate and duplicated role/admin checks, applied only where the existing code was a clean match rather than forced onto everything. Extracts assets.ts's shared per-asset optimistic-update/rollback helper without merging the single and bulk API paths, which hit genuinely different endpoints. Adds toast-on-error for previously-silent secondary loads and fixes a dead try/catch in the router's auth-init guard along the way. Also fixes a thumbnail-loading regression introduced earlier in this same pass: the new ID-keyed watcher never fired on a repeat visit to the Projects page when the store already held the same project list, so thumbnails (local component state, reset per mount) silently stopped loading after the first visit. Replaced the watcher with a direct call after each fetch. Table virtualization, splitting the two largest view files, broad store caching, and moving domain types into types/ were scoped out as separate, higher-risk follow-ups (documented in frontend_tasks.md). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
406 lines
14 KiB
Vue
406 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, 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 type { ProjectMember } from '@/services/project'
|
|
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
|
import { useProjectMembersStore } from '@/stores/projectMembers'
|
|
|
|
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 projectMembersStore = useProjectMembersStore()
|
|
|
|
const isUpdating = ref(false)
|
|
const isAssigning = ref(false)
|
|
const projectMembers = computed<ProjectMember[]>(() => projectMembersStore.getMembers(props.projectId) || [])
|
|
const isLoadingMembers = computed(() => projectMembersStore.isLoading(props.projectId))
|
|
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 (shared cache across all EditableTaskStatus instances for this project)
|
|
const loadProjectMembers = async () => {
|
|
try {
|
|
await projectMembersStore.fetchProjectMembers(props.projectId)
|
|
} catch (error) {
|
|
console.error('Failed to load project members:', error)
|
|
}
|
|
}
|
|
|
|
// Ensure members are loaded when popover is about to open
|
|
const ensureMembersLoaded = () => {
|
|
if (projectMembers.value.length === 0) {
|
|
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()
|
|
loadProjectMembers()
|
|
})
|
|
</script>
|