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 class="term-item__actions">
|
||||
<div v-if="!term.isActive" class="term-item__status">
|
||||
<span class="term-item__status-badge">
|
||||
<span class="term-item__status-dot" />
|
||||
غیرفعال
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="term-item__actions">
|
||||
<ToggleSwitch
|
||||
:model-value="!!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;
|
||||
}
|
||||
|
||||
&__status {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
&__status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -191,6 +186,7 @@ const createdDate = computed(() => formatJalaaliDate(props.term.createdAt) || ''
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
|
||||
@@ -25,12 +25,12 @@
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
@click="onRoleClick($event, user)"
|
||||
>
|
||||
<span class="users-table__role-dot" />
|
||||
<span>{{ roleLabel(user) || 'بدون نقش' }}</span>
|
||||
<span>{{ roleLabelsText(user) || 'بدون نقش' }}</span>
|
||||
<SvgIcon name="caret-down" :size="12" color="currentColor" />
|
||||
</button>
|
||||
<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 roleName = (user) => {
|
||||
const first = user.roles?.[0]
|
||||
return typeof first === 'string' ? first : first?.name
|
||||
}
|
||||
const roleNames = (user) =>
|
||||
(user.roles || []).map((r) => (typeof r === 'string' ? r : r?.name)).filter(Boolean)
|
||||
|
||||
const roleLabel = (user) => {
|
||||
const name = roleName(user)
|
||||
if (!name) return ''
|
||||
return ROLES.find((r) => r.value === name)?.label || name
|
||||
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)
|
||||
@@ -140,23 +138,39 @@ const onRoleClick = (event, user) => {
|
||||
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
|
||||
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: [targetRoleName] } },
|
||||
{ id: user.id, payload: { roles: nextRoles } },
|
||||
{ onSuccess: invalidateUsers }
|
||||
)
|
||||
}
|
||||
|
||||
const roleMenuItems = computed(() => {
|
||||
const current = activeUser.value ? roleName(activeUser.value) : null
|
||||
return ROLES.filter((r) => r.changeable && r.value !== current).map((r) => ({
|
||||
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: `تغییر به ${r.label}`,
|
||||
icon: 'arrows-clockwise',
|
||||
onClick: () => onChangeRole(r.value),
|
||||
}))
|
||||
label: isAssigned ? `حذف نقش ${r.label}` : `افزودن نقش ${r.label}`,
|
||||
icon: isAssigned ? 'close' : 'check',
|
||||
danger: isAssigned,
|
||||
disabled: isLastRole,
|
||||
onClick: () => onToggleRole(r.value, isAssigned),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const menuItems = computed(() => {
|
||||
|
||||
@@ -19,14 +19,15 @@
|
||||
<div class="user-form__row">
|
||||
<div class="user-form__cell user-form__cell--third">
|
||||
<SelectField
|
||||
v-model="form.role"
|
||||
name="role"
|
||||
label="نقش"
|
||||
v-model="form.roles"
|
||||
name="roles"
|
||||
label="نقشها"
|
||||
:multiple="true"
|
||||
:options="ROLES"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:error="errors.role"
|
||||
@change="validateAt('role', form.role)"
|
||||
:error="errors.roles"
|
||||
@change="validateAt('roles', form.roles)"
|
||||
/>
|
||||
</div>
|
||||
<div class="user-form__cell user-form__cell--third">
|
||||
@@ -265,7 +266,7 @@ const { data: cities = ref([]) } = useGetCitiesOfProvinceQuery(provinceIdRef, {
|
||||
const initialNationalCode = ref('')
|
||||
|
||||
const form = ref({
|
||||
role: '',
|
||||
roles: [],
|
||||
name: '',
|
||||
birthday: '',
|
||||
nationalCode: '',
|
||||
@@ -307,11 +308,12 @@ watch(
|
||||
existingUser,
|
||||
(user) => {
|
||||
if (!user) return
|
||||
const firstRole = Array.isArray(user.roles) ? user.roles[0] : null
|
||||
const roleValue = typeof firstRole === 'object' ? firstRole?.name : firstRole
|
||||
const roleValues = Array.isArray(user.roles)
|
||||
? user.roles.map((r) => (typeof r === 'object' ? r?.name : r)).filter(Boolean)
|
||||
: []
|
||||
form.value = {
|
||||
...form.value,
|
||||
role: roleValue || '',
|
||||
roles: roleValues,
|
||||
name: user.name || `${user.firstName || ''} ${user.lastName || ''}`.trim(),
|
||||
birthday: user.birthday || '',
|
||||
nationalCode: user.nationalCode || '',
|
||||
@@ -360,7 +362,7 @@ const submitting = computed(() => addMutation.isPending.value || updateMutation.
|
||||
const buildUpdatePayload = () => {
|
||||
return {
|
||||
name: form.value.name,
|
||||
roles: form.value.role ? [form.value.role] : [],
|
||||
roles: form.value.roles,
|
||||
provinceId: form.value.provinceId || null,
|
||||
cityId: form.value.cityId || 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 { phoneNumberRule } from '@/constants/rules/phoneNumberRule'
|
||||
import { nationalCodeRule } from '@/constants/rules/nationalCodeRule'
|
||||
@@ -9,7 +9,7 @@ export const rejectionReasonSchema = object().shape({
|
||||
})
|
||||
|
||||
const baseUserShape = {
|
||||
role: string().required(),
|
||||
roles: array().of(string().required()).min(1, 'حداقل یک نقش را انتخاب کنید').required(),
|
||||
name: string().required().min(3),
|
||||
phone: phoneNumberRule,
|
||||
nationalCode: nationalCodeRule,
|
||||
|
||||
Reference in New Issue
Block a user