147 lines
4.8 KiB
Vue
147 lines
4.8 KiB
Vue
<template>
|
|||
|
|
<div>
|
||
|
|
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
|
|
<InputText v-model.trim="userSearch" placeholder="Search users..." class="w-full sm:w-[320px]" @input="debouncedUsers" />
|
||
|
|
<Button v-if="isOwner" label="Add Member" icon="pi pi-plus" @click="addDialog = true" />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="overflow-x-auto">
|
||
|
|
<DataTable :value="members" :loading="loading" emptyMessage="No members" class="min-w-[480px]">
|
||
|
|
<Column header="User" style="width: 50%">
|
||
|
|
<template #body="{ data }">
|
||
|
|
<div class="flex items-center gap-2">
|
||
|
|
<Avatar :label="(data.displayName || data.username).slice(0, 2).toUpperCase()"
|
||
|
|
style="background: #3b82f6; color: #fff" />
|
||
|
|
<span>{{ data.displayName }}</span>
|
||
|
|
<span class="text-slate-500 dark:text-slate-400">@{{ data.username }}</span>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
</Column>
|
||
|
|
<Column field="role" header="Role" style="width: 20%">
|
||
|
|
<template #body="{ data }">
|
||
|
|
<Tag :value="data.role" :severity="data.role === 'Owner' ? 'warn' : 'secondary'" />
|
||
|
|
</template>
|
||
|
|
</Column>
|
||
|
|
<Column header="" style="width: 10%">
|
||
|
|
<template #body="{ data }">
|
||
|
|
<Button
|
||
|
|
v-if="isOwner && data.role !== 'Owner'"
|
||
|
|
icon="pi pi-trash"
|
||
|
|
text
|
||
|
|
severity="danger"
|
||
|
|
@click="onRemove(data.userId)"
|
||
|
|
/>
|
||
|
|
</template>
|
||
|
|
</Column>
|
||
|
|
</DataTable>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Dialog v-model:visible="addDialog" header="Add Member" :modal="true" style="width: min(460px, 92vw)">
|
||
|
|
<Select
|
||
|
|
v-model="selectedUserId"
|
||
|
|
:options="userOptions"
|
||
|
|
optionLabel="label"
|
||
|
|
optionValue="value"
|
||
|
|
placeholder="Select user"
|
||
|
|
filter
|
||
|
|
class="w-full"
|
||
|
|
/>
|
||
|
|
<Select v-model="newRole" :options="roleOptions" optionLabel="label" optionValue="value"
|
||
|
|
placeholder="Role" class="mt-2 w-full" />
|
||
|
|
<template #footer>
|
||
|
|
<Button label="Cancel" severity="secondary" text @click="addDialog = false" />
|
||
|
|
<Button label="Add" :loading="adding" @click="onAdd" />
|
||
|
|
</template>
|
||
|
|
</Dialog>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<script setup lang="ts">
|
||
|
|
import { getMembers, addMember, removeMember, getUsers } from '../../services/backend'
|
||
|
|
import { errorMessage } from '../../services/api'
|
||
|
|
import { useAuthStore } from '../../stores/auth'
|
||
|
|
import type { ProjectMember } from '../../types'
|
||
|
|
|
||
|
|
const route = useRoute()
|
||
|
|
const toast = useToast()
|
||
|
|
const auth = useAuthStore()
|
||
|
|
|
||
|
|
const projectId = String(route.params.id)
|
||
|
|
const members = ref<ProjectMember[]>([])
|
||
|
|
const loading = ref(false)
|
||
|
|
const isOwner = ref(false)
|
||
|
|
|
||
|
|
const addDialog = ref(false)
|
||
|
|
const userSearch = ref('')
|
||
|
|
const users = ref<{ label: string; value: string }[]>([])
|
||
|
|
const selectedUserId = ref<string | null>(null)
|
||
|
|
const newRole = ref<'Owner' | 'Member'>('Member')
|
||
|
|
const roleOptions = [
|
||
|
|
{ label: 'Member', value: 'Member' },
|
||
|
|
{ label: 'Owner', value: 'Owner' },
|
||
|
|
]
|
||
|
|
const adding = ref(false)
|
||
|
|
|
||
|
|
const userOptions = computed(() => users.value.filter((u) => !members.value.some((m) => m.userId === u.value)))
|
||
|
|
|
||
|
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||
|
|
|
||
|
|
async function loadMembers() {
|
||
|
|
loading.value = true
|
||
|
|
try {
|
||
|
|
members.value = await getMembers(projectId)
|
||
|
|
isOwner.value = members.value.some((m) => m.userId === auth.user?.id && m.role === 'Owner')
|
||
|
|
} catch (e) {
|
||
|
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||
|
|
} finally {
|
||
|
|
loading.value = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadUsers() {
|
||
|
|
users.value = (await getUsers(userSearch.value || undefined)).map((u: { id: string; username: string; displayName: string }) => ({
|
||
|
|
label: `${u.displayName} (@${u.username})`,
|
||
|
|
value: u.id,
|
||
|
|
}))
|
||
|
|
}
|
||
|
|
|
||
|
|
function debouncedUsers() {
|
||
|
|
clearTimeout(timer)
|
||
|
|
timer = setTimeout(loadUsers, 300)
|
||
|
|
}
|
||
|
|
|
||
|
|
async function onAdd() {
|
||
|
|
if (!selectedUserId.value) {
|
||
|
|
toast.add({ severity: 'warn', summary: 'Select a user', life: 3000 })
|
||
|
|
return
|
||
|
|
}
|
||
|
|
adding.value = true
|
||
|
|
try {
|
||
|
|
await addMember(projectId, selectedUserId.value, newRole.value)
|
||
|
|
addDialog.value = false
|
||
|
|
selectedUserId.value = null
|
||
|
|
toast.add({ severity: 'success', summary: 'Member added', life: 3000 })
|
||
|
|
await loadMembers()
|
||
|
|
} catch (e) {
|
||
|
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||
|
|
} finally {
|
||
|
|
adding.value = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function onRemove(userId: string) {
|
||
|
|
try {
|
||
|
|
await removeMember(projectId, userId)
|
||
|
|
toast.add({ severity: 'success', summary: 'Member removed', life: 3000 })
|
||
|
|
await loadMembers()
|
||
|
|
} catch (e) {
|
||
|
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
onMounted(async () => {
|
||
|
|
await loadMembers()
|
||
|
|
await loadUsers()
|
||
|
|
})
|
||
|
|
</script>
|