Files
banu-front/src/features/admin/users/components/UsersTable.vue
T
Saghar Mohammadi 0caea18eb8 feat(admin/users): support multiple roles in user table and form
UsersTable now shows all role labels joined (not just the first role),
and its CSS badge class is based on the primary role. UserFormPage
uses a multi-select for roles instead of a single-select, and the
form validation requires at least one role. TermItem layout fix for
inactive badge alignment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-02 15:16:04 +04:30

394 lines
9.8 KiB
Vue

<template>
<div class="users-table">
<div v-for="user in users" :key="user.id" class="users-table__row">
<div class="users-table__avatar">
<img v-if="user.avatarUrl" :src="user.avatarUrl" :alt="user?.name" />
<SvgIcon v-else name="user" :size="36" color="#cbd5e1" />
</div>
<div class="users-table__info">
<p class="users-table__name">{{ user?.name || '—' }}</p>
<div class="users-table__meta">
<span v-if="user.phone">{{ user.phone }}</span>
<span v-if="user.nationalCode" class="users-table__sep">|</span>
<span v-if="user.nationalCode">کد ملی: {{ user.nationalCode }}</span>
</div>
</div>
<div class="users-table__middle">
<div class="users-table__date">
<span class="users-table__date-label">تاریخ ثبت نام:</span>
<span class="users-table__date-value">
{{ formatJalaaliDateTime(user.createdAt) || '—' }}
</span>
</div>
<button
type="button"
class="users-table__role-badge"
:class="`users-table__role-badge--${roleNames(user)[0] || 'unknown'}`"
:disabled="updateRoleMutation.isPending.value && activeUser?.id === user.id"
@click="onRoleClick($event, user)"
>
<span class="users-table__role-dot" />
<span>{{ roleLabelsText(user) || 'بدون نقش' }}</span>
<SvgIcon name="caret-down" :size="12" color="currentColor" />
</button>
<Badge v-if="user.status === 'blocked'" variant="danger" size="sm" dot>
{{ USER_STATUS.blocked }}
</Badge>
</div>
<div class="users-table__actions">
<button
type="button"
class="users-table__more"
aria-label="گزینه‌های بیشتر"
@click="onMoreClick($event, user)"
>
<span class="users-table__more-dot" />
<span class="users-table__more-dot" />
<span class="users-table__more-dot" />
</button>
<BaseButton
text="جزئیات کاربر"
custom-class="users-table__details-btn"
@click="emit('show-details', user)"
>
<template #appendIcon>
<SvgIcon name="caret-left" :size="16" color="#fff" />
</template>
</BaseButton>
</div>
</div>
<DropdownMenu
v-model="menuOpen"
:reference="menuTrigger"
:items="menuItems"
placement="bottom-end"
/>
<DropdownMenu
v-model="roleMenuOpen"
:reference="roleMenuTrigger"
:items="roleMenuItems"
placement="bottom-end"
/>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import Badge from '@/components/Badge.vue'
import { ROLES, USER_STATUS } from '@/enums'
import { useQueryClient } from '@tanstack/vue-query'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import BaseButton from '@/components/BaseButton.vue'
import DropdownMenu from '@/components/DropdownMenu.vue'
import { formatJalaaliDateTime } from '@/utils/date-utils'
import { adminUsersKeys, useUpdateAdminUserRoleMutation } from '@/services/query/admin-users'
defineProps({
users: { type: Array, required: true },
})
const queryClient = useQueryClient()
const emit = defineEmits(['edit', 'delete', 'block', 'unblock', 'show-details'])
const roleNames = (user) =>
(user.roles || []).map((r) => (typeof r === 'string' ? r : r?.name)).filter(Boolean)
const roleLabelsText = (user) => {
const names = roleNames(user)
if (names.length === 0) return ''
return names.map((name) => ROLES.find((r) => r.value === name)?.label || name).join('، ')
}
const menuOpen = ref(false)
const menuTrigger = ref(null)
const activeUser = ref(null)
const onMoreClick = (event, user) => {
const trigger = event.currentTarget
if (menuOpen.value && menuTrigger.value === trigger) {
menuOpen.value = false
return
}
activeUser.value = user
menuTrigger.value = trigger
menuOpen.value = true
}
const roleMenuOpen = ref(false)
const roleMenuTrigger = ref(null)
const updateRoleMutation = useUpdateAdminUserRoleMutation()
const invalidateUsers = () => queryClient.invalidateQueries({ queryKey: adminUsersKeys.all })
const onRoleClick = (event, user) => {
const trigger = event.currentTarget
if (roleMenuOpen.value && roleMenuTrigger.value === trigger) {
roleMenuOpen.value = false
return
}
activeUser.value = user
roleMenuTrigger.value = trigger
roleMenuOpen.value = true
}
// Toggles one role's membership without touching the user's other roles —
// access must stay additive, so this can never collapse into a single-role
// replace the way the old "switch to X" action used to.
const onToggleRole = (targetRoleName, isAssigned) => {
const user = activeUser.value
if (!user || !targetRoleName) return
const current = roleNames(user)
const nextRoles = isAssigned
? current.filter((r) => r !== targetRoleName)
: [...current, targetRoleName]
if (nextRoles.length === 0) return
updateRoleMutation.mutate(
{ id: user.id, payload: { roles: nextRoles } },
{ onSuccess: invalidateUsers }
)
}
const roleMenuItems = computed(() => {
const user = activeUser.value
if (!user) return []
const current = roleNames(user)
return ROLES.filter((r) => r.changeable).map((r) => {
const isAssigned = current.includes(r.value)
const isLastRole = isAssigned && current.length <= 1
return {
key: `role-${r.value}`,
label: isAssigned ? `حذف نقش ${r.label}` : `افزودن نقش ${r.label}`,
icon: isAssigned ? 'close' : 'check',
danger: isAssigned,
disabled: isLastRole,
onClick: () => onToggleRole(r.value, isAssigned),
}
})
})
const menuItems = computed(() => {
const user = activeUser.value
const isBlocked = user?.status === 'blocked'
return [
{
key: 'edit',
label: 'ویرایش',
icon: 'pencil',
onClick: () => emit('edit', user),
},
{
key: 'delete',
label: 'حذف',
icon: 'trash',
onClick: () => emit('delete', user),
},
isBlocked
? {
key: 'unblock',
label: 'رفع مسدودیت کاربر',
icon: 'check',
onClick: () => emit('unblock', user),
}
: {
key: 'block',
label: 'مسدود کردن کاربر',
icon: 'close',
danger: true,
onClick: () => emit('block', user),
},
]
})
</script>
<style lang="scss" scoped>
.users-table {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__row {
display: flex;
align-items: center;
gap: 0.875rem;
padding: 0.75rem 1rem;
background: rgba(255, 255, 255, 85%);
border-radius: 0.875rem;
box-shadow: 0 4px 10px -6px rgba(241, 241, 241, 70%);
}
&__avatar {
height: 3rem;
width: 3rem;
border-radius: 9999px;
overflow: hidden;
background: #f3f4f6;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
img {
height: 100%;
width: 100%;
object-fit: cover;
}
}
&__info {
flex: 1 1 33%;
min-width: 0;
}
&__middle {
display: flex;
align-items: center;
gap: 0.375rem;
flex: 1 1 33%;
}
&__date {
background: rgba(107, 107, 107, 5%);
padding: 0.25rem 1rem;
border-radius: 0.875rem;
white-space: nowrap;
line-height: 1.5;
}
&__date-label {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.75rem;
color: #848484;
margin-inline-end: 0.25rem;
}
&__date-value {
font-family: var(--font-family-en);
font-size: 0.75rem;
}
&__name {
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 0.95rem;
color: #1f2937;
margin: 0 0 0.25rem;
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
font-family: var(--font-family-fa);
font-size: 0.75rem;
color: var(--color-prim-gray);
}
&__sep {
color: var(--color-thd-gray);
}
&__actions {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
&__more {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
width: 2.25rem;
height: 2.25rem;
padding: 0;
border: none;
border-radius: 9999px;
background: transparent;
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background-color: #f3f4f6;
}
}
&__more-dot {
width: 4px;
height: 4px;
border-radius: 9999px;
background-color: var(--color-prim-gray);
}
&__details-btn {
min-width: fit-content;
padding: 0 1rem;
}
&__role-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border: 1px solid transparent;
border-radius: 9999px;
background: rgba(0, 0, 0, 4%);
font-family: var(--font-family-fa);
font-size: 0.8rem;
color: #4b4b4b;
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease, border-color 0.15s ease;
&:hover:not(:disabled) {
background: rgba(0, 0, 0, 7%);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&--student {
color: var(--color-primary);
background: rgba(var(--color-primary-rgb, 204, 40, 49), 8%);
}
&--teacher {
color: #ff9d00;
background: rgba(255, 157, 0, 10%);
}
&--counselor {
color: #0048ff;
background: rgba(0, 72, 255, 8%);
}
&--missionary {
color: var(--color-secondary, #6b6b6b);
background: rgba(107, 107, 107, 8%);
}
&--admin {
color: #1f2937;
background: rgba(31, 41, 55, 8%);
}
}
&__role-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
background: currentcolor;
}
}
</style>