Compare commits

...

3 Commits

Author SHA1 Message Date
indigo 762bd34f74 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.
2026-07-19 00:00:11 +08:00
indigo 4bffec642e Align detail panel top edge with the header's actual bottom edge
DetailPanelOverlay.vue (shared by the shot/asset/task detail panels and
the My Tasks page) used a hardcoded top-[113px] that left a 49px gap
above the panel. The header is h-16 (64px), confirmed via its live
getBoundingClientRect() - switched to top-16 so the panel's top edge
sits flush against it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 22:26:00 +08:00
indigo 548d1081ba Remove top project tabs in favor of the sidebar's Projects sub-menu
The sidebar already provides Overview/Shots/Assets/Tasks/Settings
navigation for the current project, making the top ProjectTabs bar
redundant. Removed it from ProjectDetailView.vue and deleted the
now-fully-unused ProjectTabs.vue component.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 21:58:30 +08:00
11 changed files with 263 additions and 304 deletions
@@ -13,13 +13,6 @@
<div v-else-if="asset" class="flex-1 overflow-y-auto">
<DetailPanelHeader :title="asset.name" :deleted-at="asset.deleted_at" @close="$emit('close')">
<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 -->
<Badge v-if="authStore.isAdmin && asset.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
Deleted {{ formatDeletedDate(asset.deleted_at) }}
@@ -30,16 +23,26 @@
<!-- Tabbed Content -->
<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">
<TabsTrigger value="infos">Infos</TabsTrigger>
<TabsTrigger value="notes">
Notes
<Badge v-if="notes.length > 0" variant="secondary" class="ml-2">
{{ notes.length }}
</Badge>
<TabsTrigger value="infos" title="Infos">
<Info class="h-4 w-4" />
<span class="sr-only">Infos</span>
</TabsTrigger>
<TabsTrigger value="references">
References
<Badge v-if="references.length > 0" variant="secondary" class="ml-2">
<TabsTrigger value="notes" title="Notes">
<span class="relative inline-flex">
<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 }}
</Badge>
</TabsTrigger>
@@ -267,7 +270,7 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
Plus, MessageSquarePlus, Paperclip, Send
Plus, MessageSquarePlus, Paperclip, Send, Info, MessageSquare, Image
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -1,160 +0,0 @@
<template>
<div class="w-full">
<div class="grid w-full grid-cols-5 h-auto bg-muted/50 p-1 rounded-md">
<button
v-for="tab in tabs"
:key="tab.id"
@click="setActiveTab(tab.id)"
:class="[
'flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 py-2 px-1 sm:px-3 min-h-[3rem] sm:min-h-[2.5rem] transition-all duration-200 text-center rounded-sm',
activeTab === tab.id
? 'bg-background shadow-sm text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
]"
>
<div class="flex items-center gap-1 sm:gap-2">
<component :is="tab.icon" class="h-4 w-4 flex-shrink-0" />
<span class="hidden sm:inline text-sm font-medium">{{ tab.label }}</span>
<span class="sm:hidden text-xs font-medium">{{ getMobileLabel(tab) }}</span>
</div>
<Badge
v-if="tab.count !== undefined"
variant="secondary"
class="text-xs hidden sm:inline-flex min-w-[1.5rem] h-5"
>
{{ tab.count }}
</Badge>
<!-- Mobile count display -->
<div
v-if="tab.count !== undefined"
class="sm:hidden text-xs text-muted-foreground font-medium"
>
{{ tab.count }}
</div>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { LayoutDashboard, Camera, Package, ListTodo, Settings } from "lucide-vue-next";
import { Badge } from "@/components/ui/badge";
interface Tab {
id: string;
label: string;
icon: any;
route: string;
count?: number;
}
interface Props {
projectId: number;
shotCount?: number;
assetCount?: number;
taskCount?: number;
}
const props = defineProps<Props>();
const route = useRoute();
const router = useRouter();
// Define available tabs
const tabs = computed<Tab[]>(() => [
{
id: "overview",
label: "Overview",
icon: LayoutDashboard,
route: `/projects/${props.projectId}`,
},
{
id: "shots",
label: "Shots",
icon: Camera,
route: `/projects/${props.projectId}/shots`,
count: props.shotCount,
},
{
id: "assets",
label: "Assets",
icon: Package,
route: `/projects/${props.projectId}/assets`,
count: props.assetCount,
},
{
id: "tasks",
label: "Tasks",
icon: ListTodo,
route: `/projects/${props.projectId}/tasks`,
count: props.taskCount,
},
{
id: "settings",
label: "Settings",
icon: Settings,
route: `/projects/${props.projectId}/settings`,
},
]);
// Determine active tab based on current route
const activeTab = computed(() => {
const currentPath = route.path;
if (currentPath === `/projects/${props.projectId}`) {
return "overview";
} else if (currentPath.startsWith(`/projects/${props.projectId}/shots`)) {
return "shots";
} else if (currentPath.startsWith(`/projects/${props.projectId}/assets`)) {
return "assets";
} else if (currentPath.startsWith(`/projects/${props.projectId}/tasks`)) {
return "tasks";
} else if (
currentPath.startsWith(`/projects/${props.projectId}/settings`)
) {
return "settings";
}
return "overview";
});
// Set active tab and navigate
const setActiveTab = (tabId: string) => {
const tab = tabs.value.find((t) => t.id === tabId);
if (tab) {
router.push(tab.route).catch(() => {
// Navigation aborted (e.g. duplicate route) — safe to ignore
});
}
};
// Get mobile label for tabs
const getMobileLabel = (tab: Tab) => {
switch (tab.id) {
case "settings":
return "Settings";
case "overview":
return "Info";
case "shots":
return "Shots";
case "assets":
return "Assets";
case "tasks":
return "Tasks";
default:
return tab.label.charAt(0);
}
};
// Watch for route changes to ensure tab state persistence
watch(
() => route.path,
() => {
// Tab state is automatically updated via activeTab computed property
// This ensures tab state persistence during project navigation
},
{ immediate: true }
);
</script>
@@ -21,7 +21,7 @@ const emit = defineEmits<{ 'update:mobileOpen': [value: boolean] }>()
>
<div
v-if="visible"
class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
class="fixed right-0 top-16 bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
>
<slot />
</div>
+5 -1
View File
@@ -170,6 +170,7 @@
:key="selectedTaskId"
:task-id="selectedTaskId"
:initial-tab="selectedTaskTab"
:initial-reply-note-id="selectedTaskReplyNoteId"
@close="selectedTaskId = null"
@task-updated="loadShots"
/>
@@ -848,16 +849,19 @@ const clearSearch = () => {
const selectedTaskId = ref<number | null>(null)
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
selectedTaskTab.value = tab || 'infos'
selectedTaskReplyNoteId.value = noteId
}
// Reset the task sub-panel whenever the shot selection changes (including close)
watch(selectedShot, () => {
selectedTaskId.value = null
selectedTaskTab.value = 'infos'
selectedTaskReplyNoteId.value = undefined
})
const formatStatus = (status: ShotStatus) => {
+109 -57
View File
@@ -13,13 +13,6 @@
<div v-else-if="shot" class="flex-1 overflow-y-auto">
<DetailPanelHeader :title="shot.name" :deleted-at="shot.deleted_at" @close="$emit('close')">
<template #badges>
<Badge :variant="getStatusVariant(shot.status)" class="text-xs flex-shrink-0">
<div
class="w-2 h-2 rounded-full mr-1"
:class="getStatusColor(shot.status)"
></div>
{{ formatStatus(shot.status) }}
</Badge>
<!-- Deletion status indicator for admins -->
<Badge v-if="isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
Deleted {{ formatDeletedDate(shot.deleted_at) }}
@@ -30,11 +23,34 @@
<!-- Tabbed Content -->
<Tabs default-value="infos" class="flex-1 flex flex-col">
<TabsList class="mx-0 mt-0 grid w-full grid-cols-5 rounded-none border-b">
<TabsTrigger value="infos">Infos</TabsTrigger>
<TabsTrigger value="notes">Notes</TabsTrigger>
<TabsTrigger value="assets">Assets</TabsTrigger>
<TabsTrigger value="references">References</TabsTrigger>
<TabsTrigger value="design">Design</TabsTrigger>
<TabsTrigger value="infos" title="Infos">
<Info class="h-4 w-4" />
<span class="sr-only">Infos</span>
</TabsTrigger>
<TabsTrigger value="notes" title="Notes">
<span class="relative inline-flex">
<MessageSquare class="h-4 w-4" />
<span
v-if="shotNotes.length > 0"
class="absolute -top-1.5 -right-1.5 h-3.5 w-3.5 rounded-full bg-red-500 text-white text-[9px] leading-none flex items-center justify-center"
>
{{ shotNotes.length > 99 ? '99+' : shotNotes.length }}
</span>
</span>
<span class="sr-only">Notes</span>
</TabsTrigger>
<TabsTrigger value="assets" title="Assets">
<Package class="h-4 w-4" />
<span class="sr-only">Assets</span>
</TabsTrigger>
<TabsTrigger value="references" title="References">
<Image class="h-4 w-4" />
<span class="sr-only">References</span>
</TabsTrigger>
<TabsTrigger value="design" title="Design">
<Edit class="h-4 w-4" />
<span class="sr-only">Design</span>
</TabsTrigger>
</TabsList>
<!-- Infos Tab -->
@@ -229,7 +245,35 @@
</Popover>
</div>
<div class="text-center py-8">
<Select v-if="tasks.length > 0" v-model="noteTaskFilter">
<SelectTrigger class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Tasks</SelectItem>
<SelectItem v-for="task in tasks" :key="task.id" :value="task.id">
{{ formatTaskType(task.task_type) }}
</SelectItem>
</SelectContent>
</Select>
<div v-if="isLoadingNotes" class="text-center py-8 text-sm text-muted-foreground">
Loading notes...
</div>
<div v-else-if="filteredShotNotes.length > 0" class="space-y-4">
<div v-for="note in filteredShotNotes" :key="note.id" class="space-y-1">
<Badge variant="outline" class="text-xs">{{ formatTaskType(taskTypeForNote(note) || '') }}</Badge>
<NoteItem
:note="note"
:task-id="note.task_id"
@note-updated="handleNoteUpdated"
@reply="handleNoteReply"
/>
</div>
</div>
<div v-else class="text-center py-8">
<MessageSquare class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
<p class="text-sm text-muted-foreground">No notes yet</p>
<p class="text-xs text-muted-foreground mt-1">Add notes to track important information</p>
@@ -334,21 +378,23 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
ListTodo, Plus, MessageSquare, Package, Image, Edit, Send
ListTodo, Plus, MessageSquare, Package, Image, Edit, Send, Info
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import NoteItem from '@/components/task/NoteItem.vue'
import { shotService, ShotStatus, type Shot, type TaskStatusInfo } from '@/services/shot'
import { taskService } from '@/services/task'
import { shotService, type Shot, type TaskStatusInfo } from '@/services/shot'
import { taskService, type ProductionNote } from '@/services/task'
import { projectService, type ProjectMember } from '@/services/project'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
@@ -375,7 +421,7 @@ interface Props {
interface Emits {
(e: 'edit', shot: Shot): void
(e: 'delete', shot: Shot): void
(e: 'select-task', task: Task, tab?: string): void
(e: 'select-task', task: Task, tab?: string, noteId?: number): void
(e: 'link-asset'): void
(e: 'edit-design'): void
(e: 'close'): void
@@ -395,6 +441,9 @@ const isLoading = ref(false)
const error = ref<string | null>(null)
const isCreatingTask = ref(false)
const projectMembers = ref<ProjectMember[]>([])
const shotNotes = ref<ProductionNote[]>([])
const isLoadingNotes = ref(false)
const noteTaskFilter = ref<number | 'all'>('all')
// Computed properties
const frameCount = computed(() => {
@@ -436,6 +485,17 @@ const canCreateTask = computed(() => isCoordinatorOrAdmin.value)
const canCreateNote = computed(() => isCoordinatorOrAdmin.value)
const filteredShotNotes = computed(() => {
const notes = noteTaskFilter.value === 'all'
? shotNotes.value
: shotNotes.value.filter(note => note.task_id === noteTaskFilter.value)
return [...notes].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
})
const taskTypeForNote = (note: ProductionNote) => {
return tasks.value.find(task => task.id === note.task_id)?.task_type
}
const canLinkAssets = computed(() => isCoordinatorOrAdmin.value)
const canUploadReferences = computed(() => {
@@ -460,6 +520,7 @@ const loadShotDetails = async () => {
loadProjectMembers()
])
loadTasks() // No longer async - uses embedded data
loadShotNotes() // Fire-and-forget - own loading state, doesn't block the panel
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load shot details'
console.error('Failed to load shot details:', err)
@@ -468,6 +529,36 @@ const loadShotDetails = async () => {
}
}
const loadShotNotes = async () => {
if (tasks.value.length === 0) {
shotNotes.value = []
return
}
try {
isLoadingNotes.value = true
const notesByTask = await Promise.all(
tasks.value.map(task => taskService.getTaskNotes(task.id).catch(() => []))
)
shotNotes.value = notesByTask.flat()
} catch (err) {
console.error('Failed to load shot notes:', err)
} finally {
isLoadingNotes.value = false
}
}
const handleNoteUpdated = () => {
loadShotNotes()
}
const handleNoteReply = (noteId: number) => {
const note = shotNotes.value.find(n => n.id === noteId)
const task = note ? tasks.value.find(t => t.id === note.task_id) : undefined
if (task) {
emit('select-task', task, 'notes', noteId)
}
}
const loadProjectMembers = async () => {
try {
projectMembers.value = await projectService.getProjectMembers(props.projectId)
@@ -519,12 +610,6 @@ const handleAddTask = async (taskType: string) => {
}
}
const formatStatus = (status: ShotStatus) => {
return status.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ')
}
const formatTaskType = (taskType: string) => {
return taskType.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
@@ -537,40 +622,6 @@ const getTaskAssigneeInitials = (task: Task) => {
return (first + last).toUpperCase()
}
const getStatusVariant = (status: ShotStatus) => {
switch (status) {
case ShotStatus.NOT_STARTED:
return 'secondary'
case ShotStatus.IN_PROGRESS:
return 'default'
case ShotStatus.ON_HOLD:
return 'outline'
case ShotStatus.COMPLETED:
return 'default'
case ShotStatus.APPROVED:
return 'default'
default:
return 'secondary'
}
}
const getStatusColor = (status: ShotStatus) => {
switch (status) {
case ShotStatus.NOT_STARTED:
return 'bg-gray-400'
case ShotStatus.IN_PROGRESS:
return 'bg-blue-500'
case ShotStatus.ON_HOLD:
return 'bg-yellow-500'
case ShotStatus.COMPLETED:
return 'bg-green-500'
case ShotStatus.APPROVED:
return 'bg-emerald-600'
default:
return 'bg-gray-400'
}
}
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
@@ -596,6 +647,7 @@ const formatDeletedDate = (deletedAt: string) => {
// Watchers
watch(() => props.shotId, (newShotId) => {
if (newShotId) {
noteTaskFilter.value = 'all'
loadShotDetails()
}
}, { immediate: true })
+33 -29
View File
@@ -19,40 +19,44 @@
</div>
<!-- Content -->
<div class="flex-1 space-y-1">
<div class="flex items-center gap-2">
<span class="font-semibold text-sm">
{{ note.user_first_name }} {{ note.user_last_name }}
</span>
<span class="text-xs text-muted-foreground">
{{ formatDateTime(note.created_at) }}
</span>
<span v-if="note.updated_at !== note.created_at" class="text-xs text-muted-foreground">
(edited)
</span>
<Badge v-if="note.note_type === 'client'" variant="outline" class="text-xs">Client</Badge>
</div>
<div class="flex-1 min-w-0 space-y-1">
<!-- Message Card -->
<div
class="rounded-2xl border px-3 py-2"
:class="isOwnNote ? 'bg-primary/10 border-primary/20' : 'bg-muted/50 border-border'"
>
<div class="flex items-center gap-2 flex-wrap">
<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>
<span class="text-xs text-muted-foreground ml-auto">
{{ formatDateTime(note.created_at) }}
<template v-if="note.updated_at !== note.created_at"> (edited)</template>
</span>
</div>
<!-- Note Content -->
<div v-if="!editing" class="text-sm whitespace-pre-wrap">
{{ note.content }}
</div>
<!-- Note Content -->
<div v-if="!editing" class="text-sm whitespace-pre-wrap mt-0.5">
{{ note.content }}
</div>
<!-- Edit Form -->
<div v-else class="space-y-2">
<Textarea
v-model="editContent"
rows="3"
class="resize-none"
/>
<div class="flex gap-2">
<Button size="sm" @click="handleSave">Save</Button>
<Button size="sm" variant="outline" @click="editing = false">Cancel</Button>
<!-- Edit Form -->
<div v-else class="space-y-2 mt-1">
<Textarea
v-model="editContent"
rows="3"
class="resize-none bg-background"
/>
<div class="flex gap-2">
<Button size="sm" @click="handleSave">Save</Button>
<Button size="sm" variant="outline" @click="editing = false">Cancel</Button>
</div>
</div>
</div>
<!-- Actions -->
<div v-if="!editing" class="flex gap-2">
<div v-if="!editing" class="flex gap-2 px-1">
<Button
variant="ghost"
size="sm"
@@ -82,7 +86,7 @@
</div>
<!-- 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
v-for="childNote in note.child_notes"
:key="childNote.id"
@@ -12,31 +12,39 @@
<!-- Task Details -->
<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')">
<template #badges>
<TaskStatusBadge :status="task.status" class="flex-shrink-0" />
</template>
</DetailPanelHeader>
<!-- Tabbed Content -->
<Tabs :default-value="initialTab || 'infos'" class="flex-1 flex flex-col min-h-0">
<!-- Tabs List (Fixed) -->
<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="notes">
Notes
<Badge v-if="notes.length > 0" variant="secondary" class="ml-2">
{{ notes.length }}
</Badge>
<TabsTrigger value="infos" title="Infos">
<Info class="h-4 w-4" />
<span class="sr-only">Infos</span>
</TabsTrigger>
<TabsTrigger value="attachments">
Attachments
<Badge v-if="attachments.length > 0" variant="secondary" class="ml-2">
<TabsTrigger value="notes" title="Notes">
<span class="relative inline-flex">
<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 }}
</Badge>
</TabsTrigger>
<TabsTrigger value="submissions">
Submissions
<Badge v-if="submissions.length > 0" variant="secondary" class="ml-2">
<TabsTrigger value="submissions" title="Submissions">
<Upload class="h-4 w-4" />
<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 }}
</Badge>
</TabsTrigger>
@@ -178,7 +186,12 @@
<!-- Notes Tab -->
<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>
<!-- Attachments Tab -->
@@ -262,7 +275,7 @@
<script setup lang="ts">
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 { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
@@ -299,7 +312,6 @@ import {
CommandItem,
CommandList,
} from '@/components/ui/command'
import TaskStatusBadge from './TaskStatusBadge.vue'
import TaskNotes from './TaskNotes.vue'
import TaskAttachments from './TaskAttachments.vue'
import TaskSubmissions from './TaskSubmissions.vue'
@@ -311,6 +323,7 @@ import { useToast } from '@/components/ui/toast/use-toast'
const props = defineProps<{
taskId: number
initialTab?: string
initialReplyNoteId?: number
}>()
const emit = defineEmits<{
+50 -9
View File
@@ -20,12 +20,23 @@
<!-- Note Input (Bottom) -->
<div class="flex-shrink-0 border-t bg-background p-2">
<div class="space-y-2">
<Textarea
v-model="newNoteContent"
placeholder="Add a note..."
rows="2"
class="resize-none text-sm"
/>
<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>
<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
@@ -60,8 +71,8 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { MessageSquarePlus } from 'lucide-vue-next'
import { ref, computed, nextTick, onMounted } from 'vue'
import { MessageSquarePlus, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import NoteItem from './NoteItem.vue'
@@ -71,6 +82,7 @@ import { useToast } from '@/components/ui/toast/use-toast'
const props = defineProps<{
taskId: number
notes: ProductionNote[]
initialReplyNoteId?: number
}>()
const emit = defineEmits<{
@@ -83,6 +95,23 @@ const newNoteContent = ref('')
const newNoteType = ref<NoteType>('internal')
const submitting = ref(false)
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() {
if (!newNoteContent.value.trim()) return
@@ -117,6 +146,18 @@ async function handleAddNote() {
function handleReply(noteId: number) {
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>
@@ -20,7 +20,7 @@ const forwardedProps = useForwardProps(delegatedProps)
props.class,
)"
>
<span class="truncate">
<span class="inline-flex items-center justify-center">
<slot />
</span>
</TabsTrigger>
@@ -1,21 +1,33 @@
<template>
<textarea
v-model="modelValue"
: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',
props.class
)"
v-bind="$attrs"
/>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
interface Props {
class?: string
modelValue?: string
defaultValue?: string
}
const props = withDefaults(defineProps<Props>(), {
class: ''
})
const emits = defineEmits<{
(e: 'update:modelValue', payload: string): void
}>()
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
-10
View File
@@ -38,15 +38,6 @@
<!-- </div> -->
</div>
<!-- Project Tabs -->
<div class="px-0 sm:px-0 pb-0 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60" v-if="project">
<ProjectTabs
:project-id="project.id"
:shot-count="project.shot_count"
:asset-count="project.asset_count"
/>
</div>
<!-- Tab Content Area -->
<div class="flex-1 overflow-auto bg-muted/30">
<router-view :key="route.fullPath" />
@@ -87,7 +78,6 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useProjectsStore } from '@/stores/projects'
import ProjectTabs from '@/components/project/ProjectTabs.vue'
import type { Project } from '@/stores/projects'
const router = useRouter()