Files
LinkDesk/frontend/src/components/project/ProjectMemberManagement.vue
T
indigo c09710f4e5 Add per-project Department management
Departments are now a customizable per-project list (standard + custom),
matching the existing custom task type/status pattern, instead of a fixed
7-value enum. They're usable directly on tasks (new field, independent of
assignee) and continue to drive team member department roles.
2026-07-22 04:03:33 +08:00

430 lines
13 KiB
Vue

<template>
<div class="space-y-4">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-medium">Team Members</h3>
<p class="text-sm text-muted-foreground">
Manage project team members and their department roles
</p>
</div>
<Button @click="openAddDialog" size="sm">
<UserPlus class="h-4 w-4 mr-2" />
Add Member
</Button>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-8">
<div class="flex items-center gap-2">
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-primary"></div>
<span class="text-muted-foreground">Loading members...</span>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-8">
<AlertCircle class="h-8 w-8 mx-auto text-destructive mb-2" />
<p class="text-sm text-muted-foreground">{{ error }}</p>
<Button @click="loadMembers" variant="outline" size="sm" class="mt-2">
<RefreshCw class="h-4 w-4 mr-2" />
Retry
</Button>
</div>
<!-- Members List -->
<div v-else-if="members.length > 0" class="space-y-2">
<div
v-for="member in members"
:key="member.id"
class="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/50 transition-colors"
>
<div class="flex items-center gap-3">
<Avatar class="h-8 w-8">
<AvatarImage :src="getAvatarUrl(member.user_avatar_url, member.user_first_name, member.user_last_name)" />
<AvatarFallback>{{ getUserInitials(member) }}</AvatarFallback>
</Avatar>
<div>
<div class="font-medium">
{{ member.user_first_name }} {{ member.user_last_name }}
</div>
<div class="text-sm text-muted-foreground">
{{ member.user_email }}
</div>
</div>
</div>
<div class="flex items-center gap-3">
<!-- Department Role -->
<div class="flex items-center gap-2">
<Label class="text-sm">Department:</Label>
<Select
:model-value="member.department_role || 'none'"
@update:model-value="(value) => updateMemberRole(member, value === 'none' ? null : (value as string))"
:disabled="isUpdatingMember === member.id"
>
<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 -->
<div class="text-sm text-muted-foreground">
Joined {{ formatDate(member.joined_at) }}
</div>
<!-- Actions -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon-sm">
<MoreHorizontal class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
@click="removeMember(member)"
class="text-destructive focus:text-destructive"
>
<UserMinus class="h-4 w-4 mr-2" />
Remove from Project
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="text-center py-8">
<Users class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
<p class="text-sm text-muted-foreground mb-4">No team members yet</p>
<Button @click="openAddDialog" size="sm">
<UserPlus class="h-4 w-4 mr-2" />
Add First Member
</Button>
</div>
<!-- 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.
</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div class="space-y-2">
<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>
<div class="space-y-2">
<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>
<DialogFooter>
<Button variant="outline" @click="closeAddDialog" :disabled="isAddingMember">
Cancel
</Button>
<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>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Remove Member Confirmation -->
<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.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction @click="confirmRemoveMember" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Remove Member
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import {
Users, UserPlus, UserMinus, MoreHorizontal, AlertCircle, RefreshCw
} from 'lucide-vue-next'
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 { useDepartmentsStore } from '@/stores/departments'
import type { User } from '@/types/auth'
interface Props {
projectId: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
}>()
const { toast } = useToast()
const { getAvatarUrl } = useAvatarUrl()
const departmentsStore = useDepartmentsStore()
const departmentRoles = computed(() => {
return departmentsStore.getAllDepartmentOptions(props.projectId).map(department => ({
value: department,
label: department.replace(/_/g, ' ').replace(/^\w/, c => c.toUpperCase())
}))
})
// State
const members = ref<ProjectMember[]>([])
const allUsers = ref<User[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const isUpdatingMember = ref<number | null>(null)
const isAddingMember = ref(false)
const showAddMemberDialog = ref(false)
const showRemoveDialog = ref(false)
const memberToRemove = ref<ProjectMember | null>(null)
const newMember = ref({
userId: '',
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
const loadMembers = async () => {
try {
isLoading.value = true
error.value = null
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 loadUsers = async () => {
try {
allUsers.value = await userService.getUsers()
} catch (err) {
toast({
title: 'Error',
description: 'Failed to load users',
variant: 'destructive',
})
}
}
const updateMemberRole = async (member: ProjectMember, departmentRole: string | null) => {
try {
isUpdatingMember.value = member.id
const updatedMember = await projectService.updateProjectMember(props.projectId, member.id, {
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) {
toast({
title: 'Error',
description: 'Failed to update member role',
variant: 'destructive',
})
} finally {
isUpdatingMember.value = null
}
}
const addMember = async () => {
if (!newMember.value.userId) return
try {
isAddingMember.value = true
const memberData = {
user_id: parseInt(newMember.value.userId),
department_role: newMember.value.departmentRole === 'none' ? undefined : newMember.value.departmentRole
}
const addedMember = await projectService.addProjectMember(props.projectId, memberData)
members.value.push(addedMember)
closeAddDialog()
toast({
title: 'Member added',
description: 'Team member has been added to the project',
})
} catch (err) {
toast({
title: 'Error',
description: err instanceof Error ? err.message : 'Failed to add member',
variant: 'destructive',
})
} finally {
isAddingMember.value = false
}
}
const removeMember = (member: ProjectMember) => {
memberToRemove.value = member
showRemoveDialog.value = true
}
const confirmRemoveMember = async () => {
if (!memberToRemove.value) return
try {
await projectService.removeProjectMember(props.projectId, memberToRemove.value.id)
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
}
}
const getUserInitials = (member: ProjectMember) => {
return `${member.user_first_name.charAt(0)}${member.user_last_name.charAt(0)}`.toUpperCase()
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
const openAddDialog = async () => {
await loadUsers()
showAddMemberDialog.value = true
}
const closeAddDialog = () => {
showAddMemberDialog.value = false
newMember.value = { userId: '', departmentRole: 'none' }
}
// Lifecycle
onMounted(() => {
loadMembers()
departmentsStore.fetchProjectDepartments(props.projectId)
})
</script>