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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user