From cd3628a25534e1e28d78164f59b65c59de6c1b97 Mon Sep 17 00:00:00 2001 From: indigo Date: Fri, 17 Jul 2026 08:45:06 +0800 Subject: [PATCH] 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 --- .../src/components/asset/AssetBrowser.vue | 100 +++-- .../src/components/asset/AssetDetailPanel.vue | 138 ++++++- .../components/asset/AssetTableToolbar.vue | 20 +- .../src/components/asset/AssetsDataTable.vue | 134 ++++++- .../components/asset/EditableTaskStatus.vue | 242 +++++++++++- frontend/src/components/asset/columns.ts | 11 +- .../project/ProjectMemberManagement.vue | 348 +++++++++--------- frontend/src/components/shot/ShotBrowser.vue | 46 ++- .../src/components/shot/ShotDetailPanel.vue | 159 ++++++-- .../src/components/task/TaskDetailPanel.vue | 3 +- .../src/components/task/TasksDataTable.vue | 5 + frontend/src/components/task/columns.ts | 89 ++++- frontend/src/views/ProjectDetailView.vue | 5 +- frontend/src/views/ProjectsView.vue | 6 +- frontend_tasks.md | 20 +- 15 files changed, 1034 insertions(+), 292 deletions(-) diff --git a/frontend/src/components/asset/AssetBrowser.vue b/frontend/src/components/asset/AssetBrowser.vue index 58caadf..3f97478 100644 --- a/frontend/src/components/asset/AssetBrowser.vue +++ b/frontend/src/components/asset/AssetBrowser.vue @@ -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" />
@@ -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" > - @@ -201,18 +210,23 @@ + @@ -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(null); +const selectedTaskTab = ref('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 () => { diff --git a/frontend/src/components/asset/AssetDetailPanel.vue b/frontend/src/components/asset/AssetDetailPanel.vue index 18d96e1..fd8cd37 100644 --- a/frontend/src/components/asset/AssetDetailPanel.vue +++ b/frontend/src/components/asset/AssetDetailPanel.vue @@ -117,8 +117,111 @@
-

Tasks

- +
+

Tasks

+
+ + + + + + +
Add Task
+
+ All task types already added +
+
+ +
+
+
+ + + + + + + +
Add note to task
+
+ +
+
+
+ + + + + + + +
Upload reference to task
+
+ +
+
+
+ + + + + + + +
Publish version for task
+
+ +
+
+
+
+
+
@@ -182,17 +285,19 @@ \ No newline at end of file diff --git a/frontend/src/components/asset/columns.ts b/frontend/src/components/asset/columns.ts index ca5cc78..7851041 100644 --- a/frontend/src/components/asset/columns.ts +++ b/frontend/src/components/asset/columns.ts @@ -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, diff --git a/frontend/src/components/project/ProjectMemberManagement.vue b/frontend/src/components/project/ProjectMemberManagement.vue index df9f5e0..25b6ad9 100644 --- a/frontend/src/components/project/ProjectMemberManagement.vue +++ b/frontend/src/components/project/ProjectMemberManagement.vue @@ -41,14 +41,7 @@ >
- - + {{ getUserInitials(member) }}
@@ -65,21 +58,21 @@
- + + + + + None + + {{ role.label }} + + +
@@ -89,7 +82,7 @@ - + @@ -118,119 +111,84 @@
- -
- -
- - -
-
-

Add Team Member

-

+ +

+ + + Add Team Member + Add a user to this project and assign their department role. -

-
- + + +
-
- - + +
-
- - + +
-
- - -
-
-
+ + + -
- -
- - -
-
-

Remove Team Member

-

+ + + + Remove Team Member + Are you sure you want to remove "{{ memberToRemove?.user_first_name }} {{ memberToRemove?.user_last_name }}" from this project? This action cannot be undone. -

-
- -
- - -
-
-
+ + + +
@@ -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() +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([]) -const availableUsers = ref([]) +const allUsers = ref([]) const isLoading = ref(false) const error = ref(null) const isUpdatingMember = ref(null) @@ -273,7 +272,13 @@ const memberToRemove = ref(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() }) - \ No newline at end of file + diff --git a/frontend/src/components/shot/ShotBrowser.vue b/frontend/src/components/shot/ShotBrowser.vue index 1e34366..dcf1331 100644 --- a/frontend/src/components/shot/ShotBrowser.vue +++ b/frontend/src/components/shot/ShotBrowser.vue @@ -169,17 +169,26 @@ leave-from-class="translate-x-0" leave-to-class="translate-x-full" > -