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:
@@ -131,15 +131,58 @@
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold">Tasks</h3>
|
||||
<Button
|
||||
v-if="canCreateTask"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="$emit('create-task')"
|
||||
>
|
||||
<Plus class="h-3 w-3 mr-1" />
|
||||
Add Task
|
||||
</Button>
|
||||
<div class="flex items-center gap-1">
|
||||
<Popover v-if="canCreateTask">
|
||||
<PopoverTrigger as-child>
|
||||
<Button size="sm" variant="outline" :disabled="availableTaskTypes.length === 0">
|
||||
<Plus class="h-3 w-3 mr-1" />
|
||||
Add Task
|
||||
</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>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 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')"
|
||||
>
|
||||
{{ formatTaskType(task.task_type) }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<!-- No Tasks -->
|
||||
<div v-if="tasks.length === 0" class="text-center py-8">
|
||||
@@ -158,7 +201,7 @@
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="px-4 py-3 grid grid-cols-3 gap-4 items-center hover:bg-muted/50 cursor-pointer transition-colors border-b last:border-b-0"
|
||||
@click="$emit('select-task', task)"
|
||||
@click="$emit('select-task', task, 'infos')"
|
||||
>
|
||||
<div class="text-sm font-medium">{{ formatTaskType(task.task_type) }}</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
@@ -178,15 +221,29 @@
|
||||
<TabsContent value="notes" class="flex-1 p-6 space-y-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-semibold">Production Notes</h3>
|
||||
<Button
|
||||
v-if="canCreateNote"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="$emit('create-note')"
|
||||
>
|
||||
<Plus class="h-3 w-3 mr-1" />
|
||||
Add Note
|
||||
</Button>
|
||||
<Popover v-if="canCreateNote">
|
||||
<PopoverTrigger as-child>
|
||||
<Button size="sm" variant="outline" :disabled="tasks.length === 0">
|
||||
<Plus class="h-3 w-3 mr-1" />
|
||||
Add Note
|
||||
</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')"
|
||||
>
|
||||
{{ formatTaskType(task.task_type) }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div class="text-center py-8">
|
||||
@@ -222,15 +279,29 @@
|
||||
<TabsContent value="references" class="flex-1 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-semibold">Reference Files</h3>
|
||||
<Button
|
||||
v-if="canUploadReferences"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="$emit('upload-reference')"
|
||||
>
|
||||
<Plus class="h-3 w-3 mr-1" />
|
||||
Upload Reference
|
||||
</Button>
|
||||
<Popover v-if="canUploadReferences">
|
||||
<PopoverTrigger as-child>
|
||||
<Button size="sm" variant="outline" :disabled="tasks.length === 0">
|
||||
<Plus class="h-3 w-3 mr-1" />
|
||||
Upload Reference
|
||||
</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')"
|
||||
>
|
||||
{{ formatTaskType(task.task_type) }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div class="text-center py-8">
|
||||
@@ -279,15 +350,17 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
AlertCircle, RefreshCw, ListTodo, Plus, MessageSquare, Package, Image, X, Edit
|
||||
import {
|
||||
AlertCircle, RefreshCw, ListTodo, Plus, MessageSquare, Package, Image, X, Edit, 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 { shotService, ShotStatus, type Shot, type TaskStatusInfo, TaskStatus } from '@/services/shot'
|
||||
import { taskService } from '@/services/task'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
// Use TaskStatusInfo from shot service instead of local Task interface
|
||||
@@ -302,16 +375,14 @@ interface Props {
|
||||
projectId: number
|
||||
shotId: number
|
||||
initialShot?: Shot
|
||||
allTaskTypes: string[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'edit', shot: Shot): void
|
||||
(e: 'delete', shot: Shot): void
|
||||
(e: 'create-task'): void
|
||||
(e: 'select-task', task: Task): void
|
||||
(e: 'create-note'): void
|
||||
(e: 'select-task', task: Task, tab?: string): void
|
||||
(e: 'link-asset'): void
|
||||
(e: 'upload-reference'): void
|
||||
(e: 'edit-design'): void
|
||||
(e: 'close'): void
|
||||
}
|
||||
@@ -326,6 +397,7 @@ const shot = ref<Shot | null>(null)
|
||||
const tasks = ref<Task[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const isCreatingTask = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const frameCount = computed(() => {
|
||||
@@ -383,6 +455,11 @@ const canEditDesign = computed(() => {
|
||||
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
|
||||
})
|
||||
|
||||
const availableTaskTypes = computed(() => {
|
||||
const existingTypes = new Set(tasks.value.map(task => task.task_type))
|
||||
return props.allTaskTypes.filter(type => !existingTypes.has(type))
|
||||
})
|
||||
|
||||
// Methods
|
||||
const loadShotDetails = async () => {
|
||||
try {
|
||||
@@ -415,6 +492,20 @@ const loadTasks = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddTask = async (taskType: string) => {
|
||||
isCreatingTask.value = true
|
||||
try {
|
||||
await taskService.createShotTask(props.shotId, taskType)
|
||||
// Bypass initialShot (which would just return the stale cached object) to get the new task
|
||||
shot.value = await shotService.getShot(props.shotId)
|
||||
loadTasks()
|
||||
} catch (err) {
|
||||
console.error('Failed to create task:', err)
|
||||
} finally {
|
||||
isCreatingTask.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatStatus = (status: ShotStatus) => {
|
||||
return status.split('_').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
|
||||
Reference in New Issue
Block a user