Init Repo
This commit is contained in:
@@ -0,0 +1,428 @@
|
||||
<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
|
||||
v-if="member.user_id"
|
||||
:src="getAvatarUrl(member.user_id)"
|
||||
/>
|
||||
<AvatarImage
|
||||
v-else
|
||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${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
|
||||
:value="member.department_role || 'none'"
|
||||
@change="(event) => updateMemberRole(member.id, (event.target as HTMLSelectElement).value === 'none' ? null : (event.target as HTMLSelectElement).value)"
|
||||
: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>
|
||||
</div>
|
||||
|
||||
<!-- Joined Date -->
|
||||
<div class="text-sm text-muted-foreground">
|
||||
Joined {{ formatDate(member.joined_at) }}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
|
||||
<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 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 a user to this project and assign their department role.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
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"
|
||||
>
|
||||
Remove Member
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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 {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { projectService, type ProjectMember } from '@/services/project'
|
||||
import { userService } from '@/services/user'
|
||||
import type { User } from '@/types/auth'
|
||||
|
||||
interface Props {
|
||||
projectId: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// State
|
||||
const members = ref<ProjectMember[]>([])
|
||||
const availableUsers = 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: ''
|
||||
})
|
||||
|
||||
// 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'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadAvailableUsers = 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)
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Failed to load users:', err)
|
||||
error.value = 'Failed to load available users'
|
||||
availableUsers.value = [] // Ensure it's always an array
|
||||
}
|
||||
}
|
||||
|
||||
const updateMemberRole = async (memberId: number, departmentRole: string | null) => {
|
||||
try {
|
||||
isUpdatingMember.value = memberId
|
||||
await projectService.updateProjectMember(props.projectId, memberId, {
|
||||
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
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to update member role'
|
||||
} 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 || undefined
|
||||
}
|
||||
|
||||
const addedMember = await projectService.addProjectMember(props.projectId, memberData)
|
||||
members.value.push(addedMember)
|
||||
|
||||
closeAddDialog()
|
||||
|
||||
// Refresh available users
|
||||
await loadAvailableUsers()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to add member'
|
||||
} 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)
|
||||
|
||||
// Remove from local state
|
||||
const index = members.value.findIndex(m => m.id === memberToRemove.value!.id)
|
||||
if (index !== -1) {
|
||||
members.value.splice(index, 1)
|
||||
}
|
||||
|
||||
showRemoveDialog.value = false
|
||||
memberToRemove.value = null
|
||||
|
||||
// Refresh available users
|
||||
await loadAvailableUsers()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to remove member'
|
||||
}
|
||||
}
|
||||
|
||||
const getUserInitials = (member: ProjectMember) => {
|
||||
return `${member.user_first_name.charAt(0)}${member.user_last_name.charAt(0)}`.toUpperCase()
|
||||
}
|
||||
|
||||
const getAvatarUrl = (userIdOrUrl: string | number | null | undefined) => {
|
||||
if (!userIdOrUrl) return ''
|
||||
// If it's a number, we don't have the avatar URL, so we can't display avatars
|
||||
if (typeof userIdOrUrl === 'number') {
|
||||
return ''
|
||||
}
|
||||
// If it's already a full URL, return it
|
||||
if (userIdOrUrl.startsWith('http')) return userIdOrUrl
|
||||
// Check if it looks like a user ID (numeric string)
|
||||
if (/^\d+$/.test(userIdOrUrl)) {
|
||||
return ''
|
||||
}
|
||||
// Use direct static file serving for avatar URLs
|
||||
const cleanUrl = userIdOrUrl.replace(/^backend[\/\\]/, '').replace(/\\/g, '/')
|
||||
return `/${cleanUrl}`
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
const closeAddDialog = () => {
|
||||
showAddMemberDialog.value = false
|
||||
// Reset form
|
||||
newMember.value = { userId: '', departmentRole: '' }
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadMembers()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user