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:
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user