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