74be250912
Moves shot/ShotNotes.vue to shared/EntityNotes.vue (it had no shot-specific logic) and wires it into AssetDetailPanel.vue too, replacing the old read-only AssetNotes.vue. Asset panel gains the same multi-select task filter, sort, client-only/submission-notes toggles, and bottom composer that Shot's panel already had, plus the same min-h-0 layout fix needed for the pinned composer. NoteItem's absolute-format timestamp now shows date-only (y/m/d) with an info icon whose tooltip reveals the full y/m/d H:M:S - applied to both note and submission-note entries. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
581 lines
22 KiB
Vue
581 lines
22 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 flex flex-col min-h-0">
|
|
<DetailPanelHeader class="flex-shrink-0" :title="shot.name" :deleted-at="shot.deleted_at" @close="$emit('close')">
|
|
<template #badges>
|
|
<!-- Deletion status indicator for admins -->
|
|
<Badge v-if="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 min-h-0">
|
|
<TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-5 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="shotNotes.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"
|
|
>
|
|
{{ shotNotes.length > 99 ? '99+' : shotNotes.length }}
|
|
</span>
|
|
</span>
|
|
<span class="sr-only">Notes</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="assets" title="Assets">
|
|
<Package class="h-4 w-4" />
|
|
<span class="sr-only">Assets</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="references" title="References">
|
|
<Image class="h-4 w-4" />
|
|
<span class="sr-only">References</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="design" title="Design">
|
|
<Edit class="h-4 w-4" />
|
|
<span class="sr-only">Design</span>
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<!-- Infos Tab -->
|
|
<TabsContent value="infos" class="flex-1 overflow-y-auto p-6 space-y-6 m-0">
|
|
<!-- 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 Cards -->
|
|
<div v-else class="space-y-1.5">
|
|
<Card
|
|
v-for="task in tasks"
|
|
:key="task.id"
|
|
class="flex items-center justify-between gap-2 px-3 py-2 rounded-lg shadow-none hover:bg-muted/50 cursor-pointer transition-colors"
|
|
@click="$emit('select-task', task, 'infos')"
|
|
>
|
|
<span class="text-sm font-medium truncate">{{ formatTaskType(task.task_type) }}</span>
|
|
<div class="flex items-center gap-2 flex-shrink-0">
|
|
<Avatar class="h-5 w-5" v-if="task.assigned_user_name" :title="task.assigned_user_name">
|
|
<AvatarImage :src="getAvatarUrl(task.assigned_user_avatar_url, task.assigned_user_first_name, task.assigned_user_last_name)" />
|
|
<AvatarFallback class="text-[9px]">{{ getTaskAssigneeInitials(task) }}</AvatarFallback>
|
|
</Avatar>
|
|
<span v-else class="text-xs text-muted-foreground">Unassigned</span>
|
|
<TaskStatusBadge :status="getTaskStatusObject(task)" compact />
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<!-- Notes Tab -->
|
|
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
|
|
<div v-if="isLoadingNotes" class="text-center py-8 text-sm text-muted-foreground">
|
|
Loading notes...
|
|
</div>
|
|
<EntityNotes
|
|
v-else
|
|
:key="shotId"
|
|
:tasks="tasks"
|
|
:notes="shotNotes"
|
|
:submissions="shotSubmissions"
|
|
@notes-updated="loadShotNotes"
|
|
/>
|
|
</TabsContent>
|
|
|
|
<!-- Assets Tab -->
|
|
<TabsContent value="assets" class="flex-1 overflow-y-auto p-6 m-0">
|
|
<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 overflow-y-auto p-6 m-0">
|
|
<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 overflow-y-auto p-6 m-0">
|
|
<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, Info
|
|
} from 'lucide-vue-next'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Card } from '@/components/ui/card'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
|
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
|
|
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
|
|
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
|
|
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
|
import EntityNotes from '@/components/shared/EntityNotes.vue'
|
|
|
|
import { shotService, type Shot, type TaskStatusInfo } from '@/services/shot'
|
|
import { taskService, type ProductionNote, type Submission } from '@/services/task'
|
|
import { projectService, type ProjectMember } from '@/services/project'
|
|
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
|
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
|
import { usePermission } from '@/composables/usePermission'
|
|
|
|
// Use TaskStatusInfo from shot service instead of local Task interface
|
|
interface Task extends TaskStatusInfo {
|
|
id: number
|
|
name?: string
|
|
assigned_user_name?: string
|
|
assigned_user_avatar_url?: string | null
|
|
assigned_user_first_name?: string
|
|
assigned_user_last_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, noteId?: number): void
|
|
(e: 'link-asset'): void
|
|
(e: 'edit-design'): void
|
|
(e: 'close'): void
|
|
}
|
|
|
|
const props = defineProps<Props>()
|
|
const emit = defineEmits<Emits>()
|
|
|
|
const taskStatusesStore = useTaskStatusesStore()
|
|
const { getAvatarUrl } = useAvatarUrl()
|
|
const { isAdmin, isCoordinatorOrAdmin } = usePermission()
|
|
|
|
// 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)
|
|
const projectMembers = ref<ProjectMember[]>([])
|
|
const shotNotes = ref<ProductionNote[]>([])
|
|
const shotSubmissions = ref<Submission[]>([])
|
|
const isLoadingNotes = 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(() => isCoordinatorOrAdmin.value)
|
|
|
|
const canLinkAssets = computed(() => isCoordinatorOrAdmin.value)
|
|
|
|
const canUploadReferences = computed(() => {
|
|
return true // All users can upload references
|
|
})
|
|
|
|
const canEditDesign = computed(() => isCoordinatorOrAdmin.value)
|
|
|
|
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)
|
|
await Promise.all([
|
|
taskStatusesStore.fetchProjectStatuses(props.projectId),
|
|
loadProjectMembers()
|
|
])
|
|
loadTasks() // No longer async - uses embedded data
|
|
loadShotNotes() // Fire-and-forget - own loading state, doesn't block the panel
|
|
} 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 loadShotNotes = async () => {
|
|
if (tasks.value.length === 0) {
|
|
shotNotes.value = []
|
|
shotSubmissions.value = []
|
|
return
|
|
}
|
|
try {
|
|
isLoadingNotes.value = true
|
|
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(() => [])))
|
|
])
|
|
shotNotes.value = notesByTask.flat()
|
|
shotSubmissions.value = submissionsByTask.flat()
|
|
} catch (err) {
|
|
console.error('Failed to load shot notes:', err)
|
|
} finally {
|
|
isLoadingNotes.value = false
|
|
}
|
|
}
|
|
|
|
const loadProjectMembers = async () => {
|
|
try {
|
|
projectMembers.value = await projectService.getProjectMembers(props.projectId)
|
|
} catch (err) {
|
|
console.error('Failed to load project members:', err)
|
|
}
|
|
}
|
|
|
|
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 => {
|
|
const assignedUser = projectMembers.value.find(member => member.user_id === taskInfo.assigned_user_id)
|
|
return {
|
|
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: assignedUser ? `${assignedUser.user_first_name} ${assignedUser.user_last_name}` : undefined,
|
|
assigned_user_avatar_url: assignedUser?.user_avatar_url,
|
|
assigned_user_first_name: assignedUser?.user_first_name,
|
|
assigned_user_last_name: assignedUser?.user_last_name
|
|
}
|
|
})
|
|
} else {
|
|
tasks.value = []
|
|
}
|
|
}
|
|
|
|
// Resolve the project's actual status object (name + color) for a task, matching the shot table
|
|
const getTaskStatusObject = (task: Task) => {
|
|
const statusId = task.status.toString()
|
|
return taskStatusesStore.getStatusById(props.projectId, statusId) || statusId
|
|
}
|
|
|
|
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 formatTaskType = (taskType: string) => {
|
|
return taskType.split('_').map(word =>
|
|
word.charAt(0).toUpperCase() + word.slice(1)
|
|
).join(' ')
|
|
}
|
|
|
|
const getTaskAssigneeInitials = (task: Task) => {
|
|
const first = task.assigned_user_first_name?.charAt(0) || ''
|
|
const last = task.assigned_user_last_name?.charAt(0) || ''
|
|
return (first + last).toUpperCase()
|
|
}
|
|
|
|
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>
|