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:
2026-07-20 11:11:32 +08:00
parent 9c5abf6342
commit 172e05af3e
3 changed files with 425 additions and 122 deletions
+36 -115
View File
@@ -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 })