2026-08-11 23:17:04 +07:00
|
|
|
<template>
|
2026-08-24 08:07:23 +07:00
|
|
|
<div class="flex h-full flex-col">
|
|
|
|
|
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
|
|
|
<div class="sm:w-[320px]">
|
|
|
|
|
<IconField>
|
|
|
|
|
<InputIcon class="pi pi-search" />
|
|
|
|
|
<InputText v-model.trim="userSearch" placeholder="Search users..." class="search-input w-full" @input="debouncedUsers" />
|
|
|
|
|
</IconField>
|
|
|
|
|
</div>
|
2026-08-11 23:17:04 +07:00
|
|
|
<Button v-if="isOwner" label="Add Member" icon="pi pi-plus" @click="addDialog = true" />
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-08-24 08:07:23 +07:00
|
|
|
<div class="panel flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
|
|
|
<div class="flex min-h-0 flex-1 flex-col overflow-x-auto">
|
|
|
|
|
<AppDataTable
|
|
|
|
|
:value="members"
|
|
|
|
|
:loading="loading"
|
|
|
|
|
:lazy="true"
|
|
|
|
|
:paginator="true"
|
|
|
|
|
:rows="pageSize"
|
|
|
|
|
:totalRecords="totalCount"
|
|
|
|
|
:first="first"
|
|
|
|
|
@page="onPageChange"
|
|
|
|
|
emptyMessage="No members yet"
|
|
|
|
|
scrollable
|
|
|
|
|
scrollHeight="flex"
|
|
|
|
|
class="min-h-0 flex-1 min-w-[480px]"
|
|
|
|
|
>
|
2026-08-11 23:17:04 +07:00
|
|
|
<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()"
|
2026-08-24 08:07:23 +07:00
|
|
|
style="background: var(--primary); color: #fff" />
|
2026-08-11 23:17:04 +07:00
|
|
|
<span>{{ data.displayName }}</span>
|
2026-08-24 08:07:23 +07:00
|
|
|
<span class="muted-note">@{{ data.username }}</span>
|
2026-08-11 23:17:04 +07:00
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
</Column>
|
2026-08-24 08:07:23 +07:00
|
|
|
<Column field="role" header="Role" style="width: 16%">
|
2026-08-11 23:17:04 +07:00
|
|
|
<template #body="{ data }">
|
2026-08-24 08:07:23 +07:00
|
|
|
<Tag :value="data.role" :severity="data.role === 'Owner' ? 'primary' : 'secondary'" />
|
|
|
|
|
</template>
|
|
|
|
|
</Column>
|
|
|
|
|
<Column header="Document Access" style="width: 34%">
|
|
|
|
|
<template #body="{ data }">
|
|
|
|
|
<div v-if="data.role === 'Owner'" class="flex flex-wrap gap-1">
|
|
|
|
|
<Tag value="Full Access" severity="primary" />
|
|
|
|
|
</div>
|
|
|
|
|
<div v-else class="flex flex-wrap gap-1">
|
|
|
|
|
<Tag v-if="data.canViewDocuments" value="View" severity="secondary" />
|
|
|
|
|
<Tag v-if="data.canCreateDocuments" value="Create" severity="secondary" />
|
|
|
|
|
<Tag v-if="data.canEditDocuments" value="Edit" severity="secondary" />
|
|
|
|
|
<Tag v-if="data.canDeleteDocuments" value="Delete" severity="secondary" />
|
|
|
|
|
<Tag v-if="!data.canViewDocuments" value="No access" severity="danger" />
|
|
|
|
|
</div>
|
2026-08-11 23:17:04 +07:00
|
|
|
</template>
|
|
|
|
|
</Column>
|
|
|
|
|
<Column header="" style="width: 10%">
|
|
|
|
|
<template #body="{ data }">
|
2026-08-24 08:07:23 +07:00
|
|
|
<div v-if="isOwner && data.role !== 'Owner'" class="flex justify-end gap-1">
|
|
|
|
|
<Button
|
|
|
|
|
icon="pi pi-sliders-h"
|
|
|
|
|
text
|
|
|
|
|
severity="secondary"
|
|
|
|
|
:aria-label="`Set document permissions for ${data.displayName}`"
|
|
|
|
|
@click="openPermissions(data)"
|
|
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
icon="pi pi-trash"
|
|
|
|
|
text
|
|
|
|
|
severity="danger"
|
|
|
|
|
:aria-label="`Remove ${data.displayName}`"
|
|
|
|
|
@click="onRemove(data.userId)"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-08-11 23:17:04 +07:00
|
|
|
</template>
|
|
|
|
|
</Column>
|
2026-08-24 08:07:23 +07:00
|
|
|
</AppDataTable>
|
|
|
|
|
</div>
|
2026-08-11 23:17:04 +07:00
|
|
|
</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>
|
2026-08-24 08:07:23 +07:00
|
|
|
|
|
|
|
|
<Dialog v-model:visible="permDialog" header="Document Access" :modal="true" style="width: min(440px, 92vw)">
|
|
|
|
|
<div class="muted-note mb-3">
|
|
|
|
|
Permissions for <span class="font-medium" style="color: var(--ink)">{{ permTarget?.displayName }}</span>
|
|
|
|
|
@{{ permTarget?.username }}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex flex-col gap-3">
|
|
|
|
|
<label
|
|
|
|
|
v-for="opt in permOptions"
|
|
|
|
|
:key="opt.key"
|
|
|
|
|
class="flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2" style="border-color: var(--hairline)"
|
|
|
|
|
>
|
|
|
|
|
<Checkbox v-model="permForm[opt.key]" binary />
|
|
|
|
|
<div>
|
|
|
|
|
<div class="text-sm font-medium">{{ opt.label }}</div>
|
|
|
|
|
<div class="muted-note">{{ opt.hint }}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
<template #footer>
|
|
|
|
|
<Button label="Cancel" severity="secondary" text @click="permDialog = false" />
|
|
|
|
|
<Button label="Save" :loading="savingPerms" @click="savePermissions" />
|
|
|
|
|
</template>
|
|
|
|
|
</Dialog>
|
2026-08-11 23:17:04 +07:00
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-08-24 08:07:23 +07:00
|
|
|
import { getMembers, addMember, removeMember, getUsers, updateMemberDocumentPermissions, getMasterDataOptions } from '../../services/backend'
|
2026-08-11 23:17:04 +07:00
|
|
|
import { errorMessage } from '../../services/api'
|
|
|
|
|
import { useAuthStore } from '../../stores/auth'
|
2026-08-24 08:07:23 +07:00
|
|
|
import AppDataTable from '../../components/AppDataTable.vue'
|
2026-08-11 23:17:04 +07:00
|
|
|
import type { ProjectMember } from '../../types'
|
2026-08-24 08:07:23 +07:00
|
|
|
import type { DataTablePageEvent } from 'primevue/datatable'
|
2026-08-11 23:17:04 +07:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-08-24 08:07:23 +07:00
|
|
|
const page = ref(1)
|
|
|
|
|
const pageSize = ref(20)
|
|
|
|
|
const totalCount = ref(0)
|
|
|
|
|
const first = computed(() => (page.value - 1) * pageSize.value)
|
|
|
|
|
|
2026-08-11 23:17:04 +07:00
|
|
|
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')
|
2026-08-24 08:07:23 +07:00
|
|
|
const roleOptions = ref<{ label: string; value: string }[]>([])
|
2026-08-11 23:17:04 +07:00
|
|
|
const adding = ref(false)
|
|
|
|
|
|
2026-08-24 08:07:23 +07:00
|
|
|
const permDialog = ref(false)
|
|
|
|
|
const permTarget = ref<ProjectMember | null>(null)
|
|
|
|
|
const permForm = reactive({ canViewDocuments: false, canCreateDocuments: false, canEditDocuments: false, canDeleteDocuments: false })
|
|
|
|
|
const savingPerms = ref(false)
|
|
|
|
|
const permOptions = [
|
|
|
|
|
{ key: 'canViewDocuments' as const, label: 'View', hint: 'See documents in the project' },
|
|
|
|
|
{ key: 'canCreateDocuments' as const, label: 'Create', hint: 'Create new documents and folders' },
|
|
|
|
|
{ key: 'canEditDocuments' as const, label: 'Edit', hint: 'Edit content, rename and move' },
|
|
|
|
|
{ key: 'canDeleteDocuments' as const, label: 'Delete', hint: 'Delete documents and folders' },
|
|
|
|
|
]
|
|
|
|
|
|
2026-08-11 23:17:04 +07:00
|
|
|
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 {
|
2026-08-24 08:07:23 +07:00
|
|
|
const res = await getMembers(projectId, page.value, pageSize.value)
|
|
|
|
|
members.value = res.items
|
|
|
|
|
totalCount.value = res.totalCount
|
2026-08-11 23:17:04 +07:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 08:07:23 +07:00
|
|
|
function onPageChange(event: DataTablePageEvent) {
|
|
|
|
|
page.value = event.page + 1
|
|
|
|
|
pageSize.value = event.rows
|
|
|
|
|
void loadMembers()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-11 23:17:04 +07:00
|
|
|
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 })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 08:07:23 +07:00
|
|
|
function openPermissions(member: ProjectMember) {
|
|
|
|
|
permTarget.value = member
|
|
|
|
|
permForm.canViewDocuments = member.canViewDocuments
|
|
|
|
|
permForm.canCreateDocuments = member.canCreateDocuments
|
|
|
|
|
permForm.canEditDocuments = member.canEditDocuments
|
|
|
|
|
permForm.canDeleteDocuments = member.canDeleteDocuments
|
|
|
|
|
permDialog.value = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function savePermissions() {
|
|
|
|
|
if (!permTarget.value) return
|
|
|
|
|
savingPerms.value = true
|
|
|
|
|
try {
|
|
|
|
|
await updateMemberDocumentPermissions(projectId, permTarget.value.userId, { ...permForm })
|
|
|
|
|
permDialog.value = false
|
|
|
|
|
toast.add({ severity: 'success', summary: 'Permissions updated', life: 3000 })
|
|
|
|
|
await loadMembers()
|
|
|
|
|
} catch (e) {
|
|
|
|
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
|
|
|
|
} finally {
|
|
|
|
|
savingPerms.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-11 23:17:04 +07:00
|
|
|
onMounted(async () => {
|
2026-08-24 08:07:23 +07:00
|
|
|
roleOptions.value = await getMasterDataOptions('member_role')
|
2026-08-11 23:17:04 +07:00
|
|
|
await loadMembers()
|
|
|
|
|
await loadUsers()
|
|
|
|
|
})
|
|
|
|
|
</script>
|