Files
LinkDesk/frontend/src/views/ProjectDetailView.vue
T
indigo cd3628a255 Phase 2: asset/shot feature parity, task actions, member management
Phase 2 of frontend_tasks.md - close feature gaps between domains:

- Asset user-assignment popover, ported from shot's EditableTaskStatus,
  wired through columns.ts and AssetBrowser.vue.
- Asset column-locking toggle: two-pane frozen-column layout ported
  into AssetsDataTable.vue (adapted for asset's page-scroll layout,
  which has no bounded-height container like shot's).
- Task table row-actions menu (View Details + Reassign only - no
  Delete/Edit, since no backend support exists for either).
- Real select-task/create-task behavior on asset and shot detail
  panels: clicking a task swaps in the actual TaskDetailPanel in
  place; "Add Task" opens a task-type picker that creates real tasks
  via the existing createAssetTask/createShotTask services.
- create-note/upload-reference/publish-version implemented via a
  task-picker that deep-links into TaskDetailPanel's Notes/
  Attachments/Submissions tabs (new initialTab prop), reusing the
  already-working task-level components instead of building three new
  bespoke forms. Also fixed shot's pre-existing dead "Add Note"/
  "Upload Reference" buttons the same way.
- Consolidated ProjectMembersManager.vue and ProjectMemberManagement.vue
  into one component, combining remove-confirmation and approved-user
  filtering with toast feedback and the shared Select/Dialog UI kit.
- Wired ProjectDetailView's "Manage Members" to navigate to the
  project's Settings > Team tab.

Also fixed a bug in this session's own new code: TaskBrowser.vue's
row-click handler is a no-op by design, so the new row-actions menu
needed to emit row-double-click (which actually opens the panel)
instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 08:45:06 +08:00

185 lines
5.4 KiB
Vue

<template>
<div class="flex flex-col h-full">
<!-- Project Header -->
<div class="p-0 sm:p-0 pb-0" v-if="project">
<!-- <div class="flex flex-col sm:flex-row sm:items-start justify-between gap-4 mb-4"> -->
<!-- <div class="flex-1 min-w-0">
<h1 class="text-2xl sm:text-3xl font-bold truncate">{{ project.name }}</h1>
<p class="text-muted-foreground text-sm sm:text-base mt-1">{{ project.description }}</p>
</div> -->
<!-- <div class="flex items-center gap-2 flex-shrink-0">
<Badge :variant="getStatusVariant(project.status)">
{{ formatStatus(project.status) }}
</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<MoreHorizontal class="h-4 w-4" />
<span class="sr-only">Project actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="editProject">
<Edit class="h-4 w-4 mr-2" />
Edit Project
</DropdownMenuItem>
<DropdownMenuItem @click="manageTechnicalSpecs">
<Settings class="h-4 w-4 mr-2" />
Technical Specs
</DropdownMenuItem>
<DropdownMenuItem @click="manageMembers">
<Users class="h-4 w-4 mr-2" />
Manage Members
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</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 -->
<div class="flex-1 overflow-auto bg-muted/30">
<router-view :key="route.fullPath" />
</div>
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<div class="flex items-center gap-2">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<span class="text-muted-foreground">Loading project...</span>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-12">
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
<h3 class="text-lg font-semibold mb-2">Failed to load project</h3>
<p class="text-muted-foreground mb-4">{{ error }}</p>
<Button @click="loadProject" variant="outline">
<RefreshCw class="h-4 w-4 mr-2" />
Try Again
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import {
MoreHorizontal, Edit, Settings, Users,
AlertCircle, RefreshCw
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
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()
const route = useRoute()
const projectsStore = useProjectsStore()
// Reactive state
const project = ref<Project | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
// Computed properties
const projectId = computed(() => {
const id = route.params.projectId
return typeof id === 'string' ? parseInt(id) : null
})
// Methods
const loadProject = async () => {
if (!projectId.value) return
try {
isLoading.value = true
error.value = null
// Try to get project from store first
let projectData = projectsStore.getProjectById(projectId.value)
if (!projectData) {
// If not in store, fetch from API
projectData = await projectsStore.getProject(projectId.value, true)
}
project.value = projectData
// Set as active project in store
if (projectData) {
projectsStore.setActiveProject(projectData)
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load project'
} finally {
isLoading.value = false
}
}
const editProject = () => {
router.push('/projects')
}
const manageTechnicalSpecs = () => {
if (projectId.value) {
router.push(`/projects/${projectId.value}/technical-specs`)
}
}
const manageMembers = () => {
if (projectId.value) {
router.push(`/projects/${projectId.value}/settings`)
}
}
const getStatusVariant = (status: string) => {
switch (status) {
case 'planning': return 'secondary'
case 'in_progress': return 'default'
case 'on_hold': return 'outline'
case 'completed': return 'default'
case 'cancelled': return 'destructive'
default: return 'secondary'
}
}
const formatStatus = (status: string) => {
return status.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ')
}
// Watchers
watch(projectId, (newId) => {
if (newId) {
loadProject()
}
}, { immediate: true })
// Lifecycle
onMounted(() => {
if (projectId.value) {
loadProject()
}
})
</script>