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 })
+372
View File
@@ -0,0 +1,372 @@
<template>
<div class="flex flex-col h-full">
<!-- Filter / Sort Toolbar -->
<div class="flex-shrink-0 flex items-center justify-between gap-2 border-b px-3 py-2">
<div class="flex items-center gap-1.5">
<Popover v-if="tasks.length > 0">
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<ListFilter class="h-3.5 w-3.5 mr-1.5" />
Tasks
<Badge v-if="taskFilters.length > 0" variant="secondary" class="ml-1.5 rounded-sm px-1 font-normal">
{{ taskFilters.length }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-52 p-0" align="start">
<Command>
<CommandList>
<CommandGroup>
<CheckableCommandItem
value="all"
:model-value="taskFilters.length === 0"
@update:model-value="taskFilters = []"
>
All Tasks
</CheckableCommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup>
<CheckableCommandItem
v-for="task in tasks"
:key="task.id"
:value="String(task.id)"
:model-value="taskFilters.includes(task.id)"
@update:model-value="toggleTaskFilter(task.id)"
>
{{ formatTaskType(task.task_type) }}
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Tooltip>
<TooltipTrigger as-child>
<Button
:variant="showSubmissionNotes ? 'secondary' : 'outline'"
size="icon-sm"
@click="showSubmissionNotes = !showSubmissionNotes"
>
<Send class="h-3.5 w-3.5" />
<span class="sr-only">Submission notes</span>
</Button>
</TooltipTrigger>
<TooltipContent>Submission notes</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button
:variant="showClientOnly ? 'secondary' : 'outline'"
size="icon-sm"
@click="showClientOnly = !showClientOnly"
>
<Megaphone class="h-3.5 w-3.5" />
<span class="sr-only">Client notes only</span>
</Button>
</TooltipTrigger>
<TooltipContent>Client notes only</TooltipContent>
</Tooltip>
</div>
<Select v-model="sortOrder">
<SelectTrigger class="h-8 w-[130px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="newest">Newest first</SelectItem>
<SelectItem value="oldest">Oldest first</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Notes History (Top) -->
<div class="flex-1 overflow-y-auto p-4 space-y-4">
<div v-if="combinedEntries.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. Start the conversation below.</p>
</div>
<template v-for="entry in combinedEntries" :key="entry.id">
<!-- Production Note -->
<div v-if="entry.kind === 'note'" class="space-y-1">
<div class="flex items-center gap-1.5">
<Badge variant="outline" class="text-xs">{{ formatTaskType(taskTypeFor(entry.note.task_id)) }}</Badge>
<Badge v-if="entry.note.note_type === 'client'" class="text-xs bg-orange-500 text-white border-transparent hover:bg-orange-500">Client</Badge>
</div>
<NoteItem
:note="entry.note"
:task-id="entry.note.task_id"
date-format="absolute"
hide-client-badge
@note-updated="emit('notesUpdated')"
@reply="handleReply"
/>
</div>
<!-- Submission Note (read-only) -->
<div v-else class="space-y-1">
<div class="flex items-center gap-1.5">
<Badge variant="outline" class="text-xs">{{ formatTaskType(taskTypeFor(entry.submission.task_id)) }}</Badge>
<Badge variant="secondary" class="text-xs">Submission v{{ entry.submission.version_number }}</Badge>
</div>
<div class="flex gap-3">
<Avatar class="h-8 w-8 flex-shrink-0">
<AvatarImage :src="getAvatarUrl(undefined, entry.submission.user_first_name, entry.submission.user_last_name)" />
<AvatarFallback>{{ getInitials(entry.submission.user_first_name, entry.submission.user_last_name) }}</AvatarFallback>
</Avatar>
<div class="flex-1 min-w-0 rounded-2xl border bg-muted/50 px-3 py-2">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-semibold text-sm">
{{ entry.submission.user_first_name }} {{ entry.submission.user_last_name }}
</span>
<span class="text-xs text-muted-foreground ml-auto">{{ formatAbsolute(entry.submission.submitted_at) }}</span>
</div>
<div class="text-sm whitespace-pre-wrap mt-0.5">{{ entry.submission.notes }}</div>
</div>
</div>
</div>
</template>
</div>
<!-- Note Input (Bottom) -->
<div v-if="canCreateNote && tasks.length > 0" class="flex-shrink-0 border-t bg-background p-2 space-y-2">
<div v-if="replyToNote" class="flex items-center justify-between gap-2 rounded-md bg-muted px-2 py-1.5 text-xs">
<span class="truncate">
Replying to <strong>{{ replyToNote.user_first_name }} {{ replyToNote.user_last_name }}</strong>
<span class="text-muted-foreground"> {{ replyToNote.content }}</span>
</span>
<button type="button" class="flex-shrink-0 text-muted-foreground hover:text-foreground" @click="cancelReply">
<X class="h-3 w-3" />
</button>
</div>
<Select v-model="targetTaskId" :disabled="!!replyToNoteId">
<SelectTrigger class="h-8 text-xs">
<SelectValue placeholder="Select task..." />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="task in tasks" :key="task.id" :value="task.id">
{{ formatTaskType(task.task_type) }}
</SelectItem>
</SelectContent>
</Select>
<div ref="composerRef">
<Textarea
v-model="newNoteContent"
placeholder="Add a note..."
rows="2"
class="resize-none text-sm"
/>
</div>
<div class="flex items-center justify-between">
<div class="flex gap-1">
<Button
type="button"
size="sm"
:variant="newNoteType === 'internal' ? 'secondary' : 'ghost'"
@click="newNoteType = 'internal'"
>
Internal
</Button>
<Button
type="button"
size="sm"
:variant="newNoteType === 'client' ? 'secondary' : 'ghost'"
@click="newNoteType = 'client'"
>
Client
</Button>
</div>
<Button
size="sm"
:disabled="!newNoteContent.trim() || !targetTaskId || submitting"
@click="handleAddNote"
>
<MessageSquarePlus class="h-4 w-4 mr-2" />
Add Note
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, watch } from 'vue'
import { ListFilter, Megaphone, MessageSquarePlus, Send, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Command, CommandGroup, CommandList, CommandSeparator, CheckableCommandItem } from '@/components/ui/command'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import NoteItem from '@/components/task/NoteItem.vue'
import { taskService, type ProductionNote, type Submission, type NoteType } from '@/services/task'
import { usePermission } from '@/composables/usePermission'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
import { useToast } from '@/components/ui/toast/use-toast'
interface ShotNoteTask {
id: number
task_type: string
}
const props = defineProps<{
tasks: ShotNoteTask[]
notes: ProductionNote[]
submissions: Submission[]
}>()
const emit = defineEmits<{
notesUpdated: []
}>()
const { toast } = useToast()
const { isCoordinatorOrAdmin } = usePermission()
const { getAvatarUrl } = useAvatarUrl()
const canCreateNote = computed(() => isCoordinatorOrAdmin.value)
const taskFilters = ref<number[]>([])
const sortOrder = ref<'newest' | 'oldest'>('newest')
const showSubmissionNotes = ref(true)
const showClientOnly = ref(false)
const newNoteContent = ref('')
const newNoteType = ref<NoteType>('internal')
const targetTaskId = ref<number | null>(null)
const submitting = ref(false)
const replyToNoteId = ref<number | null>(null)
const composerRef = ref<HTMLElement | null>(null)
watch(() => props.tasks, (tasks) => {
if (!targetTaskId.value || !tasks.some(t => t.id === targetTaskId.value)) {
targetTaskId.value = tasks[0]?.id ?? null
}
}, { immediate: true })
function toggleTaskFilter(taskId: number) {
const index = taskFilters.value.indexOf(taskId)
if (index > -1) taskFilters.value.splice(index, 1)
else taskFilters.value.push(taskId)
}
function isFilterActive(taskId: number): boolean {
return taskFilters.value.length === 0 || taskFilters.value.includes(taskId)
}
function taskTypeFor(taskId: number): string {
return props.tasks.find(t => t.id === taskId)?.task_type || ''
}
function formatTaskType(taskType: string): string {
return taskType.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
function formatAbsolute(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 getInitials(firstName: string, lastName: string): string {
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase()
}
interface NoteEntry {
kind: 'note'
id: string
date: string
note: ProductionNote
}
interface SubmissionEntry {
kind: 'submission'
id: string
date: string
submission: Submission
}
const combinedEntries = computed<(NoteEntry | SubmissionEntry)[]>(() => {
const noteEntries: NoteEntry[] = props.notes
.filter(n => isFilterActive(n.task_id) && (!showClientOnly.value || n.note_type === 'client'))
.map(n => ({ kind: 'note', id: `note-${n.id}`, date: n.created_at, note: n }))
// Submissions have no internal/client distinction, so they don't qualify under "client notes only".
const submissionEntries: SubmissionEntry[] = showSubmissionNotes.value && !showClientOnly.value
? props.submissions
.filter(s => !!s.notes?.trim() && isFilterActive(s.task_id))
.map(s => ({ kind: 'submission', id: `submission-${s.id}`, date: s.submitted_at, submission: s }))
: []
const direction = sortOrder.value === 'newest' ? -1 : 1
return [...noteEntries, ...submissionEntries].sort(
(a, b) => direction * (new Date(a.date).getTime() - new Date(b.date).getTime())
)
})
function findNote(notes: ProductionNote[], id: number): ProductionNote | undefined {
for (const note of notes) {
if (note.id === id) return note
if (note.child_notes) {
const found = findNote(note.child_notes, id)
if (found) return found
}
}
return undefined
}
const replyToNote = computed(() => {
if (replyToNoteId.value === null) return undefined
return findNote(props.notes, replyToNoteId.value)
})
function handleReply(noteId: number) {
const note = findNote(props.notes, noteId)
if (!note) return
replyToNoteId.value = noteId
targetTaskId.value = note.task_id
nextTick(() => {
composerRef.value?.querySelector('textarea')?.focus()
})
}
function cancelReply() {
replyToNoteId.value = null
}
async function handleAddNote() {
if (!newNoteContent.value.trim() || !targetTaskId.value) return
submitting.value = true
try {
await taskService.createTaskNote(
targetTaskId.value,
newNoteContent.value,
replyToNoteId.value || undefined,
newNoteType.value
)
newNoteContent.value = ''
newNoteType.value = 'internal'
replyToNoteId.value = null
emit('notesUpdated')
toast({
title: 'Success',
description: 'Note added successfully'
})
} catch (error: any) {
console.error('Error adding note:', error)
toast({
title: 'Error',
description: error.response?.data?.detail || 'Failed to add note',
variant: 'destructive'
})
} finally {
submitting.value = false
}
}
</script>
+17 -7
View File
@@ -29,7 +29,7 @@
<span class="font-semibold text-sm">
{{ note.user_first_name }} {{ note.user_last_name }}
</span>
<Badge v-if="note.note_type === 'client'" variant="outline" class="text-xs">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">
{{ formatDateTime(note.created_at) }}
<template v-if="note.updated_at !== note.created_at"> (edited)</template>
@@ -68,20 +68,20 @@
<Button
v-if="canEdit"
variant="ghost"
size="sm"
size="icon-sm"
title="Edit"
@click="startEdit"
>
<Pencil class="h-3 w-3 mr-1" />
Edit
<Pencil class="h-3.5 w-3.5" />
</Button>
<Button
v-if="canDelete"
variant="ghost"
size="sm"
size="icon-sm"
title="Delete"
@click="handleDelete"
>
<Trash2 class="h-3 w-3 mr-1" />
Delete
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
@@ -92,6 +92,8 @@
:key="childNote.id"
:note="childNote"
:task-id="taskId"
:date-format="dateFormat"
:hide-client-badge="hideClientBadge"
@note-updated="emit('noteUpdated')"
@reply="emit('reply', $event)"
/>
@@ -143,6 +145,8 @@ import { useToast } from '@/components/ui/toast/use-toast'
const props = defineProps<{
note: ProductionNote
taskId: number
dateFormat?: 'relative' | 'absolute'
hideClientBadge?: boolean
}>()
const emit = defineEmits<{
@@ -176,6 +180,12 @@ function getInitials(firstName: string, lastName: string): string {
function formatDateTime(dateString: string): string {
const date = new Date(dateString)
if (props.dateFormat === 'absolute') {
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())}`
}
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)