Redesign note display as message cards; fix reply and Textarea v-model bugs

- NoteItem.vue: render each note as a rounded card (own notes tinted)
  instead of a flat list row.
- TaskNotes.vue: show a "Replying to X" banner with cancel and
  auto-focus the composer when Reply is clicked, so replying is
  actually visible instead of silently setting hidden state.
- ShotDetailPanel/ShotBrowser/TaskDetailPanel: carry the target note
  id through select-task so replying from the shot-level aggregated
  notes view pre-fills the reply in the destination task's panel
  instead of just switching tabs.
- Textarea.vue: fix v-model, which was never wired up (modelValue/
  update:modelValue fell through as dead attrs on the native
  textarea), so typed content never reached the bound ref anywhere
  it's used - notes, submissions, and shot/asset/episode/project
  description fields.
- AssetDetailPanel/ShotDetailPanel/TaskDetailPanel: drop the header
  status badge and switch tab labels to icons (with an unread-style
  badge on Notes), fixing the TabsTrigger clipping/vertical alignment
  it introduced.
This commit is contained in:
2026-07-19 00:00:11 +08:00
parent 4bffec642e
commit 762bd34f74
8 changed files with 262 additions and 133 deletions
+109 -57
View File
@@ -13,13 +13,6 @@
<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="isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
Deleted {{ formatDeletedDate(shot.deleted_at) }}
@@ -30,11 +23,34 @@
<!-- 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>
<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 -->
@@ -229,7 +245,35 @@
</Popover>
</div>
<div class="text-center py-8">
<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>
<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>
@@ -334,21 +378,23 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
ListTodo, Plus, MessageSquare, Package, Image, Edit, Send
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 { 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 { shotService, ShotStatus, type Shot, type TaskStatusInfo } from '@/services/shot'
import { taskService } from '@/services/task'
import { shotService, type Shot, type TaskStatusInfo } from '@/services/shot'
import { taskService, type ProductionNote } from '@/services/task'
import { projectService, type ProjectMember } from '@/services/project'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
@@ -375,7 +421,7 @@ interface Props {
interface Emits {
(e: 'edit', shot: Shot): void
(e: 'delete', shot: Shot): void
(e: 'select-task', task: Task, tab?: string): void
(e: 'select-task', task: Task, tab?: string, noteId?: number): void
(e: 'link-asset'): void
(e: 'edit-design'): void
(e: 'close'): void
@@ -395,6 +441,9 @@ const isLoading = ref(false)
const error = ref<string | null>(null)
const isCreatingTask = ref(false)
const projectMembers = ref<ProjectMember[]>([])
const shotNotes = ref<ProductionNote[]>([])
const isLoadingNotes = ref(false)
const noteTaskFilter = ref<number | 'all'>('all')
// Computed properties
const frameCount = computed(() => {
@@ -436,6 +485,17 @@ 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(() => {
@@ -460,6 +520,7 @@ const loadShotDetails = async () => {
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)
@@ -468,6 +529,36 @@ const loadShotDetails = async () => {
}
}
const loadShotNotes = async () => {
if (tasks.value.length === 0) {
shotNotes.value = []
return
}
try {
isLoadingNotes.value = true
const notesByTask = await Promise.all(
tasks.value.map(task => taskService.getTaskNotes(task.id).catch(() => []))
)
shotNotes.value = notesByTask.flat()
} catch (err) {
console.error('Failed to load shot notes:', err)
} finally {
isLoadingNotes.value = false
}
}
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)
@@ -519,12 +610,6 @@ const handleAddTask = async (taskType: string) => {
}
}
const formatStatus = (status: ShotStatus) => {
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)
@@ -537,40 +622,6 @@ const getTaskAssigneeInitials = (task: Task) => {
return (first + last).toUpperCase()
}
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 formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
@@ -596,6 +647,7 @@ const formatDeletedDate = (deletedAt: string) => {
// Watchers
watch(() => props.shotId, (newShotId) => {
if (newShotId) {
noteTaskFilter.value = 'all'
loadShotDetails()
}
}, { immediate: true })