Files
LinkDesk/frontend/src/components/shot/ShotDetailPanel.vue
T
indigo 26807984ee Phase 3: Unify controls across shot/asset/task toolbars and detail panels
Extracts duplication that built up as shot/asset/task features reached
parity: CheckableCommandItem/ColumnToggleList replace 14+ hand-rolled
checkbox-list blocks, shared toolbar pieces (debounced search, detail-panel
toggle, clear-filters, segmented view/context toggle) replace copy-pasted
markup in the three table toolbars, icon-only buttons standardize on the
icon-sm size, TaskBulkActionsMenu's Assign To submenu matches Set Status,
and a shared DetailPanelOverlay/Header/Loading/Error shell backs all three
detail panels (adding a previously-missing error state to the task panel).
2026-07-18 02:15:55 +08:00

597 lines
21 KiB
Vue

<template>
<div class="h-full flex flex-col">
<DetailPanelLoading v-if="isLoading" label="Loading shot details..." />
<DetailPanelError
v-else-if="error"
title="Failed to load shot"
:message="error"
@retry="loadShotDetails"
/>
<!-- Shot Details -->
<div v-else-if="shot" class="flex-1 overflow-y-auto">
<DetailPanelHeader :title="shot.name" :deleted-at="shot.deleted_at" @close="$emit('close')">
<template #badges>
<Badge :variant="getStatusVariant(shot.status)" class="text-xs flex-shrink-0">
<div
class="w-2 h-2 rounded-full mr-1"
:class="getStatusColor(shot.status)"
></div>
{{ formatStatus(shot.status) }}
</Badge>
<!-- Deletion status indicator for admins -->
<Badge v-if="authStore.isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
Deleted {{ formatDeletedDate(shot.deleted_at) }}
</Badge>
</template>
</DetailPanelHeader>
<!-- Tabbed Content -->
<Tabs default-value="infos" class="flex-1 flex flex-col">
<TabsList class="mx-0 mt-0 grid w-full grid-cols-5 rounded-none border-b">
<TabsTrigger value="infos">Infos</TabsTrigger>
<TabsTrigger value="notes">Notes</TabsTrigger>
<TabsTrigger value="assets">Assets</TabsTrigger>
<TabsTrigger value="references">References</TabsTrigger>
<TabsTrigger value="design">Design</TabsTrigger>
</TabsList>
<!-- Infos Tab -->
<TabsContent value="infos" class="flex-1 p-6 space-y-6">
<!-- Shot Information -->
<div class="space-y-4">
<h3 class="text-sm font-semibold">Shot Information</h3>
<div class="space-y-3">
<div class="grid grid-cols-2 gap-4 text-xs">
<div>
<Label class="text-muted-foreground">Frame Range</Label>
<p class="text-sm mt-1">{{ shot.frame_start }} - {{ shot.frame_end }}</p>
</div>
<div>
<Label class="text-muted-foreground">Duration</Label>
<p class="text-sm mt-1">{{ frameCount }} frames</p>
</div>
</div>
<div>
<Label class="text-xs text-muted-foreground">Description</Label>
<p class="text-sm mt-1">
{{ shot.description || 'No description provided' }}
</p>
</div>
<div class="grid grid-cols-2 gap-4 text-xs">
<div>
<Label class="text-muted-foreground">Created</Label>
<p>{{ formatDate(shot.created_at) }}</p>
</div>
<div>
<Label class="text-muted-foreground">Updated</Label>
<p>{{ formatDate(shot.updated_at) }}</p>
</div>
</div>
</div>
</div>
<!-- Progress Overview -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">Progress Overview</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>
<!-- Task Status Summary -->
<div class="grid grid-cols-2 gap-2 text-xs">
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-gray-400"></div>
<span class="text-muted-foreground">Not Started: {{ taskStatusCounts.not_started }}</span>
</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-blue-500"></div>
<span class="text-muted-foreground">In Progress: {{ taskStatusCounts.in_progress }}</span>
</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-yellow-500"></div>
<span class="text-muted-foreground">Submitted: {{ taskStatusCounts.submitted }}</span>
</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-green-500"></div>
<span class="text-muted-foreground">Approved: {{ taskStatusCounts.approved }}</span>
</div>
</div>
</div>
<!-- Tasks List -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">Tasks</h3>
<div class="flex items-center gap-1">
<Popover v-if="canCreateTask">
<PopoverTrigger as-child>
<Button size="sm" variant="outline" :disabled="availableTaskTypes.length === 0">
<Plus class="h-3 w-3 mr-1" />
Add Task
</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>
<Popover>
<PopoverTrigger as-child>
<Button variant="ghost" size="icon-sm" 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')"
>
{{ formatTaskType(task.task_type) }}
</Button>
</div>
</PopoverContent>
</Popover>
</div>
</div>
<!-- No Tasks -->
<div v-if="tasks.length === 0" class="text-center py-8">
<ListTodo class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
<p class="text-sm text-muted-foreground">No tasks yet</p>
<p class="text-xs text-muted-foreground mt-1">Create tasks to track work on this shot</p>
</div>
<!-- Tasks Table -->
<div v-else class="border rounded-lg overflow-hidden">
<div class="bg-muted/50 px-4 py-2 grid grid-cols-3 gap-4 text-xs font-medium text-muted-foreground border-b">
<div>Task Type</div>
<div>Assignee</div>
<div>Status</div>
</div>
<div
v-for="task in tasks"
:key="task.id"
class="px-4 py-3 grid grid-cols-3 gap-4 items-center hover:bg-muted/50 cursor-pointer transition-colors border-b last:border-b-0"
@click="$emit('select-task', task, 'infos')"
>
<div class="text-sm font-medium">{{ formatTaskType(task.task_type) }}</div>
<div class="text-sm text-muted-foreground">
{{ task.assigned_user_name || 'Unassigned' }}
</div>
<div>
<Badge :variant="getTaskStatusVariant(task.status)" class="text-xs">
{{ formatTaskStatus(task.status.toString()) }}
</Badge>
</div>
</div>
</div>
</div>
</TabsContent>
<!-- Notes Tab -->
<TabsContent value="notes" class="flex-1 p-6 space-y-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold">Production Notes</h3>
<Popover v-if="canCreateNote">
<PopoverTrigger as-child>
<Button size="sm" variant="outline" :disabled="tasks.length === 0">
<Plus class="h-3 w-3 mr-1" />
Add Note
</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')"
>
{{ formatTaskType(task.task_type) }}
</Button>
</div>
</PopoverContent>
</Popover>
</div>
<div class="text-center py-8">
<MessageSquare class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
<p class="text-sm text-muted-foreground">No notes yet</p>
<p class="text-xs text-muted-foreground mt-1">Add notes to track important information</p>
</div>
</TabsContent>
<!-- Assets Tab -->
<TabsContent value="assets" class="flex-1 p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold">Linked Assets</h3>
<Button
v-if="canLinkAssets"
size="sm"
variant="outline"
@click="$emit('link-asset')"
>
<Plus class="h-3 w-3 mr-1" />
Link Asset
</Button>
</div>
<div class="text-center py-8">
<Package class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
<p class="text-sm text-muted-foreground">No assets linked</p>
<p class="text-xs text-muted-foreground mt-1">Link assets that are used in this shot</p>
</div>
</TabsContent>
<!-- References Tab -->
<TabsContent value="references" class="flex-1 p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold">Reference Files</h3>
<Popover v-if="canUploadReferences">
<PopoverTrigger as-child>
<Button size="sm" variant="outline" :disabled="tasks.length === 0">
<Plus class="h-3 w-3 mr-1" />
Upload Reference
</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')"
>
{{ formatTaskType(task.task_type) }}
</Button>
</div>
</PopoverContent>
</Popover>
</div>
<div class="text-center py-8">
<Image class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
<p class="text-sm text-muted-foreground">No reference files</p>
<p class="text-xs text-muted-foreground mt-1">Upload images, videos, or documents for reference</p>
</div>
</TabsContent>
<!-- Design Tab -->
<TabsContent value="design" class="flex-1 p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold">Design Information</h3>
<Button
v-if="canEditDesign"
size="sm"
variant="outline"
@click="$emit('edit-design')"
>
<Edit class="h-3 w-3 mr-1" />
Edit Design
</Button>
</div>
<div class="space-y-4">
<div>
<Label class="text-xs text-muted-foreground">Camera Notes</Label>
<p class="text-sm mt-1 text-muted-foreground">No camera notes</p>
</div>
<div>
<Label class="text-xs text-muted-foreground">Lighting Notes</Label>
<p class="text-sm mt-1 text-muted-foreground">No lighting notes</p>
</div>
<div>
<Label class="text-xs text-muted-foreground">Animation Notes</Label>
<p class="text-sm mt-1 text-muted-foreground">No animation notes</p>
</div>
</div>
</TabsContent>
</Tabs>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
ListTodo, Plus, MessageSquare, Package, Image, Edit, Send
} 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 { shotService, ShotStatus, type Shot, type TaskStatusInfo, TaskStatus } from '@/services/shot'
import { taskService } from '@/services/task'
import { useAuthStore } from '@/stores/auth'
// Use TaskStatusInfo from shot service instead of local Task interface
interface Task extends TaskStatusInfo {
id: number
name?: string
assigned_user_name?: string
deadline?: string
}
interface Props {
projectId: number
shotId: number
initialShot?: Shot
allTaskTypes: string[]
}
interface Emits {
(e: 'edit', shot: Shot): void
(e: 'delete', shot: Shot): void
(e: 'select-task', task: Task, tab?: string): void
(e: 'link-asset'): void
(e: 'edit-design'): void
(e: 'close'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const authStore = useAuthStore()
// Reactive state
const shot = ref<Shot | null>(null)
const tasks = ref<Task[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const isCreatingTask = ref(false)
// Computed properties
const frameCount = computed(() => {
if (!shot.value) return 0
return shot.value.frame_end - shot.value.frame_start + 1
})
const completedTasksCount = computed(() => {
return tasks.value.filter(task => task.status.toString() === 'approved').length
})
const progressPercentage = computed(() => {
if (tasks.value.length === 0) return 0
return Math.round((completedTasksCount.value / tasks.value.length) * 100)
})
const taskStatusCounts = computed(() => {
const counts = {
not_started: 0,
in_progress: 0,
submitted: 0,
approved: 0,
retake: 0
}
tasks.value.forEach(task => {
const statusKey = task.status.toString() // Convert enum to string
if (counts.hasOwnProperty(statusKey)) {
counts[statusKey as keyof typeof counts]++
}
})
return counts
})
const canCreateTask = computed(() => {
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
})
const canCreateNote = computed(() => {
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
})
const canLinkAssets = computed(() => {
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
})
const canUploadReferences = computed(() => {
return true // All users can upload references
})
const canEditDesign = computed(() => {
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
})
const availableTaskTypes = computed(() => {
const existingTypes = new Set(tasks.value.map(task => task.task_type))
return props.allTaskTypes.filter(type => !existingTypes.has(type))
})
// Methods
const loadShotDetails = async () => {
try {
isLoading.value = true
error.value = null
shot.value = props.initialShot ?? await shotService.getShot(props.shotId)
loadTasks() // No longer async - uses embedded data
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load shot details'
console.error('Failed to load shot details:', err)
} finally {
isLoading.value = false
}
}
const loadTasks = () => {
// Use task_details already embedded in shot data - no API call needed!
if (shot.value?.task_details) {
tasks.value = shot.value.task_details.map(taskInfo => ({
id: taskInfo.task_id || 0,
task_type: taskInfo.task_type,
status: taskInfo.status,
assigned_user_id: taskInfo.assigned_user_id,
// Add placeholder values for display compatibility
name: taskInfo.task_type, // Use task_type as name for display
assigned_user_name: undefined // Will be resolved if needed
}))
} else {
tasks.value = []
}
}
const handleAddTask = async (taskType: string) => {
isCreatingTask.value = true
try {
await taskService.createShotTask(props.shotId, taskType)
// Bypass initialShot (which would just return the stale cached object) to get the new task
shot.value = await shotService.getShot(props.shotId)
loadTasks()
} catch (err) {
console.error('Failed to create task:', err)
} finally {
isCreatingTask.value = false
}
}
const formatStatus = (status: ShotStatus) => {
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: ShotStatus) => {
switch (status) {
case ShotStatus.NOT_STARTED:
return 'secondary'
case ShotStatus.IN_PROGRESS:
return 'default'
case ShotStatus.ON_HOLD:
return 'outline'
case ShotStatus.COMPLETED:
return 'default'
case ShotStatus.APPROVED:
return 'default'
default:
return 'secondary'
}
}
const getStatusColor = (status: ShotStatus) => {
switch (status) {
case ShotStatus.NOT_STARTED:
return 'bg-gray-400'
case ShotStatus.IN_PROGRESS:
return 'bg-blue-500'
case ShotStatus.ON_HOLD:
return 'bg-yellow-500'
case ShotStatus.COMPLETED:
return 'bg-green-500'
case ShotStatus.APPROVED:
return 'bg-emerald-600'
default:
return 'bg-gray-400'
}
}
const getTaskStatusVariant = (status: string | TaskStatus) => {
const statusStr = status.toString()
switch (statusStr) {
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.shotId, (newShotId) => {
if (newShotId) {
loadShotDetails()
}
}, { immediate: true })
// Expose methods for parent component
defineExpose({
refresh: loadShotDetails
})
</script>