Compare commits
3 Commits
960753b3d6
...
762bd34f74
| Author | SHA1 | Date | |
|---|---|---|---|
| 762bd34f74 | |||
| 4bffec642e | |||
| 548d1081ba |
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user