Init Repo

This commit is contained in:
2026-02-28 03:22:04 +08:00
commit de59b57ee7
883 changed files with 156857 additions and 0 deletions
@@ -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>