Generalize aggregated notes to a shared component, reuse in Asset panel
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>
This commit is contained in:
@@ -10,8 +10,8 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Asset Details -->
|
<!-- Asset Details -->
|
||||||
<div v-else-if="asset" class="flex-1 overflow-y-auto">
|
<div v-else-if="asset" class="flex-1 flex flex-col min-h-0">
|
||||||
<DetailPanelHeader :title="asset.name" :deleted-at="asset.deleted_at" @close="$emit('close')">
|
<DetailPanelHeader class="flex-shrink-0" :title="asset.name" :deleted-at="asset.deleted_at" @close="$emit('close')">
|
||||||
<template #badges>
|
<template #badges>
|
||||||
<!-- Deletion status indicator for admins -->
|
<!-- Deletion status indicator for admins -->
|
||||||
<Badge v-if="authStore.isAdmin && asset.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
|
<Badge v-if="authStore.isAdmin && asset.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
|
||||||
@@ -21,8 +21,8 @@
|
|||||||
</DetailPanelHeader>
|
</DetailPanelHeader>
|
||||||
|
|
||||||
<!-- Tabbed Content -->
|
<!-- Tabbed Content -->
|
||||||
<Tabs default-value="infos" class="flex-1 flex flex-col">
|
<Tabs default-value="infos" class="flex-1 flex flex-col min-h-0">
|
||||||
<TabsList class="mx-0 mt-0 grid w-full grid-cols-3 rounded-none border-b">
|
<TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-3 rounded-none border-b">
|
||||||
<TabsTrigger value="infos" title="Infos">
|
<TabsTrigger value="infos" title="Infos">
|
||||||
<Info class="h-4 w-4" />
|
<Info class="h-4 w-4" />
|
||||||
<span class="sr-only">Infos</span>
|
<span class="sr-only">Infos</span>
|
||||||
@@ -255,7 +255,13 @@
|
|||||||
|
|
||||||
<!-- Notes Tab -->
|
<!-- Notes Tab -->
|
||||||
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
|
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
|
||||||
<AssetNotes :asset-id="assetId" :notes="notes" @notes-updated="loadNotes" />
|
<EntityNotes
|
||||||
|
:key="assetId"
|
||||||
|
:tasks="tasks"
|
||||||
|
:notes="notes"
|
||||||
|
:submissions="submissions"
|
||||||
|
@notes-updated="loadNotes"
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- References Tab -->
|
<!-- References Tab -->
|
||||||
@@ -280,11 +286,11 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
|||||||
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
|
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
|
||||||
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
|
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
|
||||||
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
|
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
|
||||||
import AssetNotes from './AssetNotes.vue'
|
import EntityNotes from '@/components/shared/EntityNotes.vue'
|
||||||
import AssetReferences from './AssetReferences.vue'
|
import AssetReferences from './AssetReferences.vue'
|
||||||
|
|
||||||
import { assetService, AssetStatus, type Asset, type TaskStatusInfo } from '@/services/asset'
|
import { assetService, AssetStatus, type Asset, type TaskStatusInfo } from '@/services/asset'
|
||||||
import { taskService } from '@/services/task'
|
import { taskService, type ProductionNote, type Submission } from '@/services/task'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
@@ -318,7 +324,8 @@ const userStore = useUserStore()
|
|||||||
|
|
||||||
// Reactive state
|
// Reactive state
|
||||||
const asset = ref<Asset | null>(null)
|
const asset = ref<Asset | null>(null)
|
||||||
const notes = ref<any[]>([])
|
const notes = ref<ProductionNote[]>([])
|
||||||
|
const submissions = ref<Submission[]>([])
|
||||||
const references = ref<any[]>([])
|
const references = ref<any[]>([])
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
@@ -405,28 +412,18 @@ const loadAssetDetails = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadNotes = async () => {
|
const loadNotes = async () => {
|
||||||
|
if (tasks.value.length === 0) {
|
||||||
|
notes.value = []
|
||||||
|
submissions.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// Load notes from all tasks associated with this asset
|
const [notesByTask, submissionsByTask] = await Promise.all([
|
||||||
const { taskService } = await import('@/services/task')
|
Promise.all(tasks.value.map(task => taskService.getTaskNotes(task.id).catch(() => []))),
|
||||||
const allNotes: any[] = []
|
Promise.all(tasks.value.map(task => taskService.getTaskSubmissions(task.id).catch(() => [])))
|
||||||
|
])
|
||||||
for (const task of tasks.value) {
|
notes.value = notesByTask.flat()
|
||||||
if (task.id) {
|
submissions.value = submissionsByTask.flat()
|
||||||
const taskNotes = await taskService.getTaskNotes(task.id)
|
|
||||||
// Add task info to each note for context
|
|
||||||
const notesWithContext = taskNotes.map(note => ({
|
|
||||||
...note,
|
|
||||||
task_name: task.name,
|
|
||||||
task_type: task.task_type
|
|
||||||
}))
|
|
||||||
allNotes.push(...notesWithContext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by date (newest first)
|
|
||||||
notes.value = allNotes.sort((a, b) =>
|
|
||||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
|
||||||
)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load notes:', err)
|
console.error('Failed to load notes:', err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col h-full">
|
|
||||||
<!-- Notes History (Top) -->
|
|
||||||
<div class="flex-1 overflow-y-auto p-4 space-y-3">
|
|
||||||
<div v-if="notes.length === 0" class="flex flex-col items-center justify-center h-full text-muted-foreground">
|
|
||||||
<MessageSquarePlus class="h-10 w-10 mb-2 opacity-50" />
|
|
||||||
<p class="text-sm">No notes yet for this asset's tasks.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="note in notes"
|
|
||||||
:key="note.id"
|
|
||||||
class="border rounded-lg p-4 space-y-2 hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
<!-- Note Header -->
|
|
||||||
<div class="flex items-start justify-between gap-2">
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
|
||||||
<span class="text-sm font-medium">{{ note.author_name }}</span>
|
|
||||||
<Badge variant="outline" class="text-xs">
|
|
||||||
{{ formatTaskType(note.task_type) }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-muted-foreground mt-1">
|
|
||||||
{{ note.task_name }} • {{ formatDate(note.created_at) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Note Content -->
|
|
||||||
<p class="text-sm whitespace-pre-wrap">{{ note.content }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { MessageSquarePlus } from 'lucide-vue-next'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
assetId: number
|
|
||||||
notes: any[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
notesUpdated: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
function formatTaskType(taskType: string): string {
|
|
||||||
return taskType.split('_').map(word =>
|
|
||||||
word.charAt(0).toUpperCase() + word.slice(1)
|
|
||||||
).join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateString: string): string {
|
|
||||||
const date = new Date(dateString)
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
+19
-5
@@ -122,7 +122,15 @@
|
|||||||
<span class="font-semibold text-sm">
|
<span class="font-semibold text-sm">
|
||||||
{{ entry.submission.user_first_name }} {{ entry.submission.user_last_name }}
|
{{ entry.submission.user_first_name }} {{ entry.submission.user_last_name }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs text-muted-foreground ml-auto">{{ formatAbsolute(entry.submission.submitted_at) }}</span>
|
<span class="text-xs text-muted-foreground ml-auto flex items-center gap-1">
|
||||||
|
{{ formatDateOnly(entry.submission.submitted_at) }}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<Info class="h-3 w-3 cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{{ formatDateTimeFull(entry.submission.submitted_at) }}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm whitespace-pre-wrap mt-0.5">{{ entry.submission.notes }}</div>
|
<div class="text-sm whitespace-pre-wrap mt-0.5">{{ entry.submission.notes }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,7 +204,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, nextTick, watch } from 'vue'
|
import { ref, computed, nextTick, watch } from 'vue'
|
||||||
import { ListFilter, Megaphone, MessageSquarePlus, Send, X } from 'lucide-vue-next'
|
import { Info, ListFilter, Megaphone, MessageSquarePlus, Send, X } from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
@@ -211,13 +219,13 @@ import { usePermission } from '@/composables/usePermission'
|
|||||||
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||||
import { useToast } from '@/components/ui/toast/use-toast'
|
import { useToast } from '@/components/ui/toast/use-toast'
|
||||||
|
|
||||||
interface ShotNoteTask {
|
interface EntityNoteTask {
|
||||||
id: number
|
id: number
|
||||||
task_type: string
|
task_type: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
tasks: ShotNoteTask[]
|
tasks: EntityNoteTask[]
|
||||||
notes: ProductionNote[]
|
notes: ProductionNote[]
|
||||||
submissions: Submission[]
|
submissions: Submission[]
|
||||||
}>()
|
}>()
|
||||||
@@ -268,7 +276,13 @@ function formatTaskType(taskType: string): string {
|
|||||||
return taskType.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
return taskType.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAbsolute(dateString: string): string {
|
function formatDateOnly(dateString: string): string {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTimeFull(dateString: string): string {
|
||||||
const date = new Date(dateString)
|
const date = new Date(dateString)
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||||
@@ -214,7 +214,7 @@
|
|||||||
<div v-if="isLoadingNotes" class="text-center py-8 text-sm text-muted-foreground">
|
<div v-if="isLoadingNotes" class="text-center py-8 text-sm text-muted-foreground">
|
||||||
Loading notes...
|
Loading notes...
|
||||||
</div>
|
</div>
|
||||||
<ShotNotes
|
<EntityNotes
|
||||||
v-else
|
v-else
|
||||||
:key="shotId"
|
:key="shotId"
|
||||||
:tasks="tasks"
|
:tasks="tasks"
|
||||||
@@ -335,7 +335,7 @@ import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
|
|||||||
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
|
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
|
||||||
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
|
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
|
||||||
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
||||||
import ShotNotes from './ShotNotes.vue'
|
import EntityNotes from '@/components/shared/EntityNotes.vue'
|
||||||
|
|
||||||
import { shotService, type Shot, type TaskStatusInfo } from '@/services/shot'
|
import { shotService, type Shot, type TaskStatusInfo } from '@/services/shot'
|
||||||
import { taskService, type ProductionNote, type Submission } from '@/services/task'
|
import { taskService, type ProductionNote, type Submission } from '@/services/task'
|
||||||
|
|||||||
@@ -30,9 +30,15 @@
|
|||||||
{{ note.user_first_name }} {{ note.user_last_name }}
|
{{ note.user_first_name }} {{ note.user_last_name }}
|
||||||
</span>
|
</span>
|
||||||
<Badge v-if="note.note_type === 'client' && !hideClientBadge" class="text-xs bg-orange-500 text-white border-transparent hover:bg-orange-500">Client</Badge>
|
<Badge v-if="note.note_type === 'client' && !hideClientBadge" class="text-xs bg-orange-500 text-white border-transparent hover:bg-orange-500">Client</Badge>
|
||||||
<span class="text-xs text-muted-foreground ml-auto">
|
<span class="text-xs text-muted-foreground ml-auto flex items-center gap-1">
|
||||||
{{ formatDateTime(note.created_at) }}
|
{{ formatDateTime(note.created_at) }}
|
||||||
<template v-if="note.updated_at !== note.created_at"> (edited)</template>
|
<template v-if="note.updated_at !== note.created_at"> (edited)</template>
|
||||||
|
<Tooltip v-if="dateFormat === 'absolute'">
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<Info class="h-3 w-3 cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{{ formatDateTimeFull(note.created_at) }}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -122,11 +128,12 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { Reply, Pencil, Trash2 } from 'lucide-vue-next'
|
import { Reply, Pencil, Trash2, Info } from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -178,12 +185,18 @@ function getInitials(firstName: string, lastName: string): string {
|
|||||||
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase()
|
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDateTimeFull(dateString: string): string {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateTime(dateString: string): string {
|
function formatDateTime(dateString: string): string {
|
||||||
const date = new Date(dateString)
|
const date = new Date(dateString)
|
||||||
|
|
||||||
if (props.dateFormat === 'absolute') {
|
if (props.dateFormat === 'absolute') {
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|||||||
Reference in New Issue
Block a user