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>
This commit is contained in:
madresebanoo
2026-09-02 15:16:04 +04:30
parent 97adb2923e
commit 72d92c24d5
4 changed files with 54 additions and 42 deletions
@@ -17,14 +17,13 @@
</div> </div>
</div> </div>
<div class="term-item__actions">
<div v-if="!term.isActive" class="term-item__status"> <div v-if="!term.isActive" class="term-item__status">
<span class="term-item__status-badge"> <span class="term-item__status-badge">
<span class="term-item__status-dot" /> <span class="term-item__status-dot" />
غیرفعال غیرفعال
</span> </span>
</div> </div>
<div class="term-item__actions">
<ToggleSwitch <ToggleSwitch
:model-value="!!term.isActive" :model-value="!!term.isActive"
@update:model-value="emit('change-status', { id: term.id, isActive: !term.isActive })" @update:model-value="emit('change-status', { id: term.id, isActive: !term.isActive })"
@@ -166,10 +165,6 @@ const createdDate = computed(() => formatJalaaliDate(props.term.createdAt) || ''
font-size: 0.75rem; font-size: 0.75rem;
} }
&__status {
flex: 0 0 auto;
}
&__status-badge { &__status-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -191,6 +186,7 @@ const createdDate = computed(() => formatJalaaliDate(props.term.createdAt) || ''
&__actions { &__actions {
display: flex; display: flex;
flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
align-items: center; align-items: center;
gap: 0.375rem; gap: 0.375rem;
@@ -25,12 +25,12 @@
<button <button
type="button" type="button"
class="users-table__role-badge" class="users-table__role-badge"
:class="`users-table__role-badge--${roleName(user) || 'unknown'}`" :class="`users-table__role-badge--${roleNames(user)[0] || 'unknown'}`"
:disabled="updateRoleMutation.isPending.value && activeUser?.id === user.id" :disabled="updateRoleMutation.isPending.value && activeUser?.id === user.id"
@click="onRoleClick($event, user)" @click="onRoleClick($event, user)"
> >
<span class="users-table__role-dot" /> <span class="users-table__role-dot" />
<span>{{ roleLabel(user) || 'بدون نقش' }}</span> <span>{{ roleLabelsText(user) || 'بدون نقش' }}</span>
<SvgIcon name="caret-down" :size="12" color="currentColor" /> <SvgIcon name="caret-down" :size="12" color="currentColor" />
</button> </button>
<Badge v-if="user.status === 'blocked'" variant="danger" size="sm" dot> <Badge v-if="user.status === 'blocked'" variant="danger" size="sm" dot>
@@ -96,15 +96,13 @@ const queryClient = useQueryClient()
const emit = defineEmits(['edit', 'delete', 'block', 'unblock', 'show-details']) const emit = defineEmits(['edit', 'delete', 'block', 'unblock', 'show-details'])
const roleName = (user) => { const roleNames = (user) =>
const first = user.roles?.[0] (user.roles || []).map((r) => (typeof r === 'string' ? r : r?.name)).filter(Boolean)
return typeof first === 'string' ? first : first?.name
}
const roleLabel = (user) => { const roleLabelsText = (user) => {
const name = roleName(user) const names = roleNames(user)
if (!name) return '' if (names.length === 0) return ''
return ROLES.find((r) => r.value === name)?.label || name return names.map((name) => ROLES.find((r) => r.value === name)?.label || name).join('، ')
} }
const menuOpen = ref(false) const menuOpen = ref(false)
@@ -140,23 +138,39 @@ const onRoleClick = (event, user) => {
roleMenuOpen.value = true roleMenuOpen.value = true
} }
const onChangeRole = (targetRoleName) => { // 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 const user = activeUser.value
if (!user || !targetRoleName) return 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( updateRoleMutation.mutate(
{ id: user.id, payload: { roles: [targetRoleName] } }, { id: user.id, payload: { roles: nextRoles } },
{ onSuccess: invalidateUsers } { onSuccess: invalidateUsers }
) )
} }
const roleMenuItems = computed(() => { const roleMenuItems = computed(() => {
const current = activeUser.value ? roleName(activeUser.value) : null const user = activeUser.value
return ROLES.filter((r) => r.changeable && r.value !== current).map((r) => ({ 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}`, key: `role-${r.value}`,
label: `تغییر به ${r.label}`, label: isAssigned ? `حذف نقش ${r.label}` : `افزودن نقش ${r.label}`,
icon: 'arrows-clockwise', icon: isAssigned ? 'close' : 'check',
onClick: () => onChangeRole(r.value), danger: isAssigned,
})) disabled: isLastRole,
onClick: () => onToggleRole(r.value, isAssigned),
}
})
}) })
const menuItems = computed(() => { const menuItems = computed(() => {
+12 -10
View File
@@ -19,14 +19,15 @@
<div class="user-form__row"> <div class="user-form__row">
<div class="user-form__cell user-form__cell--third"> <div class="user-form__cell user-form__cell--third">
<SelectField <SelectField
v-model="form.role" v-model="form.roles"
name="role" name="roles"
label="نقش" label="نقش‌ها"
:multiple="true"
:options="ROLES" :options="ROLES"
option-label="label" option-label="label"
option-value="value" option-value="value"
:error="errors.role" :error="errors.roles"
@change="validateAt('role', form.role)" @change="validateAt('roles', form.roles)"
/> />
</div> </div>
<div class="user-form__cell user-form__cell--third"> <div class="user-form__cell user-form__cell--third">
@@ -265,7 +266,7 @@ const { data: cities = ref([]) } = useGetCitiesOfProvinceQuery(provinceIdRef, {
const initialNationalCode = ref('') const initialNationalCode = ref('')
const form = ref({ const form = ref({
role: '', roles: [],
name: '', name: '',
birthday: '', birthday: '',
nationalCode: '', nationalCode: '',
@@ -307,11 +308,12 @@ watch(
existingUser, existingUser,
(user) => { (user) => {
if (!user) return if (!user) return
const firstRole = Array.isArray(user.roles) ? user.roles[0] : null const roleValues = Array.isArray(user.roles)
const roleValue = typeof firstRole === 'object' ? firstRole?.name : firstRole ? user.roles.map((r) => (typeof r === 'object' ? r?.name : r)).filter(Boolean)
: []
form.value = { form.value = {
...form.value, ...form.value,
role: roleValue || '', roles: roleValues,
name: user.name || `${user.firstName || ''} ${user.lastName || ''}`.trim(), name: user.name || `${user.firstName || ''} ${user.lastName || ''}`.trim(),
birthday: user.birthday || '', birthday: user.birthday || '',
nationalCode: user.nationalCode || '', nationalCode: user.nationalCode || '',
@@ -360,7 +362,7 @@ const submitting = computed(() => addMutation.isPending.value || updateMutation.
const buildUpdatePayload = () => { const buildUpdatePayload = () => {
return { return {
name: form.value.name, name: form.value.name,
roles: form.value.role ? [form.value.role] : [], roles: form.value.roles,
provinceId: form.value.provinceId || null, provinceId: form.value.provinceId || null,
cityId: form.value.cityId || null, cityId: form.value.cityId || null,
marriageStatus: form.value.maritalStatus || null, marriageStatus: form.value.maritalStatus || null,
+2 -2
View File
@@ -1,4 +1,4 @@
import { number, object, string } from 'yup' import { array, number, object, string } from 'yup'
import { passwordRule } from '@/constants/rules/passwordRule' import { passwordRule } from '@/constants/rules/passwordRule'
import { phoneNumberRule } from '@/constants/rules/phoneNumberRule' import { phoneNumberRule } from '@/constants/rules/phoneNumberRule'
import { nationalCodeRule } from '@/constants/rules/nationalCodeRule' import { nationalCodeRule } from '@/constants/rules/nationalCodeRule'
@@ -9,7 +9,7 @@ export const rejectionReasonSchema = object().shape({
}) })
const baseUserShape = { const baseUserShape = {
role: string().required(), roles: array().of(string().required()).min(1, 'حداقل یک نقش را انتخاب کنید').required(),
name: string().required().min(3), name: string().required().min(3),
phone: phoneNumberRule, phone: phoneNumberRule,
nationalCode: nationalCodeRule, nationalCode: nationalCodeRule,