e1dd5c6eae
ShotDetailPanel and AssetDetailPanel now merge department task types (filtered to shot/asset departments respectively) into the flat task type list shown in "Add Task", so types like Animation's blocking or Composite's first_pass are selectable at creation time instead of only via editing a task's type afterward. No backend change needed since task creation already derives department from an owned task type.
595 lines
20 KiB
Vue
595 lines
20 KiB
Vue
<template>
|
|
<div class="h-full flex flex-col">
|
|
<DetailPanelLoading v-if="isLoading" label="Loading asset details..." />
|
|
|
|
<DetailPanelError
|
|
v-else-if="error"
|
|
title="Failed to load asset"
|
|
:message="error"
|
|
@retry="loadAssetDetails"
|
|
/>
|
|
|
|
<!-- Asset Details -->
|
|
<div v-else-if="asset" class="flex-1 flex flex-col min-h-0">
|
|
<DetailPanelHeader class="flex-shrink-0" :title="asset.name" :deleted-at="asset.deleted_at" @close="$emit('close')">
|
|
<template #badges>
|
|
<!-- Deletion status indicator for admins -->
|
|
<Badge v-if="authStore.isAdmin && asset.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
|
|
Deleted {{ formatDeletedDate(asset.deleted_at) }}
|
|
</Badge>
|
|
</template>
|
|
</DetailPanelHeader>
|
|
|
|
<!-- Tabbed Content -->
|
|
<Tabs default-value="infos" class="flex-1 flex flex-col min-h-0">
|
|
<TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-3 rounded-none border-b">
|
|
<TabsTrigger value="infos" title="Infos">
|
|
<Info class="h-4 w-4" />
|
|
<span class="sr-only">Infos</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="notes" title="Notes">
|
|
<span class="relative inline-flex">
|
|
<MessageSquare class="h-4 w-4" />
|
|
<span
|
|
v-if="notes.length > 0"
|
|
class="absolute -top-1.5 -right-1.5 h-3.5 w-3.5 rounded-full bg-red-500 text-white text-[9px] leading-none flex items-center justify-center"
|
|
>
|
|
{{ notes.length > 99 ? '99+' : notes.length }}
|
|
</span>
|
|
</span>
|
|
<span class="sr-only">Notes</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="references" title="References">
|
|
<Image class="h-4 w-4" />
|
|
<span class="sr-only">References</span>
|
|
<Badge v-if="references.length > 0" variant="secondary" class="ml-1 h-4 px-1 text-[10px]">
|
|
{{ references.length }}
|
|
</Badge>
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<!-- Infos Tab -->
|
|
<TabsContent value="infos" class="flex-1 overflow-y-auto p-6 space-y-6 m-0">
|
|
<!-- Asset Name -->
|
|
<div class="space-y-2">
|
|
<h3 class="text-sm font-semibold">Asset Name</h3>
|
|
<p class="text-lg font-medium">{{ asset.name }}</p>
|
|
</div>
|
|
|
|
<!-- Asset Category & Status -->
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div class="space-y-2">
|
|
<h3 class="text-sm font-semibold">Category</h3>
|
|
<Badge variant="outline" class="text-sm">
|
|
{{ formatCategory(asset.category) }}
|
|
</Badge>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<h3 class="text-sm font-semibold">Status</h3>
|
|
<Badge :variant="getStatusVariant(asset.status)" class="text-sm">
|
|
<div
|
|
class="w-2 h-2 rounded-full mr-1"
|
|
:class="getStatusColor(asset.status)"
|
|
></div>
|
|
{{ formatStatus(asset.status) }}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Description -->
|
|
<div v-if="asset.description" class="space-y-2">
|
|
<h3 class="text-sm font-semibold">Description</h3>
|
|
<p class="text-sm text-muted-foreground">{{ asset.description }}</p>
|
|
</div>
|
|
|
|
<!-- Progress Overview -->
|
|
<div class="space-y-3">
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-sm font-semibold">Task Progress</h3>
|
|
<span class="text-sm text-muted-foreground">
|
|
{{ completedTasksCount }} / {{ tasks.length }} tasks
|
|
</span>
|
|
</div>
|
|
<!-- Progress Bar -->
|
|
<div class="w-full bg-muted rounded-full h-2">
|
|
<div
|
|
class="bg-primary h-2 rounded-full transition-all duration-300"
|
|
:style="{ width: `${progressPercentage}%` }"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Task Status & Assignees -->
|
|
<div class="space-y-3">
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-sm font-semibold">Tasks</h3>
|
|
<div class="flex items-center gap-1">
|
|
<!-- Add Task -->
|
|
<Popover>
|
|
<PopoverTrigger as-child>
|
|
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" title="Add Task" :disabled="availableTaskTypes.length === 0">
|
|
<Plus class="h-4 w-4" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent class="w-48 p-2" align="end">
|
|
<div class="px-2 py-1.5 text-sm font-semibold">Add Task</div>
|
|
<div v-if="availableTaskTypes.length === 0" class="px-2 py-2 text-xs text-muted-foreground">
|
|
All task types already added
|
|
</div>
|
|
<div v-else class="flex flex-col gap-1 max-h-48 overflow-y-auto">
|
|
<Button
|
|
v-for="type in availableTaskTypes"
|
|
:key="type"
|
|
variant="ghost"
|
|
size="sm"
|
|
class="justify-start"
|
|
:disabled="isCreatingTask"
|
|
@click="handleAddTask(type)"
|
|
>
|
|
{{ formatTaskType(type) }}
|
|
</Button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
<!-- Add Note (picks a task, opens its Notes tab) -->
|
|
<Popover>
|
|
<PopoverTrigger as-child>
|
|
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" title="Add Note" :disabled="tasks.length === 0">
|
|
<MessageSquarePlus class="h-4 w-4" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent class="w-48 p-2" align="end">
|
|
<div class="px-2 py-1.5 text-sm font-semibold">Add note to task</div>
|
|
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
|
|
<Button
|
|
v-for="task in tasks"
|
|
:key="task.id"
|
|
variant="ghost"
|
|
size="sm"
|
|
class="justify-start"
|
|
@click="$emit('select-task', task, 'notes')"
|
|
>
|
|
{{ task.name }}
|
|
</Button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
<!-- Upload Reference (picks a task, opens its Attachments tab) -->
|
|
<Popover>
|
|
<PopoverTrigger as-child>
|
|
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" title="Upload Reference" :disabled="tasks.length === 0">
|
|
<Paperclip class="h-4 w-4" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent class="w-48 p-2" align="end">
|
|
<div class="px-2 py-1.5 text-sm font-semibold">Upload reference to task</div>
|
|
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
|
|
<Button
|
|
v-for="task in tasks"
|
|
:key="task.id"
|
|
variant="ghost"
|
|
size="sm"
|
|
class="justify-start"
|
|
@click="$emit('select-task', task, 'attachments')"
|
|
>
|
|
{{ task.name }}
|
|
</Button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
<!-- Publish Version (picks a task, opens its Submissions tab) -->
|
|
<Popover>
|
|
<PopoverTrigger as-child>
|
|
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" title="Publish Version" :disabled="tasks.length === 0">
|
|
<Send class="h-4 w-4" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent class="w-48 p-2" align="end">
|
|
<div class="px-2 py-1.5 text-sm font-semibold">Publish version for task</div>
|
|
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
|
|
<Button
|
|
v-for="task in tasks"
|
|
:key="task.id"
|
|
variant="ghost"
|
|
size="sm"
|
|
class="justify-start"
|
|
@click="$emit('select-task', task, 'submissions')"
|
|
>
|
|
{{ task.name }}
|
|
</Button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading Tasks -->
|
|
<div v-if="isLoading" class="flex items-center justify-center py-4">
|
|
<div class="flex items-center gap-2">
|
|
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
|
|
<span class="text-sm text-muted-foreground">Loading tasks...</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- No Tasks -->
|
|
<div v-else-if="tasks.length === 0" class="text-center py-4 text-sm text-muted-foreground">
|
|
No tasks yet
|
|
</div>
|
|
|
|
<!-- Tasks List -->
|
|
<div v-else class="space-y-2">
|
|
<div
|
|
v-for="task in tasks"
|
|
:key="task.id"
|
|
class="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 cursor-pointer transition-colors"
|
|
@click="$emit('select-task', task)"
|
|
>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-sm font-medium">{{ formatTaskType(task.task_type) }}</div>
|
|
<div class="text-xs text-muted-foreground mt-1">
|
|
{{ task.assigned_user_name || 'Unassigned' }}
|
|
</div>
|
|
</div>
|
|
<Badge :variant="getTaskStatusVariant(task.status)" class="text-xs flex-shrink-0">
|
|
{{ formatTaskStatus(task.status) }}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Timestamps -->
|
|
<div class="grid grid-cols-2 gap-4 text-xs">
|
|
<div>
|
|
<Label class="text-muted-foreground">Created</Label>
|
|
<p class="mt-1">{{ formatDate(asset.created_at) }}</p>
|
|
</div>
|
|
<div>
|
|
<Label class="text-muted-foreground">Updated</Label>
|
|
<p class="mt-1">{{ formatDate(asset.updated_at) }}</p>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<!-- Notes Tab -->
|
|
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
|
|
<EntityNotes
|
|
:key="assetId"
|
|
:tasks="tasks"
|
|
:notes="notes"
|
|
:submissions="submissions"
|
|
@notes-updated="loadNotes"
|
|
/>
|
|
</TabsContent>
|
|
|
|
<!-- References Tab -->
|
|
<TabsContent value="references" class="flex-1 m-0 overflow-hidden">
|
|
<AssetReferences :asset-id="assetId" :references="references" @references-updated="loadReferences" />
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch } from 'vue'
|
|
import {
|
|
Plus, MessageSquarePlus, Paperclip, Send, Info, MessageSquare, Image
|
|
} from 'lucide-vue-next'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
|
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
|
|
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
|
|
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
|
|
import EntityNotes from '@/components/shared/EntityNotes.vue'
|
|
import AssetReferences from './AssetReferences.vue'
|
|
|
|
import { assetService, AssetStatus, type Asset, type TaskStatusInfo } from '@/services/asset'
|
|
import { taskService, type ProductionNote, type Submission } from '@/services/task'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import { useUserStore } from '@/stores/user'
|
|
import { useDepartmentsStore } from '@/stores/departments'
|
|
|
|
interface Task {
|
|
id: number
|
|
name: string
|
|
task_type: string
|
|
status: string
|
|
assigned_user_name?: string
|
|
deadline?: string
|
|
}
|
|
|
|
interface Props {
|
|
projectId: number
|
|
assetId: number
|
|
allTaskTypes: string[]
|
|
}
|
|
|
|
interface Emits {
|
|
(e: 'edit', asset: Asset): void
|
|
(e: 'delete', asset: Asset): void
|
|
(e: 'select-task', task: Task, tab?: string): void
|
|
(e: 'close'): void
|
|
}
|
|
|
|
const props = defineProps<Props>()
|
|
const emit = defineEmits<Emits>()
|
|
|
|
const authStore = useAuthStore()
|
|
const userStore = useUserStore()
|
|
const departmentsStore = useDepartmentsStore()
|
|
|
|
// Reactive state
|
|
const asset = ref<Asset | null>(null)
|
|
const notes = ref<ProductionNote[]>([])
|
|
const submissions = ref<Submission[]>([])
|
|
const references = ref<any[]>([])
|
|
const isLoading = ref(false)
|
|
const error = ref<string | null>(null)
|
|
const isCreatingTask = ref(false)
|
|
|
|
// Computed properties
|
|
const tasks = computed(() => {
|
|
if (!asset.value?.task_details) return []
|
|
|
|
// Transform TaskStatusInfo to Task interface expected by the component
|
|
return asset.value.task_details.map((taskInfo: TaskStatusInfo) => {
|
|
// Find user name from user store if available
|
|
const assignedUser = taskInfo.assigned_user_id
|
|
? userStore.users.find(user => user.id === taskInfo.assigned_user_id)
|
|
: null
|
|
|
|
return {
|
|
id: taskInfo.task_id || 0,
|
|
name: formatTaskType(taskInfo.task_type),
|
|
task_type: taskInfo.task_type,
|
|
status: taskInfo.status,
|
|
assigned_user_name: assignedUser
|
|
? `${assignedUser.first_name} ${assignedUser.last_name}`.trim()
|
|
: undefined
|
|
}
|
|
})
|
|
})
|
|
|
|
const completedTasksCount = computed(() => {
|
|
return tasks.value.filter(task => task.status === 'approved').length
|
|
})
|
|
|
|
const progressPercentage = computed(() => {
|
|
if (tasks.value.length === 0) return 0
|
|
return Math.round((completedTasksCount.value / tasks.value.length) * 100)
|
|
})
|
|
|
|
// Task types offered in "Add Task" include the flat asset task type list
|
|
// plus every task type owned by an asset department (e.g. Modeling's own
|
|
// task types), deduped against types the asset already has a task for.
|
|
const availableTaskTypes = computed(() => {
|
|
const existingTypes = new Set(tasks.value.map(task => task.task_type))
|
|
const departmentTaskTypes = departmentsStore.getDepartmentsByType(props.projectId, 'asset')
|
|
.flatMap(d => d.task_types)
|
|
const merged = Array.from(new Set([...props.allTaskTypes, ...departmentTaskTypes]))
|
|
return merged.filter(type => !existingTypes.has(type))
|
|
})
|
|
|
|
const taskStatusCounts = computed(() => {
|
|
const counts = {
|
|
not_started: 0,
|
|
in_progress: 0,
|
|
submitted: 0,
|
|
approved: 0,
|
|
retake: 0
|
|
}
|
|
|
|
tasks.value.forEach(task => {
|
|
if (counts.hasOwnProperty(task.status)) {
|
|
counts[task.status as keyof typeof counts]++
|
|
}
|
|
})
|
|
|
|
return counts
|
|
})
|
|
|
|
|
|
|
|
// Methods
|
|
const loadAssetDetails = async () => {
|
|
try {
|
|
isLoading.value = true
|
|
error.value = null
|
|
asset.value = await assetService.getAsset(props.assetId)
|
|
departmentsStore.fetchProjectDepartments(props.projectId)
|
|
|
|
// Load users if not already loaded (for user name resolution)
|
|
if (userStore.users.length === 0) {
|
|
try {
|
|
await userStore.fetchAllUsers()
|
|
} catch (err) {
|
|
console.warn('Failed to load users for name resolution:', err)
|
|
}
|
|
}
|
|
} catch (err) {
|
|
error.value = err instanceof Error ? err.message : 'Failed to load asset details'
|
|
console.error('Failed to load asset details:', err)
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
const loadNotes = async () => {
|
|
if (tasks.value.length === 0) {
|
|
notes.value = []
|
|
submissions.value = []
|
|
return
|
|
}
|
|
try {
|
|
const [notesByTask, submissionsByTask] = await Promise.all([
|
|
Promise.all(tasks.value.map(task => taskService.getTaskNotes(task.id).catch(() => []))),
|
|
Promise.all(tasks.value.map(task => taskService.getTaskSubmissions(task.id).catch(() => [])))
|
|
])
|
|
notes.value = notesByTask.flat()
|
|
submissions.value = submissionsByTask.flat()
|
|
} catch (err) {
|
|
console.error('Failed to load notes:', err)
|
|
}
|
|
}
|
|
|
|
const loadReferences = async () => {
|
|
try {
|
|
// Load reference files (attachments) from all tasks
|
|
const { taskService } = await import('@/services/task')
|
|
const allReferences: any[] = []
|
|
|
|
for (const task of tasks.value) {
|
|
if (task.id) {
|
|
const taskAttachments = await taskService.getTaskAttachments(task.id)
|
|
// Add task info to each reference for context
|
|
const referencesWithContext = taskAttachments.map(attachment => ({
|
|
...attachment,
|
|
task_name: task.name,
|
|
task_type: task.task_type
|
|
}))
|
|
allReferences.push(...referencesWithContext)
|
|
}
|
|
}
|
|
|
|
// Sort by date (newest first)
|
|
references.value = allReferences.sort((a, b) =>
|
|
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
|
)
|
|
} catch (err) {
|
|
console.error('Failed to load references:', err)
|
|
}
|
|
}
|
|
|
|
const handleAddTask = async (taskType: string) => {
|
|
isCreatingTask.value = true
|
|
try {
|
|
await taskService.createAssetTask(props.assetId, taskType)
|
|
await loadAssetDetails()
|
|
} catch (err) {
|
|
console.error('Failed to create task:', err)
|
|
} finally {
|
|
isCreatingTask.value = false
|
|
}
|
|
}
|
|
|
|
const formatCategory = (category: string) => {
|
|
return category.charAt(0).toUpperCase() + category.slice(1)
|
|
}
|
|
|
|
const formatStatus = (status: AssetStatus) => {
|
|
return status.split('_').map(word =>
|
|
word.charAt(0).toUpperCase() + word.slice(1)
|
|
).join(' ')
|
|
}
|
|
|
|
const formatTaskStatus = (status: string) => {
|
|
return status.split('_').map(word =>
|
|
word.charAt(0).toUpperCase() + word.slice(1)
|
|
).join(' ')
|
|
}
|
|
|
|
const formatTaskType = (taskType: string) => {
|
|
return taskType.split('_').map(word =>
|
|
word.charAt(0).toUpperCase() + word.slice(1)
|
|
).join(' ')
|
|
}
|
|
|
|
const getStatusVariant = (status: AssetStatus) => {
|
|
switch (status) {
|
|
case AssetStatus.NOT_STARTED:
|
|
return 'secondary'
|
|
case AssetStatus.IN_PROGRESS:
|
|
return 'default'
|
|
case AssetStatus.ON_HOLD:
|
|
return 'outline'
|
|
case AssetStatus.COMPLETED:
|
|
return 'default'
|
|
case AssetStatus.APPROVED:
|
|
return 'default'
|
|
default:
|
|
return 'secondary'
|
|
}
|
|
}
|
|
|
|
const getStatusColor = (status: AssetStatus) => {
|
|
switch (status) {
|
|
case AssetStatus.NOT_STARTED:
|
|
return 'bg-gray-400'
|
|
case AssetStatus.IN_PROGRESS:
|
|
return 'bg-blue-500'
|
|
case AssetStatus.ON_HOLD:
|
|
return 'bg-yellow-500'
|
|
case AssetStatus.COMPLETED:
|
|
return 'bg-green-500'
|
|
case AssetStatus.APPROVED:
|
|
return 'bg-emerald-600'
|
|
default:
|
|
return 'bg-gray-400'
|
|
}
|
|
}
|
|
|
|
const getTaskStatusVariant = (status: string) => {
|
|
switch (status) {
|
|
case 'not_started':
|
|
return 'secondary'
|
|
case 'in_progress':
|
|
return 'default'
|
|
case 'submitted':
|
|
return 'outline'
|
|
case 'approved':
|
|
return 'default'
|
|
case 'retake':
|
|
return 'destructive'
|
|
default:
|
|
return 'secondary'
|
|
}
|
|
}
|
|
|
|
const formatDate = (dateString: string) => {
|
|
const date = new Date(dateString)
|
|
return date.toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric'
|
|
})
|
|
}
|
|
|
|
const formatDeletedDate = (deletedAt: string) => {
|
|
const date = new Date(deletedAt)
|
|
const now = new Date()
|
|
const diffInHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60))
|
|
|
|
if (diffInHours < 24) {
|
|
return `${diffInHours}h ago`
|
|
} else {
|
|
const diffInDays = Math.floor(diffInHours / 24)
|
|
return `${diffInDays}d ago`
|
|
}
|
|
}
|
|
|
|
// Watchers
|
|
watch(() => props.assetId, (newAssetId) => {
|
|
if (newAssetId) {
|
|
loadAssetDetails()
|
|
}
|
|
}, { immediate: true })
|
|
|
|
watch(() => asset.value?.task_details, () => {
|
|
if (asset.value?.task_details && asset.value.task_details.length > 0) {
|
|
loadNotes()
|
|
loadReferences()
|
|
}
|
|
}, { deep: true })
|
|
|
|
// Expose methods for parent component
|
|
defineExpose({
|
|
refresh: loadAssetDetails
|
|
})
|
|
</script>
|