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
@@ -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>