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:
@@ -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(() => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user