c09710f4e5
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.
430 lines
13 KiB
Vue
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>
|