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
+65 -35
View File
@@ -16,6 +16,7 @@
:selected-asset="selectedAsset"
:is-detail-panel-enabled="isDetailPanelEnabled"
:show-thumbnails="showThumbnails"
:is-columns-locked="lockColumns"
@update:view-mode="viewMode = $event"
@update:category-filter="handleCategoryFilterChange"
@update:search="searchQuery = $event"
@@ -23,6 +24,7 @@
@update:show-thumbnails="handleThumbnailToggle"
@task-status-filter-changed="handleTaskStatusFilter"
@toggle-detail-panel="toggleDetailPanelEnabled"
@toggle-column-lock="toggleColumnLock"
@create-asset="showCreateDialog = true"
/>
<div v-else class="flex items-center justify-center py-4">
@@ -109,6 +111,7 @@
:sorting="sorting"
:column-visibility="columnVisibility"
:all-task-types="allTaskTypes"
:lock-columns="lockColumns"
@update:sorting="sorting = $event"
@update:column-visibility="handleColumnVisibilityChange"
@update:rowSelection="handleRowSelectionChange"
@@ -179,21 +182,27 @@
leave-from-class="translate-x-0"
leave-to-class="translate-x-full"
>
<div
v-if="showPanel && selectedAsset"
<div
v-if="showPanel && selectedAsset"
class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
>
<TaskDetailPanel
v-if="selectedTaskId"
:key="selectedTaskId"
:task-id="selectedTaskId"
:initial-tab="selectedTaskTab"
@close="selectedTaskId = null"
@task-updated="loadAssets"
/>
<AssetDetailPanel
v-else
:project-id="projectId"
:asset-id="selectedAsset.id"
:all-task-types="allTaskTypes"
@close="closeDetailPanel"
@edit="editAsset"
@delete="deleteAsset"
@create-task="handleCreateTask"
@select-task="handleSelectTask"
@create-note="handleCreateNote"
@upload-reference="handleUploadReference"
@publish-version="handlePublishVersion"
/>
</div>
</Transition>
@@ -201,18 +210,23 @@
<!-- Asset Detail Panel (Mobile) -->
<Sheet v-model:open="showMobileDetail">
<SheetContent side="right" class="w-full sm:max-w-md p-0">
<TaskDetailPanel
v-if="selectedTaskId"
:key="selectedTaskId"
:task-id="selectedTaskId"
:initial-tab="selectedTaskTab"
@close="selectedTaskId = null"
@task-updated="loadAssets"
/>
<AssetDetailPanel
v-if="selectedAsset"
v-else-if="selectedAsset"
:project-id="projectId"
:asset-id="selectedAsset.id"
:all-task-types="allTaskTypes"
@close="closeDetailPanel"
@edit="editAsset"
@delete="deleteAsset"
@create-task="handleCreateTask"
@select-task="handleSelectTask"
@create-note="handleCreateNote"
@upload-reference="handleUploadReference"
@publish-version="handlePublishVersion"
/>
</SheetContent>
</Sheet>
@@ -244,6 +258,7 @@ import AssetCard from "./AssetCard.vue";
import AssetForm from "./AssetForm.vue";
import AssetDetailPanel from "./AssetDetailPanel.vue";
import AssetDeleteConfirmDialog from "./AssetDeleteConfirmDialog.vue";
import TaskDetailPanel from "@/components/task/TaskDetailPanel.vue";
import AssetsDataTable from "./AssetsDataTable.vue";
import AssetTableToolbar from "./AssetTableToolbar.vue";
import { createAssetColumns, type AssetColumnMeta } from "./columns";
@@ -376,6 +391,13 @@ const showThumbnails = ref(
sessionStorage.getItem('assetBrowser.showThumbnails') === 'true'
);
// Lock (freeze) the first columns (select/thumbnail/name) for horizontal scroll
const lockColumns = ref(localStorage.getItem('asset-columns-locked') === 'true');
const toggleColumnLock = () => {
lockColumns.value = !lockColumns.value;
localStorage.setItem('asset-columns-locked', String(lockColumns.value));
};
// Computed properties
const assets = computed(() => assetsStore.assets);
const isLoading = computed(() => assetsStore.isLoading);
@@ -435,6 +457,7 @@ const assetColumns = computed(() => {
onDelete: deleteAsset,
onViewTasks: viewAssetTasks,
onTaskStatusUpdated: handleTaskStatusUpdate,
onTaskAssignmentUpdated: handleTaskAssignmentUpdated,
onBulkTaskStatusChange: handleBulkTaskStatusChange,
getSelectedCount: () => selectedCount.value,
getAllStatusOptions: () => taskStatusesStore.getAllStatusOptions(props.projectId)
@@ -564,8 +587,8 @@ const deleteAsset = async (asset: Asset) => {
};
const viewAssetTasks = (asset: Asset) => {
// TODO: Navigate to asset tasks view
console.log("View tasks for asset:", asset.name);
// Open the asset's own detail panel (Infos tab, already the default, lists its tasks)
selectAsset(asset);
};
const handleCreateAsset = async (assetData: AssetCreate | AssetUpdate) => {
@@ -661,6 +684,24 @@ const handleTaskStatusUpdate = async (
}
};
const handleTaskAssignmentUpdated = (assetId: number, taskType: string, userId: number | null) => {
// Update local state instead of reloading all assets
const asset = assetsStore.assets.find((a) => a.id === assetId);
if (asset && asset.task_details) {
const taskDetail = asset.task_details.find((detail) => detail.task_type === taskType);
if (taskDetail) {
taskDetail.assigned_user_id = userId ?? undefined;
}
}
toast({
title: "Task assignment updated",
description: userId
? `${formatTaskType(taskType)} task assigned successfully.`
: `${formatTaskType(taskType)} task unassigned successfully.`,
});
};
const clearFilters = () => {
selectedCategory.value = "all";
searchQuery.value = "";
@@ -678,30 +719,19 @@ const formatTaskType = (taskType: string) => {
};
// Detail panel event handlers
const handleCreateTask = () => {
// TODO: Navigate to task creation for this asset
console.log('Create task for asset:', selectedAsset.value?.name);
const selectedTaskId = ref<number | null>(null);
const selectedTaskTab = ref<string>('infos');
const handleSelectTask = (task: { id: number }, tab?: string) => {
selectedTaskId.value = task.id;
selectedTaskTab.value = tab || 'infos';
};
const handleSelectTask = (task: any) => {
// TODO: Open task detail panel
console.log('Select task:', task);
};
const handleCreateNote = () => {
// TODO: Open note creation dialog
console.log('Create note for asset:', selectedAsset.value?.name);
};
const handleUploadReference = () => {
// TODO: Open reference upload dialog
console.log('Upload reference for asset:', selectedAsset.value?.name);
};
const handlePublishVersion = () => {
// TODO: Open version publish dialog
console.log('Publish version for asset:', selectedAsset.value?.name);
};
// Reset the task sub-panel whenever the asset selection changes (including close)
watch(selectedAsset, () => {
selectedTaskId.value = null;
selectedTaskTab.value = 'infos';
});
// Load custom task types from project
const loadCustomTaskTypes = async () => {
@@ -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)
}
@@ -199,6 +199,19 @@
<PanelRightOpen v-else class="h-4 w-4" />
</Button>
<!-- Lock First Columns Toggle Button (only for list view) -->
<Button
v-if="viewMode === 'list'"
@click="$emit('toggle-column-lock')"
:variant="isColumnsLocked ? 'default' : 'outline'"
size="sm"
:class="['h-8 w-8 p-0', isColumnsLocked ? 'bg-primary text-primary-foreground hover:bg-primary/90' : '']"
:title="isColumnsLocked ? 'Unlock columns' : 'Lock first columns'"
>
<Lock v-if="isColumnsLocked" class="h-4 w-4" />
<Unlock v-else class="h-4 w-4" />
</Button>
<!-- Clear Filters -->
<Button
v-if="hasFilters"
@@ -248,9 +261,10 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
import {
LayoutGrid, List, Search, Package, Plus, ImageIcon, ImageOff,
PanelRightClose, PanelRightOpen, Check, X, Settings2, ListTodo, ListX
PanelRightClose, PanelRightOpen, Check, X, Settings2, ListTodo, ListX,
Lock, Unlock
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -290,6 +304,7 @@ interface Props {
selectedAsset: Asset | null
isDetailPanelEnabled: boolean
showThumbnails: boolean
isColumnsLocked: boolean
}
const props = defineProps<Props>()
@@ -302,6 +317,7 @@ const emit = defineEmits<{
'update:show-thumbnails': [value: boolean]
'task-status-filter-changed': [value: string]
'toggle-detail-panel': []
'toggle-column-lock': []
'create-asset': []
}>()
@@ -1,7 +1,105 @@
<template>
<div class="space-y-4 px-4">
<div class="rounded-md border">
<Table>
<!-- Locked: two-pane layout (fixed left pane + horizontally scrollable right pane) -->
<template v-if="lockColumns">
<div v-if="hasRows" class="flex">
<!-- Left pane: frozen columns, no horizontal scroll -->
<div class="flex-shrink-0 border-r">
<table class="caption-bottom text-sm">
<TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in frozenHeaders(headerGroup)"
:key="header.id"
:style="frozenWidth(header.column.id)"
:class="header.column.getCanSort() ? 'cursor-pointer select-none hover:bg-muted/50' : ''"
@click="header.column.getCanSort() ? header.column.toggleSorting() : null"
>
<FlexRender
v-if="!header.isPlaceholder"
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="row in table.getRowModel().rows"
:key="row.id"
:data-state="row.getIsSelected() ? 'selected' : undefined"
class="cursor-pointer hover:bg-muted/50"
:class="{ 'bg-muted/30': row.getIsSelected(), 'table-row-selectable': true, 'selecting': isRangeSelecting }"
@click="handleRowClick(row.original, $event, row)"
@mousedown="handleMouseDown"
@mouseup="handleMouseUp"
>
<TableCell
v-for="cell in frozenCells(row)"
:key="cell.id"
:style="frozenWidth(cell.column.id)"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</TableCell>
</TableRow>
</TableBody>
</table>
</div>
<!-- Right pane: movable columns, own horizontal scrollbar -->
<div class="flex-1 min-w-0 overflow-x-auto">
<table class="min-w-full caption-bottom text-sm">
<TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in movableHeaders(headerGroup)"
:key="header.id"
:class="[
header.column.getCanSort() ? 'cursor-pointer select-none hover:bg-muted/50' : '',
header.column.id === 'actions' ? 'w-12' : '',
allTaskTypes.includes(header.column.id) ? 'w-[140px]' : '',
]"
@click="header.column.getCanSort() ? header.column.toggleSorting() : null"
>
<FlexRender
v-if="!header.isPlaceholder"
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="row in table.getRowModel().rows"
:key="row.id"
:data-state="row.getIsSelected() ? 'selected' : undefined"
class="cursor-pointer hover:bg-muted/50"
:class="{ 'bg-muted/30': row.getIsSelected(), 'table-row-selectable': true, 'selecting': isRangeSelecting }"
@click="handleRowClick(row.original, $event, row)"
@mousedown="handleMouseDown"
@mouseup="handleMouseUp"
>
<TableCell
v-for="cell in movableCells(row)"
:key="cell.id"
v-memo="[cell.getValue(), cell.column.getIsVisible()]"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</TableCell>
</TableRow>
</TableBody>
</table>
</div>
</div>
<div v-else class="h-24 flex items-center justify-center text-sm text-muted-foreground">
No results.
</div>
</template>
<!-- Unlocked: standard single table -->
<Table v-else>
<TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
@@ -31,7 +129,7 @@
:key="row.id"
:data-state="row.getIsSelected() ? 'selected' : undefined"
class="cursor-pointer hover:bg-muted/50"
:class="{
:class="{
'bg-muted/30': row.getIsSelected(),
'table-row-selectable': true,
'selecting': isRangeSelecting
@@ -67,13 +165,15 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, computed, watch } from 'vue'
import {
FlexRender,
getCoreRowModel,
getSortedRowModel,
useVueTable,
type ColumnDef,
type HeaderGroup,
type Row,
type SortingState,
type VisibilityState,
} from '@tanstack/vue-table'
@@ -93,10 +193,38 @@ interface Props {
sorting: SortingState
columnVisibility: VisibilityState
allTaskTypes: string[]
lockColumns?: boolean
}
const props = defineProps<Props>()
// Frozen (locked) columns and their fixed widths (px), in column order.
const FROZEN: Record<string, number> = {
select: 48,
thumbnail: 96,
name: 200,
}
const isFrozen = (id: string) => id in FROZEN
const frozenWidth = (id: string) => {
const w = FROZEN[id]
return w ? { width: `${w}px`, minWidth: `${w}px` } : undefined
}
// Column partitioning for the two-pane (locked) layout. Movable headers/cells
// respect column visibility; frozen ones are always shown.
const frozenHeaders = (group: HeaderGroup<Asset>) =>
group.headers.filter((h) => isFrozen(h.column.id) && h.column.getIsVisible())
const movableHeaders = (group: HeaderGroup<Asset>) =>
group.headers.filter((h) => !isFrozen(h.column.id) && h.column.getIsVisible())
const frozenCells = (row: Row<Asset>) =>
row.getVisibleCells().filter((c) => isFrozen(c.column.id))
const movableCells = (row: Row<Asset>) =>
row.getVisibleCells().filter((c) => !isFrozen(c.column.id))
const hasRows = computed(() => table.getRowModel().rows.length > 0)
const emit = defineEmits<{
'update:sorting': [sorting: SortingState]
'update:columnVisibility': [visibility: VisibilityState]
@@ -1,11 +1,10 @@
<template>
<div class="relative"
>
<Select
:model-value="currentStatusId"
<div class="relative flex items-center gap-1">
<Select
:model-value="currentStatusId"
@update:model-value="handleStatusChange"
:disabled="isUpdating || isLoadingStatuses"
>
<SelectTrigger class="h-6 w-[130px] font-semibold text-xs"
:style="{ backgroundColor: currentStatusObject.color }"
@@ -15,14 +14,14 @@
/>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="statusOption in allStatusOptions"
:key="statusOption.id"
<SelectItem
v-for="statusOption in allStatusOptions"
:key="statusOption.id"
:value="statusOption.id"
>
<div class="flex items-center gap-2">
<!-- Color indicator -->
<div
<div
v-if="statusOption.color"
class="w-3 h-3 rounded-full border border-border"
:style="{ backgroundColor: statusOption.color }"
@@ -32,10 +31,116 @@
</SelectItem>
</SelectContent>
</Select>
<!-- User Assignment Button -->
<div @click.stop>
<Popover>
<PopoverTrigger as-child>
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0 hover:bg-accent relative"
:disabled="isUpdating"
@click.stop="ensureMembersLoaded"
>
<Avatar class="h-4 w-4" v-if="assignedUser">
<AvatarImage :src="getAvatarUrl(assignedUser?.user_avatar_url, assignedUser?.user_first_name, assignedUser?.user_last_name)" />
<AvatarFallback class="text-[8px]">{{ getUserInitials(assignedUser) }}</AvatarFallback>
</Avatar>
<User class="h-3 w-3" v-else />
</Button>
</PopoverTrigger>
<PopoverContent class="w-64 p-2" align="start" side="bottom" :side-offset="4">
<div class="space-y-2">
<div class="px-2 py-1.5 text-sm font-semibold">Assign Task</div>
<!-- Current Assignment Display with X button -->
<div v-if="assignedUser" class="px-2 py-2 bg-muted rounded-md flex items-center gap-2">
<Avatar class="h-8 w-8">
<AvatarImage :src="getAvatarUrl(assignedUser?.user_avatar_url, assignedUser?.user_first_name, assignedUser?.user_last_name)" />
<AvatarFallback class="text-[8px]">{{ getUserInitials(assignedUser) }}</AvatarFallback>
</Avatar>
<div class="flex flex-col flex-1 min-w-0">
<span class="text-xs font-medium truncate">{{ assignedUser.user_first_name }} {{ assignedUser.user_last_name }}</span>
<span class="text-[10px] text-muted-foreground">Current</span>
</div>
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0 hover:bg-destructive hover:text-destructive-foreground"
@click.stop="handleAssignUser(null)"
:disabled="isAssigning"
>
<X class="h-3 w-3" />
</Button>
</div>
<!-- Search Input -->
<div class="relative">
<Search class="absolute left-2 top-1/2 transform -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
v-model="searchQuery"
placeholder="Search members..."
class="pl-7 h-8 text-xs"
/>
</div>
<!-- Loading state -->
<div v-if="isLoadingMembers" class="flex items-center justify-center py-4">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
<span class="ml-2 text-sm">Loading members...</span>
</div>
<!-- Error state -->
<div v-else-if="projectMembers.length === 0" class="px-2 py-4 text-sm text-muted-foreground text-center">
No project members found
<Button
variant="outline"
size="sm"
class="mt-2"
@click="loadProjectMembers"
>
Retry
</Button>
</div>
<!-- Content when members are loaded -->
<template v-else>
<!-- Project members list -->
<div class="max-h-64 overflow-y-auto">
<div v-if="filteredProjectMembers.length === 0" class="py-2 text-xs text-muted-foreground text-center">
No matching members found
</div>
<Button
v-else
v-for="member in filteredProjectMembers"
:key="member.user_id"
variant="ghost"
size="sm"
class="w-full justify-start h-10"
@click="handleAssignUser(member.user_id)"
:disabled="isAssigning"
>
<Avatar class="h-8 w-8 mr-2">
<AvatarImage :src="getAvatarUrl(member.user_avatar_url, member.user_first_name, member.user_last_name)" />
<AvatarFallback class="text-[8px]">{{ getUserInitials(member) }}</AvatarFallback>
</Avatar>
<div class="flex flex-col items-start flex-1 min-w-0">
<span class="text-xs truncate">{{ member.user_first_name }} {{ member.user_last_name }}</span>
<span class="text-[10px] text-muted-foreground" v-if="member.department_role">{{ formatDepartmentRole(member.department_role) }}</span>
</div>
<Check v-if="assignedUserId === member.user_id" class="h-4 w-4 text-green-500 ml-1" />
</Button>
</div>
</template>
</div>
</PopoverContent>
</Popover>
</div>
<!-- Loading indicator -->
<div
v-if="isUpdating || isLoadingStatuses"
<div
v-if="isUpdating || isLoadingStatuses"
class="absolute inset-0 bg-background/50 flex items-center justify-center rounded"
>
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
@@ -52,11 +157,22 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { User, Search, Check, X } from 'lucide-vue-next'
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import { TaskStatus } from '@/services/asset'
import { taskService } from '@/services/task'
import { projectService, type ProjectMember } from '@/services/project'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
interface StatusOption {
id: string
@@ -71,19 +187,47 @@ interface Props {
status: TaskStatus | string
taskId?: number | null
projectId: number
assignedUserId?: number | null
}
interface Emits {
(e: 'status-updated', assetId: number, taskType: string, newStatus: string): void
(e: 'assignment-updated', assetId: number, taskType: string, userId: number | null): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { getAvatarUrl } = useAvatarUrl()
// Use the shared task statuses store instead of direct API calls
const taskStatusesStore = useTaskStatusesStore()
const isUpdating = ref(false)
const isAssigning = ref(false)
const isLoadingMembers = ref(false)
const projectMembers = ref<ProjectMember[]>([])
const searchQuery = ref('')
// Filtered project members based on search query
const filteredProjectMembers = computed(() => {
if (!searchQuery.value.trim()) {
return projectMembers.value
}
const query = searchQuery.value.toLowerCase().trim()
return projectMembers.value.filter(member => {
const fullName = `${member.user_first_name || ''} ${member.user_last_name || ''}`.toLowerCase()
const departmentRole = member.department_role?.toLowerCase() || ''
return fullName.includes(query) || departmentRole.includes(query)
})
})
// Get assigned user info
const assignedUserId = computed(() => props.assignedUserId)
const assignedUser = computed(() => {
if (!assignedUserId.value) return null
return projectMembers.value.find(member => member.user_id === assignedUserId.value) || null
})
// Get loading state from store
const isLoadingStatuses = computed(() => taskStatusesStore.isLoading(props.projectId))
@@ -139,10 +283,22 @@ const formatStatusName = (status: string): string => {
}
}
// Format department role for display
const formatDepartmentRole = (role: string): string => {
return role.charAt(0).toUpperCase() + role.slice(1)
}
// Get user initials
const getUserInitials = (member: ProjectMember): string => {
const first = member.user_first_name?.charAt(0) || ''
const last = member.user_last_name?.charAt(0) || ''
return (first + last).toUpperCase()
}
// Fetch custom statuses for the project using store
const fetchStatuses = async () => {
if (!props.projectId) return
try {
await taskStatusesStore.fetchProjectStatuses(props.projectId)
} catch (error) {
@@ -150,6 +306,62 @@ const fetchStatuses = async () => {
}
}
// Load project members
const loadProjectMembers = async () => {
if (projectMembers.value.length > 0) return // Already loaded
isLoadingMembers.value = true
try {
projectMembers.value = await projectService.getProjectMembers(props.projectId)
} catch (error) {
console.error('Failed to load project members:', error)
} finally {
isLoadingMembers.value = false
}
}
// Ensure members are loaded when popover is about to open
const ensureMembersLoaded = () => {
if (projectMembers.value.length === 0) {
loadProjectMembers()
}
}
const handleAssignUser = async (userId: number | null) => {
isAssigning.value = true
try {
let taskId = props.taskId
// If no task exists, create one first
if (!taskId) {
const newTask = await taskService.createAssetTask(props.assetId, props.taskType)
taskId = newTask.task_id
}
// Assign or unassign the task
if (taskId) {
if (userId) {
// Use the assignment endpoint for assigning to a user
await taskService.assignTask(taskId, userId)
} else {
// Use the update endpoint for unassignment (set assigned_user_id to 0)
await taskService.updateTask(taskId, { assigned_user_id: 0 })
}
emit('assignment-updated', props.assetId, props.taskType, userId)
}
// Close popover by simulating click outside after assignment
setTimeout(() => {
document.querySelector('[data-state="open"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
}, 100)
} catch (error) {
console.error('Failed to assign task:', error)
} finally {
isAssigning.value = false
}
}
const handleStatusChange = async (newStatusId: any) => {
if (!newStatusId || newStatusId === currentStatusId.value) return
@@ -184,10 +396,14 @@ const handleStatusChange = async (newStatusId: any) => {
// Fetch statuses on mount
onMounted(() => {
fetchStatuses()
// Preload project members to ensure they're available when needed
loadProjectMembers()
})
// Refetch statuses when projectId changes
watch(() => props.projectId, () => {
fetchStatuses()
// Clear project members when project changes
projectMembers.value = []
})
</script>
+9 -2
View File
@@ -55,6 +55,7 @@ export interface AssetColumnMeta {
onDelete: (asset: Asset) => void
onViewTasks: (asset: Asset) => void
onTaskStatusUpdated: (assetId: number, taskType: string, newStatus: TaskStatus) => void
onTaskAssignmentUpdated?: (assetId: number, taskType: string, userId: number | null) => void
onBulkTaskStatusChange?: (taskType: string, status: TaskStatus) => void
getSelectedCount?: () => number
getAllStatusOptions?: () => Array<{ id: string; name: string; color?: string; is_system?: boolean }>
@@ -225,8 +226,10 @@ export const createAssetColumns = (
cell: ({ row }) => {
const asset = row.original
const status = asset.task_status?.[taskType] || TaskStatus.NOT_STARTED
const taskId = asset.task_details?.find(t => t.task_type === taskType)?.task_id
const taskDetail = asset.task_details?.find(t => t.task_type === taskType)
const taskId = taskDetail?.task_id
const assignedUserId = taskDetail?.assigned_user_id || null
return h(EditableTaskStatus, {
key: `${asset.id}-${taskType}`, // Add stable key to prevent unnecessary re-renders
assetId: asset.id,
@@ -234,9 +237,13 @@ export const createAssetColumns = (
status,
taskId,
projectId: meta.projectId,
assignedUserId,
onStatusUpdated: (assetId: number, taskType: string, newStatus: TaskStatus) => {
meta.onTaskStatusUpdated(assetId, taskType, newStatus)
},
onAssignmentUpdated: (assetId: number, taskType: string, userId: number | null) => {
meta.onTaskAssignmentUpdated?.(assetId, taskType, userId)
},
})
},
enableSorting: true,
@@ -41,14 +41,7 @@
>
<div class="flex items-center gap-3">
<Avatar class="h-8 w-8">
<AvatarImage
v-if="member.user_id"
:src="getAvatarUrl(member.avatar_url, member.user_first_name, member.user_last_name)"
/>
<AvatarImage
v-else
:src="`https://ui-avatars.com/api/?name=${member.user_first_name} ${member.user_last_name}`"
/>
<AvatarImage :src="getAvatarUrl(member.user_avatar_url, member.user_first_name, member.user_last_name)" />
<AvatarFallback>{{ getUserInitials(member) }}</AvatarFallback>
</Avatar>
<div>
@@ -65,21 +58,21 @@
<!-- Department Role -->
<div class="flex items-center gap-2">
<Label class="text-sm">Department:</Label>
<select
:value="member.department_role || 'none'"
@change="(event) => updateMemberRole(member.id, (event.target as HTMLSelectElement).value === 'none' ? null : (event.target as HTMLSelectElement).value)"
<Select
:model-value="member.department_role || 'none'"
@update:model-value="(value) => updateMemberRole(member, value === 'none' ? null : (value as string))"
:disabled="isUpdatingMember === member.id"
class="flex h-8 w-32 rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="none">None</option>
<option value="layout">Layout</option>
<option value="animation">Animation</option>
<option value="lighting">Lighting</option>
<option value="composite">Composite</option>
<option value="modeling">Modeling</option>
<option value="rigging">Rigging</option>
<option value="surfacing">Surfacing</option>
</select>
<SelectTrigger class="w-32 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem v-for="role in departmentRoles" :key="role.value" :value="role.value">
{{ role.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Joined Date -->
@@ -89,7 +82,7 @@
<!-- Actions -->
<DropdownMenu>
<DropdownMenuTrigger asChild>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
<MoreHorizontal class="h-4 w-4" />
</Button>
@@ -118,119 +111,84 @@
</Button>
</div>
<!-- Add Member Modal -->
<div v-if="showAddMemberDialog" class="fixed inset-0 z-50 flex items-center justify-center">
<!-- Backdrop -->
<div
class="fixed inset-0 bg-black/50"
@click="closeAddDialog"
></div>
<!-- Modal Content -->
<div class="relative bg-background rounded-lg shadow-lg w-full max-w-md mx-4 p-6">
<div class="mb-4">
<h3 class="text-lg font-semibold">Add Team Member</h3>
<p class="text-sm text-muted-foreground">
<!-- Add Member Dialog -->
<Dialog v-model:open="showAddMemberDialog">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Team Member</DialogTitle>
<DialogDescription>
Add a user to this project and assign their department role.
</p>
</div>
</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<!-- User Selection -->
<div class="space-y-2">
<Label for="user">User</Label>
<select
v-model="newMember.userId"
:disabled="isAddingMember"
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium 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"
>
<option value="">Select a user</option>
<option
v-for="user in (availableUsers || [])"
:key="user.id"
:value="user.id.toString()"
>
{{ user.first_name }} {{ user.last_name }} ({{ user.email }})
</option>
</select>
<Label>User</Label>
<Select v-model="newMember.userId" :disabled="isAddingMember">
<SelectTrigger>
<SelectValue placeholder="Select a user" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="user in availableUsers"
:key="user.id"
:value="user.id.toString()"
>
{{ user.first_name }} {{ user.last_name }} ({{ user.email }})
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Department Role -->
<div class="space-y-2">
<Label for="department">Department Role (Optional)</Label>
<select
v-model="newMember.departmentRole"
:disabled="isAddingMember"
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium 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"
>
<option value="">None</option>
<option value="layout">Layout</option>
<option value="animation">Animation</option>
<option value="lighting">Lighting</option>
<option value="composite">Composite</option>
<option value="modeling">Modeling</option>
<option value="rigging">Rigging</option>
<option value="surfacing">Surfacing</option>
</select>
<Label>Department Role (Optional)</Label>
<Select v-model="newMember.departmentRole" :disabled="isAddingMember">
<SelectTrigger>
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem v-for="role in departmentRoles" :key="role.value" :value="role.value">
{{ role.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="flex justify-end gap-3 mt-6">
<Button
variant="outline"
@click="closeAddDialog"
:disabled="isAddingMember"
>
<DialogFooter>
<Button variant="outline" @click="closeAddDialog" :disabled="isAddingMember">
Cancel
</Button>
<Button
@click="addMember"
:disabled="!newMember.userId || isAddingMember"
>
<Button @click="addMember" :disabled="!newMember.userId || isAddingMember">
<div v-if="isAddingMember" class="flex items-center gap-2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
<span>Adding...</span>
</div>
<span v-else>Add Member</span>
</Button>
</div>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Remove Member Confirmation -->
<div v-if="showRemoveDialog" class="fixed inset-0 z-50 flex items-center justify-center">
<!-- Backdrop -->
<div
class="fixed inset-0 bg-black/50"
@click="showRemoveDialog = false"
></div>
<!-- Modal Content -->
<div class="relative bg-background rounded-lg shadow-lg w-full max-w-md mx-4 p-6">
<div class="mb-4">
<h3 class="text-lg font-semibold">Remove Team Member</h3>
<p class="text-sm text-muted-foreground">
<AlertDialog v-model:open="showRemoveDialog">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Team Member</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove "{{ memberToRemove?.user_first_name }} {{ memberToRemove?.user_last_name }}" from this project?
This action cannot be undone.
</p>
</div>
<div class="flex justify-end gap-3">
<Button
variant="outline"
@click="showRemoveDialog = false"
>
Cancel
</Button>
<Button
@click="confirmRemoveMember"
variant="destructive"
>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction @click="confirmRemoveMember" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Remove Member
</Button>
</div>
</div>
</div>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
@@ -242,14 +200,39 @@ import {
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
import { useToast } from '@/components/ui/toast/use-toast'
import { projectService, type ProjectMember } from '@/services/project'
import { userService } from '@/services/user'
import type { User } from '@/types/auth'
@@ -259,10 +242,26 @@ interface Props {
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
}>()
const { toast } = useToast()
const { getAvatarUrl } = useAvatarUrl()
const departmentRoles = [
{ value: 'layout', label: 'Layout' },
{ value: 'animation', label: 'Animation' },
{ value: 'lighting', label: 'Lighting' },
{ value: 'composite', label: 'Composite' },
{ value: 'modeling', label: 'Modeling' },
{ value: 'rigging', label: 'Rigging' },
{ value: 'surfacing', label: 'Surfacing' },
]
// State
const members = ref<ProjectMember[]>([])
const availableUsers = ref<User[]>([])
const allUsers = ref<User[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const isUpdatingMember = ref<number | null>(null)
@@ -273,7 +272,13 @@ const memberToRemove = ref<ProjectMember | null>(null)
const newMember = ref({
userId: '',
departmentRole: ''
departmentRole: 'none'
})
// Computed
const availableUsers = computed(() => {
const memberUserIds = new Set(members.value.map(m => m.user_id))
return allUsers.value.filter(user => user.is_approved && !memberUserIds.has(user.id))
})
// Methods
@@ -284,40 +289,50 @@ const loadMembers = async () => {
members.value = await projectService.getProjectMembers(props.projectId)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load members'
toast({
title: 'Error',
description: 'Failed to load project members',
variant: 'destructive',
})
} finally {
isLoading.value = false
}
}
const loadAvailableUsers = async () => {
const loadUsers = async () => {
try {
const allUsers = await userService.getAllUsers()
// Filter out users who are already members
const memberUserIds = new Set(members.value.map(m => m.user_id))
availableUsers.value = allUsers.filter(user =>
user.is_approved && !memberUserIds.has(user.id)
)
allUsers.value = await userService.getUsers()
} catch (err) {
console.error('Failed to load users:', err)
error.value = 'Failed to load available users'
availableUsers.value = [] // Ensure it's always an array
toast({
title: 'Error',
description: 'Failed to load users',
variant: 'destructive',
})
}
}
const updateMemberRole = async (memberId: number, departmentRole: string | null) => {
const updateMemberRole = async (member: ProjectMember, departmentRole: string | null) => {
try {
isUpdatingMember.value = memberId
await projectService.updateProjectMember(props.projectId, memberId, {
isUpdatingMember.value = member.id
const updatedMember = await projectService.updateProjectMember(props.projectId, member.id, {
department_role: departmentRole as any
})
// Update local state
const member = members.value.find(m => m.id === memberId)
if (member) {
member.department_role = departmentRole as any
const index = members.value.findIndex(m => m.id === member.id)
if (index !== -1) {
members.value[index] = updatedMember
}
toast({
title: 'Role updated',
description: 'Member department role has been updated',
})
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to update member role'
toast({
title: 'Error',
description: 'Failed to update member role',
variant: 'destructive',
})
} finally {
isUpdatingMember.value = null
}
@@ -330,18 +345,24 @@ const addMember = async () => {
isAddingMember.value = true
const memberData = {
user_id: parseInt(newMember.value.userId),
department_role: newMember.value.departmentRole || undefined
department_role: newMember.value.departmentRole === 'none' ? undefined : newMember.value.departmentRole
}
const addedMember = await projectService.addProjectMember(props.projectId, memberData)
members.value.push(addedMember)
closeAddDialog()
// Refresh available users
await loadAvailableUsers()
toast({
title: 'Member added',
description: 'Team member has been added to the project',
})
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to add member'
toast({
title: 'Error',
description: err instanceof Error ? err.message : 'Failed to add member',
variant: 'destructive',
})
} finally {
isAddingMember.value = false
}
@@ -357,20 +378,25 @@ const confirmRemoveMember = async () => {
try {
await projectService.removeProjectMember(props.projectId, memberToRemove.value.id)
// Remove from local state
const index = members.value.findIndex(m => m.id === memberToRemove.value!.id)
if (index !== -1) {
members.value.splice(index, 1)
}
toast({
title: 'Member removed',
description: 'Team member has been removed from the project',
})
} catch (err) {
toast({
title: 'Error',
description: 'Failed to remove member',
variant: 'destructive',
})
} finally {
showRemoveDialog.value = false
memberToRemove.value = null
// Refresh available users
await loadAvailableUsers()
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to remove member'
}
}
@@ -378,10 +404,6 @@ const getUserInitials = (member: ProjectMember) => {
return `${member.user_first_name.charAt(0)}${member.user_last_name.charAt(0)}`.toUpperCase()
}
import { useAvatarUrl } from '@/composables/useAvatarUrl'
const { getAvatarUrl, getInitialsAvatarUrl } = useAvatarUrl()
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
@@ -391,25 +413,17 @@ const formatDate = (dateString: string) => {
}
const openAddDialog = async () => {
try {
await loadAvailableUsers()
showAddMemberDialog.value = true
} catch (err) {
console.error('Failed to open add dialog:', err)
error.value = 'Failed to load user list'
}
await loadUsers()
showAddMemberDialog.value = true
}
const closeAddDialog = () => {
showAddMemberDialog.value = false
// Reset form
newMember.value = { userId: '', departmentRole: '' }
newMember.value = { userId: '', departmentRole: 'none' }
}
// Lifecycle
onMounted(() => {
loadMembers()
})
</script>
</script>
+34 -12
View File
@@ -169,17 +169,26 @@
leave-from-class="translate-x-0"
leave-to-class="translate-x-full"
>
<div
v-if="showPanel && selectedShot"
<div
v-if="showPanel && selectedShot"
class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
>
<TaskDetailPanel
v-if="selectedTaskId"
:key="selectedTaskId"
:task-id="selectedTaskId"
:initial-tab="selectedTaskTab"
@close="selectedTaskId = null"
@task-updated="loadShots"
/>
<ShotDetailPanel
v-else
:project-id="projectId"
:shot-id="selectedShot.id"
:initial-shot="selectedShot"
:all-task-types="allTaskTypes"
@edit="editShot"
@delete="deleteShot"
@create-task="handleCreateTask"
@select-task="handleSelectTask"
@close="closeDetailPanel"
/>
@@ -190,14 +199,22 @@
<!-- Mobile Detail Sheet -->
<Sheet v-model:open="showMobileDetail">
<SheetContent side="right" class="w-full sm:max-w-md p-0">
<TaskDetailPanel
v-if="selectedTaskId"
:key="selectedTaskId"
:task-id="selectedTaskId"
:initial-tab="selectedTaskTab"
@close="selectedTaskId = null"
@task-updated="loadShots"
/>
<ShotDetailPanel
v-if="selectedShot"
v-else-if="selectedShot"
:project-id="projectId"
:shot-id="selectedShot.id"
:initial-shot="selectedShot"
:all-task-types="allTaskTypes"
@edit="editShot"
@delete="deleteShot"
@create-task="handleCreateTask"
@select-task="handleSelectTask"
/>
</SheetContent>
@@ -316,6 +333,7 @@ import ShotCard from './ShotCard.vue'
import ShotForm from './ShotForm.vue'
import BulkShotForm from './BulkShotForm.vue'
import ShotDetailPanel from './ShotDetailPanel.vue'
import TaskDetailPanel from '@/components/task/TaskDetailPanel.vue'
import ShotsDataTable from './ShotsDataTable.vue'
import ShotTableToolbar from './ShotTableToolbar.vue'
import { createShotColumns, type ShotColumnMeta } from './columns'
@@ -845,15 +863,19 @@ const clearSearch = () => {
loadShots()
}
const handleCreateTask = () => {
// TODO: Navigate to task creation for this shot
console.log('Create task for shot:', selectedShot.value?.name)
const selectedTaskId = ref<number | null>(null)
const selectedTaskTab = ref<string>('infos')
const handleSelectTask = (task: { id: number }, tab?: string) => {
selectedTaskId.value = task.id
selectedTaskTab.value = tab || 'infos'
}
const handleSelectTask = (task: any) => {
// TODO: Navigate to task detail view
console.log('View task:', task.name)
}
// Reset the task sub-panel whenever the shot selection changes (including close)
watch(selectedShot, () => {
selectedTaskId.value = null
selectedTaskTab.value = 'infos'
})
const formatStatus = (status: ShotStatus) => {
return status.split('_').map(word =>
+125 -34
View File
@@ -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)
@@ -26,7 +26,7 @@
</div>
<!-- Tabbed Content -->
<Tabs default-value="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) -->
<TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-4 rounded-none border-b">
<TabsTrigger value="infos">Infos</TabsTrigger>
@@ -315,6 +315,7 @@ import { useToast } from '@/components/ui/toast/use-toast'
const props = defineProps<{
taskId: number
initialTab?: string
}>()
const emit = defineEmits<{
@@ -134,6 +134,11 @@ const columns = createColumns({
onBulkStatusChange: handleBulkStatusChange,
onStatusUpdated: handleStatusUpdated,
getSelectedCount: () => Object.keys(rowSelection.value).filter(key => rowSelection.value[key]).length,
// Both actions open the task detail panel (which already has a working Reassign flow).
// row-click is a no-op in TaskBrowser.vue (single click is reserved for selection) - only
// row-double-click actually opens the panel, so reuse that instead.
onViewDetails: (task: Task) => emit('row-double-click', task),
onReassign: (task: Task) => emit('row-double-click', task),
})
// TanStack Table configuration
+88 -1
View File
@@ -1,6 +1,6 @@
import { h, ref } from 'vue'
import type { ColumnDef } from '@tanstack/vue-table'
import { ArrowUpDown, Film, Package, ChevronDown } from 'lucide-vue-next'
import { ArrowUpDown, Film, Package, ChevronDown, MoreHorizontal, Eye, UserCog } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
@@ -9,6 +9,12 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import TaskStatusBadge from '@/components/asset/TaskStatusBadge.vue'
import EditableTaskStatus from '@/components/task/EditableTaskStatus.vue'
import { type Task } from '@/services/task'
@@ -27,6 +33,8 @@ interface ColumnCallbacks {
onBulkStatusChange?: (status: TaskStatus) => void
onStatusUpdated?: (taskId: number, newStatus: TaskStatus) => void
getSelectedCount?: () => number
onViewDetails?: (task: Task) => void
onReassign?: (task: Task) => void
}
export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] => {
@@ -308,5 +316,84 @@ export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] =>
return h('div', { class: 'text-sm text-muted-foreground' }, formatDate(row.getValue('created_at')))
},
},
{
id: 'actions',
cell: ({ row }) => {
const task = row.original
return h(
DropdownMenu,
{},
{
default: () => [
h(
DropdownMenuTrigger,
{ asChild: true },
{
default: () =>
h(
Button,
{
variant: 'ghost',
size: 'sm',
class: 'h-8 w-8 p-0',
onMouseDown: (e: Event) => {
e.stopPropagation()
},
onClick: (e: Event) => {
e.stopPropagation()
e.preventDefault()
},
},
{
default: () => h(MoreHorizontal, { class: 'h-4 w-4' }),
}
),
}
),
h(
DropdownMenuContent,
{ align: 'end' },
{
default: () => [
h(
DropdownMenuItem,
{
onClick: (e: Event) => {
e.stopPropagation()
e.preventDefault()
callbacks?.onViewDetails?.(task)
},
},
{
default: () => [
h(Eye, { class: 'h-4 w-4 mr-2' }),
'View Details',
],
}
),
h(
DropdownMenuItem,
{
onClick: (e: Event) => {
e.stopPropagation()
e.preventDefault()
callbacks?.onReassign?.(task)
},
},
{
default: () => [
h(UserCog, { class: 'h-4 w-4 mr-2' }),
'Reassign',
],
}
),
],
}
),
],
}
)
},
},
]
}
+3 -2
View File
@@ -147,8 +147,9 @@ const manageTechnicalSpecs = () => {
}
const manageMembers = () => {
// TODO: Implement member management
console.log('Manage members')
if (projectId.value) {
router.push(`/projects/${projectId.value}/settings`)
}
}
const getStatusVariant = (status: string) => {
+3 -3
View File
@@ -346,9 +346,9 @@
</DialogDescription>
</DialogHeader>
<ProjectMembersManager
<ProjectMemberManagement
v-if="selectedProject && showMembersDialog"
:project="selectedProject"
:project-id="selectedProject.id"
@close="showMembersDialog = false"
/>
</DialogContent>
@@ -407,7 +407,7 @@ import {
import { useToast } from '@/components/ui/toast/use-toast'
import { useProjectsStore } from '@/stores/projects'
import { useAuthStore } from '@/stores/auth'
import ProjectMembersManager from '@/components/project/ProjectMembersManager.vue'
import ProjectMemberManagement from '@/components/project/ProjectMemberManagement.vue'
import type { Project } from '@/stores/projects'
import { apiClient } from '@/services/api'
+12 -8
View File
@@ -32,15 +32,19 @@ Derived from `frontend_report.md`. Checklist form for tracking progress — chec
- Fixed two pre-existing backend bugs that 500'd once real data was wired up: `backend/routers/reviews.py` (`joinedload("reviewer")` string → `joinedload(Review.reviewer)`) and `backend/utils/activity.py` (wrong `models.submission` import path → `models.task`).
- Fixed a pre-existing bug shared by both `ShotDeleteConfirmDialog.vue` and `AssetDeleteConfirmDialog.vue`: their `watch(() => props.open, ...)` lacked `{ immediate: true }`, so the impact-summary section never rendered since the dialog only mounts once already open. Deletion itself worked fine either way — this was purely cosmetic, but it was the whole point of the "impact summary" safety feature.
## Phase 2 — Close feature gaps (asset vs. shot parity)
## Phase 2 — Close feature gaps (asset vs. shot parity) ✅ (done)
- [ ] Add user-assignment popover to `components/asset/EditableTaskStatus.vue` (shot version at `components/shot/EditableTaskStatus.vue` is the reference)
- [ ] Add column-locking toggle to `components/asset/AssetTableToolbar.vue` (shot version has `Lock`/`Unlock` toggle)
- [ ] Add row-actions ("…") menu to `components/task/columns.ts` (shot/asset already have one)
- [ ] Implement the six stubbed asset detail-panel actions in `components/asset/AssetBrowser.vue:571-574, 680-703`: create task, select task, create note, upload reference, publish version — or hide the affordances until built
- [ ] Implement shot detail-panel task stubs in `components/shot/ShotBrowser.vue:848-856`: create task, select task
- [ ] Consolidate `components/project/ProjectMembersManager.vue` and `components/project/ProjectMemberManagement.vue` into one component
- [ ] Wire the consolidated member-management component into `ProjectDetailView.vue:149-151` ("Manage Members" is currently a no-op)
- [x] Add user-assignment popover to `components/asset/EditableTaskStatus.vue` — ported from the shot version; wired through `asset/columns.ts` and `AssetBrowser.vue`
- [x] Add column-locking toggle to `components/asset/AssetTableToolbar.vue` — two-pane frozen-column layout ported into `AssetsDataTable.vue` (adapted for asset's page-scroll layout, no vertical scroll-sync needed unlike shot's bounded-height container)
- [x] Add row-actions ("…") menu to `components/task/columns.ts` — "View Details" + "Reassign" (no Delete/Edit — no backend `deleteTask`/task-edit-form exists)
- [x] Implement the asset detail-panel actions in `AssetBrowser.vue`/`AssetDetailPanel.vue`: create task (task-type picker), select task (opens `TaskDetailPanel` in-place), create note/upload reference/publish version (task picker → deep-links into `TaskDetailPanel`'s Notes/Attachments/Submissions tabs via new `initialTab` prop)
- [x] Implement the same for `ShotBrowser.vue`/`ShotDetailPanel.vue` (also fixed the pre-existing dead "Add Note"/"Upload Reference" buttons there)
- [x] Consolidate `components/project/ProjectMembersManager.vue` and `components/project/ProjectMemberManagement.vue` into one component (kept `ProjectMemberManagement.vue`, deleted the other) — combined remove-confirmation + approved-user filtering + real avatars from one with toast feedback + shared `Select`/`Dialog` UI-kit from the other
- [x] Wire the consolidated member-management component into `ProjectDetailView.vue:149-151` — navigates to the project Settings "Team" tab (mirrors the existing `manageTechnicalSpecs` pattern) rather than a duplicate dialog
**Bugs found and fixed during verification (approved mid-implementation):**
- `GET /assets/{id}` never returned `task_details` (schema didn't even declare the field) — unlike `GET /shots/{id}`, which already did. Broke the asset detail panel's task list and, transitively, every new create-task/note/reference/version feature. Fixed in `backend/schemas/asset.py` + `backend/routers/assets.py`.
- `TaskBrowser.vue`'s `handleRowClick` is a no-op by design (single click reserved for selection) — the new row-actions menu's "View Details"/"Reassign" needed to emit `row-double-click` instead, which is what actually opens the panel.
## Phase 3 — Unify controls