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"> <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'
@@ -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 <div
v-if="visible" 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 /> <slot />
</div> </div>
+5 -1
View File
@@ -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) => {
+109 -57
View File
@@ -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 })
+33 -29
View File
@@ -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<{
+50 -9
View File
@@ -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>
-10
View File
@@ -38,15 +38,6 @@
<!-- </div> --> <!-- </div> -->
</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 --> <!-- Tab Content Area -->
<div class="flex-1 overflow-auto bg-muted/30"> <div class="flex-1 overflow-auto bg-muted/30">
<router-view :key="route.fullPath" /> <router-view :key="route.fullPath" />
@@ -87,7 +78,6 @@ import {
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { useProjectsStore } from '@/stores/projects' import { useProjectsStore } from '@/stores/projects'
import ProjectTabs from '@/components/project/ProjectTabs.vue'
import type { Project } from '@/stores/projects' import type { Project } from '@/stores/projects'
const router = useRouter() const router = useRouter()