Redesign shot detail panel notes: aggregated composer, filters, submission notes
Extracts a new ShotNotes.vue component (mirroring TaskNotes.vue) with a pinned bottom composer, multi-select task filter, newest/oldest sort, and toggleable client-only / submission-notes filters. Notes now merge production notes with each task's submission notes into one chronological, absolute-timestamped (y/m/d H:M:S) list. Required restructuring ShotDetailPanel's outer layout to the same bounded flex-column pattern TaskDetailPanel already uses, since a properly pinned composer needs a min-h-0 flex chain that the old single-scroll wrapper didn't provide. NoteItem.vue gains optional dateFormat and hideClientBadge props (both default to prior behavior, so TaskDetailPanel is unaffected), compacts Edit/Delete to icon-only, and the Client badge is now orange/white instead of a plain outline. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,8 +10,8 @@
|
||||
/>
|
||||
|
||||
<!-- 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')">
|
||||
<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">
|
||||
@@ -21,8 +21,8 @@
|
||||
</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">
|
||||
<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>
|
||||
@@ -54,7 +54,7 @@
|
||||
</TabsList>
|
||||
|
||||
<!-- Infos Tab -->
|
||||
<TabsContent value="infos" class="flex-1 p-6 space-y-6">
|
||||
<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>
|
||||
@@ -187,101 +187,45 @@
|
||||
<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
|
||||
<!-- Tasks Cards -->
|
||||
<div v-else class="space-y-1.5">
|
||||
<Card
|
||||
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"
|
||||
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')"
|
||||
>
|
||||
<div class="text-sm font-medium">{{ formatTaskType(task.task_type) }}</div>
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
|
||||
<Avatar class="h-5 w-5 flex-shrink-0" v-if="task.assigned_user_name">
|
||||
<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 class="truncate">{{ task.assigned_user_name || 'Unassigned' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span v-else class="text-xs text-muted-foreground">Unassigned</span>
|
||||
<TaskStatusBadge :status="getTaskStatusObject(task)" compact />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</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>
|
||||
|
||||
<Select v-if="tasks.length > 0" v-model="noteTaskFilter">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Tasks</SelectItem>
|
||||
<SelectItem v-for="task in tasks" :key="task.id" :value="task.id">
|
||||
{{ formatTaskType(task.task_type) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<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>
|
||||
|
||||
<div v-else-if="filteredShotNotes.length > 0" class="space-y-4">
|
||||
<div v-for="note in filteredShotNotes" :key="note.id" class="space-y-1">
|
||||
<Badge variant="outline" class="text-xs">{{ formatTaskType(taskTypeForNote(note) || '') }}</Badge>
|
||||
<NoteItem
|
||||
:note="note"
|
||||
:task-id="note.task_id"
|
||||
@note-updated="handleNoteUpdated"
|
||||
@reply="handleNoteReply"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else 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>
|
||||
<ShotNotes
|
||||
v-else
|
||||
:key="shotId"
|
||||
:tasks="tasks"
|
||||
:notes="shotNotes"
|
||||
:submissions="shotSubmissions"
|
||||
@notes-updated="loadShotNotes"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<!-- Assets Tab -->
|
||||
<TabsContent value="assets" class="flex-1 p-6">
|
||||
<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
|
||||
@@ -303,7 +247,7 @@
|
||||
</TabsContent>
|
||||
|
||||
<!-- References Tab -->
|
||||
<TabsContent value="references" class="flex-1 p-6">
|
||||
<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">
|
||||
@@ -339,7 +283,7 @@
|
||||
</TabsContent>
|
||||
|
||||
<!-- Design Tab -->
|
||||
<TabsContent value="design" class="flex-1 p-6">
|
||||
<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
|
||||
@@ -382,19 +326,19 @@ import {
|
||||
} 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
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 NoteItem from '@/components/task/NoteItem.vue'
|
||||
import ShotNotes from './ShotNotes.vue'
|
||||
|
||||
import { shotService, type Shot, type TaskStatusInfo } from '@/services/shot'
|
||||
import { taskService, type ProductionNote } from '@/services/task'
|
||||
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'
|
||||
@@ -442,8 +386,8 @@ 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)
|
||||
const noteTaskFilter = ref<number | 'all'>('all')
|
||||
|
||||
// Computed properties
|
||||
const frameCount = computed(() => {
|
||||
@@ -483,19 +427,6 @@ const taskStatusCounts = computed(() => {
|
||||
|
||||
const canCreateTask = computed(() => isCoordinatorOrAdmin.value)
|
||||
|
||||
const canCreateNote = computed(() => isCoordinatorOrAdmin.value)
|
||||
|
||||
const filteredShotNotes = computed(() => {
|
||||
const notes = noteTaskFilter.value === 'all'
|
||||
? shotNotes.value
|
||||
: shotNotes.value.filter(note => note.task_id === noteTaskFilter.value)
|
||||
return [...notes].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
})
|
||||
|
||||
const taskTypeForNote = (note: ProductionNote) => {
|
||||
return tasks.value.find(task => task.id === note.task_id)?.task_type
|
||||
}
|
||||
|
||||
const canLinkAssets = computed(() => isCoordinatorOrAdmin.value)
|
||||
|
||||
const canUploadReferences = computed(() => {
|
||||
@@ -532,14 +463,17 @@ const loadShotDetails = async () => {
|
||||
const loadShotNotes = async () => {
|
||||
if (tasks.value.length === 0) {
|
||||
shotNotes.value = []
|
||||
shotSubmissions.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
isLoadingNotes.value = true
|
||||
const notesByTask = await Promise.all(
|
||||
tasks.value.map(task => taskService.getTaskNotes(task.id).catch(() => []))
|
||||
)
|
||||
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 {
|
||||
@@ -547,18 +481,6 @@ const loadShotNotes = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleNoteUpdated = () => {
|
||||
loadShotNotes()
|
||||
}
|
||||
|
||||
const handleNoteReply = (noteId: number) => {
|
||||
const note = shotNotes.value.find(n => n.id === noteId)
|
||||
const task = note ? tasks.value.find(t => t.id === note.task_id) : undefined
|
||||
if (task) {
|
||||
emit('select-task', task, 'notes', noteId)
|
||||
}
|
||||
}
|
||||
|
||||
const loadProjectMembers = async () => {
|
||||
try {
|
||||
projectMembers.value = await projectService.getProjectMembers(props.projectId)
|
||||
@@ -647,7 +569,6 @@ const formatDeletedDate = (deletedAt: string) => {
|
||||
// Watchers
|
||||
watch(() => props.shotId, (newShotId) => {
|
||||
if (newShotId) {
|
||||
noteTaskFilter.value = 'all'
|
||||
loadShotDetails()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
Reference in New Issue
Block a user