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="asset" class="flex-1 overflow-y-auto">
|
<div v-else-if="asset" class="flex-1 overflow-y-auto">
|
||||||
<DetailPanelHeader :title="asset.name" :deleted-at="asset.deleted_at" @close="$emit('close')">
|
<DetailPanelHeader :title="asset.name" :deleted-at="asset.deleted_at" @close="$emit('close')">
|
||||||
<template #badges>
|
<template #badges>
|
||||||
<Badge :variant="getStatusVariant(asset.status)" class="text-xs flex-shrink-0">
|
|
||||||
<div
|
|
||||||
class="w-2 h-2 rounded-full mr-1"
|
|
||||||
:class="getStatusColor(asset.status)"
|
|
||||||
></div>
|
|
||||||
{{ formatStatus(asset.status) }}
|
|
||||||
</Badge>
|
|
||||||
<!-- 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">
|
||||||
Deleted {{ formatDeletedDate(asset.deleted_at) }}
|
Deleted {{ formatDeletedDate(asset.deleted_at) }}
|
||||||
@@ -30,16 +23,26 @@
|
|||||||
<!-- Tabbed Content -->
|
<!-- Tabbed Content -->
|
||||||
<Tabs default-value="infos" class="flex-1 flex flex-col">
|
<Tabs default-value="infos" class="flex-1 flex flex-col">
|
||||||
<TabsList class="mx-0 mt-0 grid w-full grid-cols-3 rounded-none border-b">
|
<TabsList class="mx-0 mt-0 grid w-full grid-cols-3 rounded-none border-b">
|
||||||
<TabsTrigger value="infos">Infos</TabsTrigger>
|
<TabsTrigger value="infos" title="Infos">
|
||||||
<TabsTrigger value="notes">
|
<Info class="h-4 w-4" />
|
||||||
Notes
|
<span class="sr-only">Infos</span>
|
||||||
<Badge v-if="notes.length > 0" variant="secondary" class="ml-2">
|
|
||||||
{{ notes.length }}
|
|
||||||
</Badge>
|
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="references">
|
<TabsTrigger value="notes" title="Notes">
|
||||||
References
|
<span class="relative inline-flex">
|
||||||
<Badge v-if="references.length > 0" variant="secondary" class="ml-2">
|
<MessageSquare class="h-4 w-4" />
|
||||||
|
<span
|
||||||
|
v-if="notes.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"
|
||||||
|
>
|
||||||
|
{{ notes.length > 99 ? '99+' : notes.length }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="sr-only">Notes</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="references" title="References">
|
||||||
|
<Image class="h-4 w-4" />
|
||||||
|
<span class="sr-only">References</span>
|
||||||
|
<Badge v-if="references.length > 0" variant="secondary" class="ml-1 h-4 px-1 text-[10px]">
|
||||||
{{ references.length }}
|
{{ references.length }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -267,7 +270,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
Plus, MessageSquarePlus, Paperclip, Send
|
Plus, MessageSquarePlus, Paperclip, Send, Info, MessageSquare, Image
|
||||||
} from 'lucide-vue-next'
|
} 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'
|
||||||
|
|||||||
@@ -170,6 +170,7 @@
|
|||||||
:key="selectedTaskId"
|
:key="selectedTaskId"
|
||||||
:task-id="selectedTaskId"
|
:task-id="selectedTaskId"
|
||||||
:initial-tab="selectedTaskTab"
|
:initial-tab="selectedTaskTab"
|
||||||
|
:initial-reply-note-id="selectedTaskReplyNoteId"
|
||||||
@close="selectedTaskId = null"
|
@close="selectedTaskId = null"
|
||||||
@task-updated="loadShots"
|
@task-updated="loadShots"
|
||||||
/>
|
/>
|
||||||
@@ -848,16 +849,19 @@ const clearSearch = () => {
|
|||||||
|
|
||||||
const selectedTaskId = ref<number | null>(null)
|
const selectedTaskId = ref<number | null>(null)
|
||||||
const selectedTaskTab = ref<string>('infos')
|
const selectedTaskTab = ref<string>('infos')
|
||||||
|
const selectedTaskReplyNoteId = ref<number | undefined>(undefined)
|
||||||
|
|
||||||
const handleSelectTask = (task: { id: number }, tab?: string) => {
|
const handleSelectTask = (task: { id: number }, tab?: string, noteId?: number) => {
|
||||||
selectedTaskId.value = task.id
|
selectedTaskId.value = task.id
|
||||||
selectedTaskTab.value = tab || 'infos'
|
selectedTaskTab.value = tab || 'infos'
|
||||||
|
selectedTaskReplyNoteId.value = noteId
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset the task sub-panel whenever the shot selection changes (including close)
|
// Reset the task sub-panel whenever the shot selection changes (including close)
|
||||||
watch(selectedShot, () => {
|
watch(selectedShot, () => {
|
||||||
selectedTaskId.value = null
|
selectedTaskId.value = null
|
||||||
selectedTaskTab.value = 'infos'
|
selectedTaskTab.value = 'infos'
|
||||||
|
selectedTaskReplyNoteId.value = undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatStatus = (status: ShotStatus) => {
|
const formatStatus = (status: ShotStatus) => {
|
||||||
|
|||||||
@@ -13,13 +13,6 @@
|
|||||||
<div v-else-if="shot" class="flex-1 overflow-y-auto">
|
<div v-else-if="shot" class="flex-1 overflow-y-auto">
|
||||||
<DetailPanelHeader :title="shot.name" :deleted-at="shot.deleted_at" @close="$emit('close')">
|
<DetailPanelHeader :title="shot.name" :deleted-at="shot.deleted_at" @close="$emit('close')">
|
||||||
<template #badges>
|
<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 -->
|
<!-- Deletion status indicator for admins -->
|
||||||
<Badge v-if="isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
|
<Badge v-if="isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
|
||||||
Deleted {{ formatDeletedDate(shot.deleted_at) }}
|
Deleted {{ formatDeletedDate(shot.deleted_at) }}
|
||||||
@@ -30,11 +23,34 @@
|
|||||||
<!-- Tabbed Content -->
|
<!-- Tabbed Content -->
|
||||||
<Tabs default-value="infos" class="flex-1 flex flex-col">
|
<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">
|
<TabsList class="mx-0 mt-0 grid w-full grid-cols-5 rounded-none border-b">
|
||||||
<TabsTrigger value="infos">Infos</TabsTrigger>
|
<TabsTrigger value="infos" title="Infos">
|
||||||
<TabsTrigger value="notes">Notes</TabsTrigger>
|
<Info class="h-4 w-4" />
|
||||||
<TabsTrigger value="assets">Assets</TabsTrigger>
|
<span class="sr-only">Infos</span>
|
||||||
<TabsTrigger value="references">References</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="design">Design</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>
|
</TabsList>
|
||||||
|
|
||||||
<!-- Infos Tab -->
|
<!-- Infos Tab -->
|
||||||
@@ -229,7 +245,35 @@
|
|||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</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" />
|
<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-sm text-muted-foreground">No notes yet</p>
|
||||||
<p class="text-xs text-muted-foreground mt-1">Add notes to track important information</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">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
ListTodo, Plus, MessageSquare, Package, Image, Edit, Send
|
ListTodo, Plus, MessageSquare, Package, Image, Edit, Send, Info
|
||||||
} from 'lucide-vue-next'
|
} 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 { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
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 TaskStatusBadge from '@/components/task/TaskStatusBadge.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 { shotService, type Shot, type TaskStatusInfo } from '@/services/shot'
|
||||||
import { taskService } from '@/services/task'
|
import { taskService, type ProductionNote } from '@/services/task'
|
||||||
import { projectService, type ProjectMember } from '@/services/project'
|
import { projectService, type ProjectMember } from '@/services/project'
|
||||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||||
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
||||||
@@ -375,7 +421,7 @@ interface Props {
|
|||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'edit', shot: Shot): void
|
(e: 'edit', shot: Shot): void
|
||||||
(e: 'delete', 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: 'link-asset'): void
|
||||||
(e: 'edit-design'): void
|
(e: 'edit-design'): void
|
||||||
(e: 'close'): void
|
(e: 'close'): void
|
||||||
@@ -395,6 +441,9 @@ const isLoading = ref(false)
|
|||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const isCreatingTask = ref(false)
|
const isCreatingTask = ref(false)
|
||||||
const projectMembers = ref<ProjectMember[]>([])
|
const projectMembers = ref<ProjectMember[]>([])
|
||||||
|
const shotNotes = ref<ProductionNote[]>([])
|
||||||
|
const isLoadingNotes = ref(false)
|
||||||
|
const noteTaskFilter = ref<number | 'all'>('all')
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
const frameCount = computed(() => {
|
const frameCount = computed(() => {
|
||||||
@@ -436,6 +485,17 @@ const canCreateTask = computed(() => isCoordinatorOrAdmin.value)
|
|||||||
|
|
||||||
const canCreateNote = 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 canLinkAssets = computed(() => isCoordinatorOrAdmin.value)
|
||||||
|
|
||||||
const canUploadReferences = computed(() => {
|
const canUploadReferences = computed(() => {
|
||||||
@@ -460,6 +520,7 @@ const loadShotDetails = async () => {
|
|||||||
loadProjectMembers()
|
loadProjectMembers()
|
||||||
])
|
])
|
||||||
loadTasks() // No longer async - uses embedded data
|
loadTasks() // No longer async - uses embedded data
|
||||||
|
loadShotNotes() // Fire-and-forget - own loading state, doesn't block the panel
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to load shot details'
|
error.value = err instanceof Error ? err.message : 'Failed to load shot details'
|
||||||
console.error('Failed to load shot details:', err)
|
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 () => {
|
const loadProjectMembers = async () => {
|
||||||
try {
|
try {
|
||||||
projectMembers.value = await projectService.getProjectMembers(props.projectId)
|
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) => {
|
const formatTaskType = (taskType: string) => {
|
||||||
return taskType.split('_').map(word =>
|
return taskType.split('_').map(word =>
|
||||||
word.charAt(0).toUpperCase() + word.slice(1)
|
word.charAt(0).toUpperCase() + word.slice(1)
|
||||||
@@ -537,40 +622,6 @@ const getTaskAssigneeInitials = (task: Task) => {
|
|||||||
return (first + last).toUpperCase()
|
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 formatDate = (dateString: string) => {
|
||||||
const date = new Date(dateString)
|
const date = new Date(dateString)
|
||||||
return date.toLocaleDateString('en-US', {
|
return date.toLocaleDateString('en-US', {
|
||||||
@@ -596,6 +647,7 @@ const formatDeletedDate = (deletedAt: string) => {
|
|||||||
// Watchers
|
// Watchers
|
||||||
watch(() => props.shotId, (newShotId) => {
|
watch(() => props.shotId, (newShotId) => {
|
||||||
if (newShotId) {
|
if (newShotId) {
|
||||||
|
noteTaskFilter.value = 'all'
|
||||||
loadShotDetails()
|
loadShotDetails()
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|||||||
@@ -19,40 +19,44 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="flex-1 space-y-1">
|
<div class="flex-1 min-w-0 space-y-1">
|
||||||
<div class="flex items-center gap-2">
|
<!-- Message Card -->
|
||||||
<span class="font-semibold text-sm">
|
<div
|
||||||
{{ note.user_first_name }} {{ note.user_last_name }}
|
class="rounded-2xl border px-3 py-2"
|
||||||
</span>
|
:class="isOwnNote ? 'bg-primary/10 border-primary/20' : 'bg-muted/50 border-border'"
|
||||||
<span class="text-xs text-muted-foreground">
|
>
|
||||||
{{ formatDateTime(note.created_at) }}
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
</span>
|
<span class="font-semibold text-sm">
|
||||||
<span v-if="note.updated_at !== note.created_at" class="text-xs text-muted-foreground">
|
{{ note.user_first_name }} {{ note.user_last_name }}
|
||||||
(edited)
|
</span>
|
||||||
</span>
|
<Badge v-if="note.note_type === 'client'" variant="outline" class="text-xs">Client</Badge>
|
||||||
<Badge v-if="note.note_type === 'client'" variant="outline" class="text-xs">Client</Badge>
|
<span class="text-xs text-muted-foreground ml-auto">
|
||||||
</div>
|
{{ formatDateTime(note.created_at) }}
|
||||||
|
<template v-if="note.updated_at !== note.created_at"> (edited)</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Note Content -->
|
<!-- Note Content -->
|
||||||
<div v-if="!editing" class="text-sm whitespace-pre-wrap">
|
<div v-if="!editing" class="text-sm whitespace-pre-wrap mt-0.5">
|
||||||
{{ note.content }}
|
{{ note.content }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Edit Form -->
|
<!-- Edit Form -->
|
||||||
<div v-else class="space-y-2">
|
<div v-else class="space-y-2 mt-1">
|
||||||
<Textarea
|
<Textarea
|
||||||
v-model="editContent"
|
v-model="editContent"
|
||||||
rows="3"
|
rows="3"
|
||||||
class="resize-none"
|
class="resize-none bg-background"
|
||||||
/>
|
/>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button size="sm" @click="handleSave">Save</Button>
|
<Button size="sm" @click="handleSave">Save</Button>
|
||||||
<Button size="sm" variant="outline" @click="editing = false">Cancel</Button>
|
<Button size="sm" variant="outline" @click="editing = false">Cancel</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div v-if="!editing" class="flex gap-2">
|
<div v-if="!editing" class="flex gap-2 px-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -82,7 +86,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Child Notes (Threaded) -->
|
<!-- Child Notes (Threaded) -->
|
||||||
<div v-if="note.child_notes && note.child_notes.length > 0" class="mt-4 space-y-4 pl-4 border-l-2">
|
<div v-if="note.child_notes && note.child_notes.length > 0" class="mt-3 space-y-3 pl-4 border-l-2">
|
||||||
<NoteItem
|
<NoteItem
|
||||||
v-for="childNote in note.child_notes"
|
v-for="childNote in note.child_notes"
|
||||||
:key="childNote.id"
|
:key="childNote.id"
|
||||||
|
|||||||
@@ -12,31 +12,39 @@
|
|||||||
<!-- Task Details -->
|
<!-- Task Details -->
|
||||||
<div v-else-if="task" class="flex-1 flex flex-col min-h-0">
|
<div v-else-if="task" class="flex-1 flex flex-col min-h-0">
|
||||||
<DetailPanelHeader class="flex-shrink-0" :title="task.name" @close="emit('close')">
|
<DetailPanelHeader class="flex-shrink-0" :title="task.name" @close="emit('close')">
|
||||||
<template #badges>
|
|
||||||
<TaskStatusBadge :status="task.status" class="flex-shrink-0" />
|
|
||||||
</template>
|
|
||||||
</DetailPanelHeader>
|
</DetailPanelHeader>
|
||||||
|
|
||||||
<!-- Tabbed Content -->
|
<!-- Tabbed Content -->
|
||||||
<Tabs :default-value="initialTab || 'infos'" class="flex-1 flex flex-col min-h-0">
|
<Tabs :default-value="initialTab || 'infos'" class="flex-1 flex flex-col min-h-0">
|
||||||
<!-- Tabs List (Fixed) -->
|
<!-- Tabs List (Fixed) -->
|
||||||
<TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-4 rounded-none border-b">
|
<TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-4 rounded-none border-b">
|
||||||
<TabsTrigger value="infos">Infos</TabsTrigger>
|
<TabsTrigger value="infos" title="Infos">
|
||||||
<TabsTrigger value="notes">
|
<Info class="h-4 w-4" />
|
||||||
Notes
|
<span class="sr-only">Infos</span>
|
||||||
<Badge v-if="notes.length > 0" variant="secondary" class="ml-2">
|
|
||||||
{{ notes.length }}
|
|
||||||
</Badge>
|
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="attachments">
|
<TabsTrigger value="notes" title="Notes">
|
||||||
Attachments
|
<span class="relative inline-flex">
|
||||||
<Badge v-if="attachments.length > 0" variant="secondary" class="ml-2">
|
<MessageSquare class="h-4 w-4" />
|
||||||
|
<span
|
||||||
|
v-if="notes.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"
|
||||||
|
>
|
||||||
|
{{ notes.length > 99 ? '99+' : notes.length }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="sr-only">Notes</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="attachments" title="Attachments">
|
||||||
|
<Paperclip class="h-4 w-4" />
|
||||||
|
<span class="sr-only">Attachments</span>
|
||||||
|
<Badge v-if="attachments.length > 0" variant="secondary" class="ml-1 h-4 px-1 text-[10px]">
|
||||||
{{ attachments.length }}
|
{{ attachments.length }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="submissions">
|
<TabsTrigger value="submissions" title="Submissions">
|
||||||
Submissions
|
<Upload class="h-4 w-4" />
|
||||||
<Badge v-if="submissions.length > 0" variant="secondary" class="ml-2">
|
<span class="sr-only">Submissions</span>
|
||||||
|
<Badge v-if="submissions.length > 0" variant="secondary" class="ml-1 h-4 px-1 text-[10px]">
|
||||||
{{ submissions.length }}
|
{{ submissions.length }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -178,7 +186,12 @@
|
|||||||
|
|
||||||
<!-- Notes Tab -->
|
<!-- Notes Tab -->
|
||||||
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
|
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
|
||||||
<TaskNotes :task-id="taskId" :notes="notes" @notes-updated="loadNotes" />
|
<TaskNotes
|
||||||
|
:task-id="taskId"
|
||||||
|
:notes="notes"
|
||||||
|
:initial-reply-note-id="initialReplyNoteId"
|
||||||
|
@notes-updated="loadNotes"
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- Attachments Tab -->
|
<!-- Attachments Tab -->
|
||||||
@@ -262,7 +275,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch, computed } from 'vue'
|
import { ref, onMounted, watch, computed } from 'vue'
|
||||||
import { Play, Upload, UserPlus, Calendar, User } from 'lucide-vue-next'
|
import { Play, Upload, UserPlus, Calendar, User, Info, MessageSquare, Paperclip } 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 { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
@@ -299,7 +312,6 @@ import {
|
|||||||
CommandItem,
|
CommandItem,
|
||||||
CommandList,
|
CommandList,
|
||||||
} from '@/components/ui/command'
|
} from '@/components/ui/command'
|
||||||
import TaskStatusBadge from './TaskStatusBadge.vue'
|
|
||||||
import TaskNotes from './TaskNotes.vue'
|
import TaskNotes from './TaskNotes.vue'
|
||||||
import TaskAttachments from './TaskAttachments.vue'
|
import TaskAttachments from './TaskAttachments.vue'
|
||||||
import TaskSubmissions from './TaskSubmissions.vue'
|
import TaskSubmissions from './TaskSubmissions.vue'
|
||||||
@@ -311,6 +323,7 @@ import { useToast } from '@/components/ui/toast/use-toast'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
taskId: number
|
taskId: number
|
||||||
initialTab?: string
|
initialTab?: string
|
||||||
|
initialReplyNoteId?: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@@ -20,12 +20,23 @@
|
|||||||
<!-- Note Input (Bottom) -->
|
<!-- Note Input (Bottom) -->
|
||||||
<div class="flex-shrink-0 border-t bg-background p-2">
|
<div class="flex-shrink-0 border-t bg-background p-2">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Textarea
|
<div v-if="replyToNote" class="flex items-center justify-between gap-2 rounded-md bg-muted px-2 py-1.5 text-xs">
|
||||||
v-model="newNoteContent"
|
<span class="truncate">
|
||||||
placeholder="Add a note..."
|
Replying to <strong>{{ replyToNote.user_first_name }} {{ replyToNote.user_last_name }}</strong>
|
||||||
rows="2"
|
<span class="text-muted-foreground">— {{ replyToNote.content }}</span>
|
||||||
class="resize-none text-sm"
|
</span>
|
||||||
/>
|
<button type="button" class="flex-shrink-0 text-muted-foreground hover:text-foreground" @click="cancelReply">
|
||||||
|
<X class="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<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 items-center justify-between">
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
@@ -60,8 +71,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, computed, nextTick, onMounted } from 'vue'
|
||||||
import { MessageSquarePlus } from 'lucide-vue-next'
|
import { MessageSquarePlus, X } from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import NoteItem from './NoteItem.vue'
|
import NoteItem from './NoteItem.vue'
|
||||||
@@ -71,6 +82,7 @@ import { useToast } from '@/components/ui/toast/use-toast'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
taskId: number
|
taskId: number
|
||||||
notes: ProductionNote[]
|
notes: ProductionNote[]
|
||||||
|
initialReplyNoteId?: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -83,6 +95,23 @@ const newNoteContent = ref('')
|
|||||||
const newNoteType = ref<NoteType>('internal')
|
const newNoteType = ref<NoteType>('internal')
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const replyToNoteId = ref<number | null>(null)
|
const replyToNoteId = ref<number | null>(null)
|
||||||
|
const composerRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
async function handleAddNote() {
|
async function handleAddNote() {
|
||||||
if (!newNoteContent.value.trim()) return
|
if (!newNoteContent.value.trim()) return
|
||||||
@@ -117,6 +146,18 @@ async function handleAddNote() {
|
|||||||
|
|
||||||
function handleReply(noteId: number) {
|
function handleReply(noteId: number) {
|
||||||
replyToNoteId.value = noteId
|
replyToNoteId.value = noteId
|
||||||
// Focus on textarea (you could add a ref for this)
|
nextTick(() => {
|
||||||
|
composerRef.value?.querySelector('textarea')?.focus()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancelReply() {
|
||||||
|
replyToNoteId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.initialReplyNoteId) {
|
||||||
|
handleReply(props.initialReplyNoteId)
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const forwardedProps = useForwardProps(delegatedProps)
|
|||||||
props.class,
|
props.class,
|
||||||
)"
|
)"
|
||||||
>
|
>
|
||||||
<span class="truncate">
|
<span class="inline-flex items-center justify-center">
|
||||||
<slot />
|
<slot />
|
||||||
</span>
|
</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
|||||||
@@ -1,21 +1,33 @@
|
|||||||
<template>
|
<template>
|
||||||
<textarea
|
<textarea
|
||||||
|
v-model="modelValue"
|
||||||
:class="cn(
|
:class="cn(
|
||||||
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
props.class
|
props.class
|
||||||
)"
|
)"
|
||||||
v-bind="$attrs"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useVModel } from '@vueuse/core'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: string
|
class?: string
|
||||||
|
modelValue?: string
|
||||||
|
defaultValue?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
class: ''
|
class: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
(e: 'update:modelValue', payload: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modelValue = useVModel(props, 'modelValue', emits, {
|
||||||
|
passive: true,
|
||||||
|
defaultValue: props.defaultValue,
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
Reference in New Issue
Block a user