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>
This commit is contained in:
2026-07-17 08:45:06 +08:00
parent e8b26487db
commit cd3628a255
15 changed files with 1034 additions and 292 deletions
@@ -117,8 +117,111 @@
<!-- Task Status & Assignees -->
<div class="space-y-3">
<h3 class="text-sm font-semibold">Tasks</h3>
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">Tasks</h3>
<div class="flex items-center gap-1">
<!-- Add Task -->
<Popover>
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" title="Add Task" :disabled="availableTaskTypes.length === 0">
<Plus class="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-48 p-2" align="end">
<div class="px-2 py-1.5 text-sm font-semibold">Add Task</div>
<div v-if="availableTaskTypes.length === 0" class="px-2 py-2 text-xs text-muted-foreground">
All task types already added
</div>
<div v-else class="flex flex-col gap-1 max-h-48 overflow-y-auto">
<Button
v-for="type in availableTaskTypes"
:key="type"
variant="ghost"
size="sm"
class="justify-start"
:disabled="isCreatingTask"
@click="handleAddTask(type)"
>
{{ formatTaskType(type) }}
</Button>
</div>
</PopoverContent>
</Popover>
<!-- Add Note (picks a task, opens its Notes tab) -->
<Popover>
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" title="Add Note" :disabled="tasks.length === 0">
<MessageSquarePlus class="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-48 p-2" align="end">
<div class="px-2 py-1.5 text-sm font-semibold">Add note to task</div>
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
<Button
v-for="task in tasks"
:key="task.id"
variant="ghost"
size="sm"
class="justify-start"
@click="$emit('select-task', task, 'notes')"
>
{{ task.name }}
</Button>
</div>
</PopoverContent>
</Popover>
<!-- Upload Reference (picks a task, opens its Attachments tab) -->
<Popover>
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" title="Upload Reference" :disabled="tasks.length === 0">
<Paperclip class="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-48 p-2" align="end">
<div class="px-2 py-1.5 text-sm font-semibold">Upload reference to task</div>
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
<Button
v-for="task in tasks"
:key="task.id"
variant="ghost"
size="sm"
class="justify-start"
@click="$emit('select-task', task, 'attachments')"
>
{{ task.name }}
</Button>
</div>
</PopoverContent>
</Popover>
<!-- Publish Version (picks a task, opens its Submissions tab) -->
<Popover>
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" title="Publish Version" :disabled="tasks.length === 0">
<Send class="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-48 p-2" align="end">
<div class="px-2 py-1.5 text-sm font-semibold">Publish version for task</div>
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
<Button
v-for="task in tasks"
:key="task.id"
variant="ghost"
size="sm"
class="justify-start"
@click="$emit('select-task', task, 'submissions')"
>
{{ task.name }}
</Button>
</div>
</PopoverContent>
</Popover>
</div>
</div>
<!-- Loading Tasks -->
<div v-if="isLoading" class="flex items-center justify-center py-4">
<div class="flex items-center gap-2">
@@ -182,17 +285,19 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
AlertCircle, RefreshCw, X
import {
AlertCircle, RefreshCw, X, Plus, MessageSquarePlus, Paperclip, Send
} 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 AssetNotes from './AssetNotes.vue'
import AssetReferences from './AssetReferences.vue'
import { assetService, AssetStatus, type Asset, type TaskStatusInfo } from '@/services/asset'
import { taskService } from '@/services/task'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
@@ -208,16 +313,13 @@ interface Task {
interface Props {
projectId: number
assetId: number
allTaskTypes: string[]
}
interface Emits {
(e: 'edit', asset: Asset): void
(e: 'delete', asset: Asset): void
(e: 'create-task'): void
(e: 'select-task', task: Task): void
(e: 'create-note'): void
(e: 'upload-reference'): void
(e: 'publish-version'): void
(e: 'select-task', task: Task, tab?: string): void
(e: 'close'): void
}
@@ -233,6 +335,7 @@ const notes = ref<any[]>([])
const references = ref<any[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const isCreatingTask = ref(false)
// Computed properties
const tasks = computed(() => {
@@ -266,6 +369,11 @@ const progressPercentage = computed(() => {
return Math.round((completedTasksCount.value / tasks.value.length) * 100)
})
const availableTaskTypes = computed(() => {
const existingTypes = new Set(tasks.value.map(task => task.task_type))
return props.allTaskTypes.filter(type => !existingTypes.has(type))
})
const taskStatusCounts = computed(() => {
const counts = {
not_started: 0,
@@ -365,6 +473,18 @@ const loadReferences = async () => {
}
}
const handleAddTask = async (taskType: string) => {
isCreatingTask.value = true
try {
await taskService.createAssetTask(props.assetId, taskType)
await loadAssetDetails()
} catch (err) {
console.error('Failed to create task:', err)
} finally {
isCreatingTask.value = false
}
}
const formatCategory = (category: string) => {
return category.charAt(0).toUpperCase() + category.slice(1)
}