Selection of multiple officials and role sub-categories
This commit is contained in:
@@ -77,6 +77,7 @@ const roleFormId = ref<string | null>(null);
|
||||
const roleFormName = ref('');
|
||||
const roleFormDesc = ref('');
|
||||
const roleFormColor = ref<RoleColor>('teal');
|
||||
const roleFormParentId = ref('');
|
||||
const roleFormPerms = ref<RolePermissionKey[]>(ROLE_PERMISSION_ITEMS.map((p) => p.key));
|
||||
|
||||
const deleteOpen = ref(false);
|
||||
@@ -148,10 +149,42 @@ const visibleMembers = computed(() => {
|
||||
return showAllMembers.value ? role.members : role.members.slice(0, 3);
|
||||
});
|
||||
|
||||
const selectedChildRoles = computed(() => {
|
||||
const id = selectedRole.value?.id;
|
||||
if (!id) return [] as OrgRoleDto[];
|
||||
return roles.value.filter((r) => r.parentId === id);
|
||||
});
|
||||
|
||||
/** گزینههای والد در فرم نقش — خود نقش و زیرنقشهایش حذف میشوند */
|
||||
const parentRoleOptions = computed(() => {
|
||||
const editingId = roleFormId.value;
|
||||
const blocked = new Set<string>();
|
||||
if (editingId) {
|
||||
blocked.add(editingId);
|
||||
const queue = [editingId];
|
||||
while (queue.length) {
|
||||
const current = queue.shift()!;
|
||||
for (const role of roles.value) {
|
||||
if (role.parentId === current && !blocked.has(role.id)) {
|
||||
blocked.add(role.id);
|
||||
queue.push(role.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return roles.value.filter((r) => !blocked.has(r.id) && (r.depth ?? 0) < 4);
|
||||
});
|
||||
|
||||
const inviteableRoles = computed(() =>
|
||||
roles.value.filter((r) => r.systemKey !== 'owner' || auth.user?.role === 'owner'),
|
||||
);
|
||||
|
||||
function roleTreeLabel(role: OrgRoleDto) {
|
||||
const depth = role.depth ?? 0;
|
||||
const prefix = depth > 0 ? `${'— '.repeat(depth)}` : '';
|
||||
return `${prefix}${role.name}`;
|
||||
}
|
||||
|
||||
function tabFromHash(hash: string): TabKey {
|
||||
if (hash.includes('settings-org') || hash.includes('access')) return 'access';
|
||||
if (hash.includes('roles')) return 'roles';
|
||||
@@ -344,15 +377,41 @@ async function toggleStatus(user: OrgUserDto) {
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateRole() {
|
||||
async function changeCommission(user: OrgUserDto, raw: string) {
|
||||
if (!canInvite.value) return;
|
||||
const next = Number(raw);
|
||||
if (!Number.isFinite(next) || next === Number(user.commissionPercent ?? 0)) return;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
await api.patch(`/users/${user.id}/commission`, { commissionPercent: next });
|
||||
ok.value = `پورسانت «${user.fullName}» ذخیره شد.`;
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ذخیره پورسانت ناموفق بود');
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateRole(parentId?: string | null) {
|
||||
const parent = parentId ? roles.value.find((r) => r.id === parentId) : null;
|
||||
roleFormId.value = null;
|
||||
roleFormName.value = '';
|
||||
roleFormDesc.value = '';
|
||||
roleFormColor.value = 'teal';
|
||||
roleFormPerms.value = ROLE_PERMISSION_ITEMS.map((p) => p.key);
|
||||
roleFormColor.value = (parent && ROLE_COLOR_OPTIONS.some((c) => c.value === parent.color)
|
||||
? parent.color
|
||||
: 'teal') as RoleColor;
|
||||
roleFormParentId.value = parent?.id ?? '';
|
||||
roleFormPerms.value = parent
|
||||
? [...parent.permissions]
|
||||
: ROLE_PERMISSION_ITEMS.map((p) => p.key);
|
||||
roleFormOpen.value = true;
|
||||
}
|
||||
|
||||
function openCreateChildRole() {
|
||||
if (!selectedRole.value) return;
|
||||
openCreateRole(selectedRole.value.id);
|
||||
}
|
||||
|
||||
function openEditRole() {
|
||||
const role = selectedRole.value;
|
||||
if (!role) return;
|
||||
@@ -362,6 +421,7 @@ function openEditRole() {
|
||||
roleFormColor.value = (ROLE_COLOR_OPTIONS.some((c) => c.value === role.color)
|
||||
? role.color
|
||||
: 'teal') as RoleColor;
|
||||
roleFormParentId.value = role.systemKey ? '' : (role.parentId ?? '');
|
||||
roleFormPerms.value = [...role.permissions];
|
||||
roleFormOpen.value = true;
|
||||
}
|
||||
@@ -384,12 +444,24 @@ async function submitRoleForm() {
|
||||
roleFormSaving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const body = {
|
||||
const editing = roleFormId.value
|
||||
? roles.value.find((r) => r.id === roleFormId.value)
|
||||
: null;
|
||||
const body: {
|
||||
name: string;
|
||||
description: string;
|
||||
color: RoleColor;
|
||||
permissions: RolePermissionKey[];
|
||||
parentId?: string | null;
|
||||
} = {
|
||||
name: roleFormName.value.trim(),
|
||||
description: roleFormDesc.value.trim(),
|
||||
color: roleFormColor.value,
|
||||
permissions: roleFormPerms.value,
|
||||
};
|
||||
if (!editing?.systemKey) {
|
||||
body.parentId = roleFormParentId.value || null;
|
||||
}
|
||||
if (roleFormId.value) {
|
||||
const { data } = await api.patch<OrgRoleDto>(`/org-roles/${roleFormId.value}`, body);
|
||||
selectedRoleId.value = data.id;
|
||||
@@ -564,7 +636,7 @@ onUnmounted(() => {
|
||||
:aria-selected="accessFocusRole?.id === role.id"
|
||||
@click="accessFocusId = role.id"
|
||||
>
|
||||
<span class="pill" :data-color="role.color">{{ role.name }}</span>
|
||||
<span class="pill" :data-color="role.color">{{ roleTreeLabel(role) }}</span>
|
||||
<small>{{ permCountLabel(role) }}</small>
|
||||
</button>
|
||||
</div>
|
||||
@@ -590,7 +662,7 @@ onUnmounted(() => {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>مجوز</th>
|
||||
<th v-for="role in roles" :key="role.id">{{ role.name }}</th>
|
||||
<th v-for="role in roles" :key="role.id">{{ roleTreeLabel(role) }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -638,9 +710,42 @@ onUnmounted(() => {
|
||||
<div>
|
||||
<h3>{{ selectedRole.name }}</h3>
|
||||
<p>{{ selectedRole.description || '—' }}</p>
|
||||
<p v-if="selectedRole.parentName" class="parent-line">
|
||||
زیرمجموعهٔ
|
||||
<button
|
||||
type="button"
|
||||
class="linkish"
|
||||
@click="pickRole(selectedRole.parentId!)"
|
||||
>
|
||||
{{ selectedRole.parentName }}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="selectedChildRoles.length || canEditRoles" class="panel-block">
|
||||
<div class="block-head">
|
||||
<h4>زیرنقشها</h4>
|
||||
<button
|
||||
v-if="canEditRoles && (selectedRole.depth ?? 0) < 4"
|
||||
type="button"
|
||||
class="linkish"
|
||||
@click="openCreateChildRole"
|
||||
>
|
||||
+ زیرنقش
|
||||
</button>
|
||||
</div>
|
||||
<ul v-if="selectedChildRoles.length" class="child-roles">
|
||||
<li v-for="child in selectedChildRoles" :key="child.id">
|
||||
<button type="button" class="child-role-btn" @click="pickRole(child.id)">
|
||||
<span class="pill" :data-color="child.color">{{ child.name }}</span>
|
||||
<small>{{ toFaDigits(child.userCount) }} کاربر</small>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="empty">هنوز زیرنقشی تعریف نشده.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel-block">
|
||||
<h4>مجوزهای دسترسی</h4>
|
||||
<ul class="perms">
|
||||
@@ -677,6 +782,14 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<footer v-if="canEditRoles" class="panel-actions">
|
||||
<button
|
||||
v-if="(selectedRole.depth ?? 0) < 4"
|
||||
type="button"
|
||||
class="btn outline"
|
||||
@click="openCreateChildRole"
|
||||
>
|
||||
+ زیرنقش
|
||||
</button>
|
||||
<button type="button" class="btn outline" @click="openEditRole">
|
||||
<UiIcon name="pencil" :size="16" />
|
||||
ویرایش نقش
|
||||
@@ -740,6 +853,7 @@ onUnmounted(() => {
|
||||
<tr>
|
||||
<th>نام و نام خانوادگی</th>
|
||||
<th>نقش</th>
|
||||
<th>پورسانت %</th>
|
||||
<th>وضعیت</th>
|
||||
<th>
|
||||
<button type="button" class="sort" @click="sortLoginDesc = !sortLoginDesc">
|
||||
@@ -763,6 +877,21 @@ onUnmounted(() => {
|
||||
{{ user.orgRoleName || '—' }}
|
||||
</span>
|
||||
</td>
|
||||
<td @click.stop>
|
||||
<label class="comm-field">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
dir="ltr"
|
||||
:value="user.commissionPercent ?? 0"
|
||||
:disabled="!canInvite"
|
||||
:aria-label="`پورسانت ${user.fullName}`"
|
||||
@change="changeCommission(user, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status" :class="{ off: user.isActive === false }">
|
||||
<i />
|
||||
@@ -788,7 +917,7 @@ onUnmounted(() => {
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && !pagedUsers.length">
|
||||
<td colspan="5" class="empty">کاربری یافت نشد.</td>
|
||||
<td colspan="6" class="empty">کاربری یافت نشد.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -805,26 +934,51 @@ onUnmounted(() => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="status" :class="{ off: user.isActive === false }">
|
||||
<i />
|
||||
{{ user.isActive === false ? 'غیرفعال' : 'فعال' }}
|
||||
</span>
|
||||
<div class="card-meta" @click.stop>
|
||||
<label class="comm-field">
|
||||
<span>پورسانت %</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
dir="ltr"
|
||||
:value="user.commissionPercent ?? 0"
|
||||
:disabled="!canInvite"
|
||||
:aria-label="`پورسانت ${user.fullName}`"
|
||||
@change="changeCommission(user, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
<span class="status" :class="{ off: user.isActive === false }">
|
||||
<i />
|
||||
{{ user.isActive === false ? 'غیرفعال' : 'فعال' }}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
<p v-if="!loading && !pagedUsers.length" class="empty">کاربری یافت نشد.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'roles'" class="roles-grid">
|
||||
<div v-if="tab === 'roles'" class="roles-tree">
|
||||
<button
|
||||
v-for="role in roles"
|
||||
:key="role.id"
|
||||
type="button"
|
||||
class="role-card"
|
||||
:class="{ on: selectedRole?.id === role.id }"
|
||||
:class="{ on: selectedRole?.id === role.id, child: (role.depth ?? 0) > 0 }"
|
||||
:style="{ '--role-depth': String(role.depth ?? 0) }"
|
||||
@click="pickRole(role.id)"
|
||||
>
|
||||
<span class="pill" :data-color="role.color">{{ role.name }}</span>
|
||||
<div class="role-card-top">
|
||||
<span class="pill" :data-color="role.color">{{ role.name }}</span>
|
||||
<span v-if="role.childCount" class="child-badge">
|
||||
{{ toFaDigits(role.childCount) }} زیرنقش
|
||||
</span>
|
||||
</div>
|
||||
<p>{{ role.description || 'بدون توضیح' }}</p>
|
||||
<small>{{ toFaDigits(role.userCount) }} کاربر</small>
|
||||
<small>
|
||||
{{ toFaDigits(role.userCount) }} کاربر
|
||||
<template v-if="role.parentName"> · زیرمجموعهٔ {{ role.parentName }}</template>
|
||||
</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -859,7 +1013,9 @@ onUnmounted(() => {
|
||||
<label>
|
||||
نقش
|
||||
<select v-model="inviteRoleId">
|
||||
<option v-for="role in inviteableRoles" :key="role.id" :value="role.id">{{ role.name }}</option>
|
||||
<option v-for="role in inviteableRoles" :key="role.id" :value="role.id">
|
||||
{{ roleTreeLabel(role) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
@@ -871,10 +1027,23 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</UiModal>
|
||||
|
||||
<UiModal :open="roleFormOpen" :title="roleFormId ? 'ویرایش نقش' : 'نقش جدید'" @close="roleFormOpen = false">
|
||||
<UiModal
|
||||
:open="roleFormOpen"
|
||||
:title="roleFormId ? 'ویرایش نقش' : roleFormParentId ? 'زیرنقش جدید' : 'نقش جدید'"
|
||||
@close="roleFormOpen = false"
|
||||
>
|
||||
<form class="form" @submit.prevent="submitRoleForm">
|
||||
<label>نام نقش<input v-model="roleFormName" required minlength="2" /></label>
|
||||
<label>توضیح<textarea v-model="roleFormDesc" rows="3" /></label>
|
||||
<label v-if="!(roleFormId && roles.find((r) => r.id === roleFormId)?.systemKey)">
|
||||
نقش والد
|
||||
<select v-model="roleFormParentId">
|
||||
<option value="">بدون والد (نقش اصلی)</option>
|
||||
<option v-for="role in parentRoleOptions" :key="role.id" :value="role.id">
|
||||
{{ roleTreeLabel(role) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
رنگ
|
||||
<select v-model="roleFormColor">
|
||||
@@ -1450,6 +1619,7 @@ tbody tr + tr td {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
@@ -1457,12 +1627,46 @@ tbody tr + tr td {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.roles-grid {
|
||||
.card-meta {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.comm-field {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.comm-field input {
|
||||
width: 4.8rem;
|
||||
min-height: 2rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 0 0.45rem;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.comm-field input:disabled {
|
||||
background: #f8fafc;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.roles-grid,
|
||||
.roles-tree {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.roles-tree {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.role-card {
|
||||
text-align: right;
|
||||
padding: 1rem;
|
||||
@@ -1470,11 +1674,83 @@ tbody tr + tr td {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.roles-tree .role-card {
|
||||
margin-inline-start: calc(var(--role-depth, 0) * 1.25rem);
|
||||
border-inline-start: 3px solid transparent;
|
||||
}
|
||||
|
||||
.roles-tree .role-card.child {
|
||||
border-inline-start-color: #99f6e4;
|
||||
background: #f8fffd;
|
||||
}
|
||||
|
||||
.role-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.child-badge {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: #0f766e;
|
||||
background: #ccfbf1;
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.55rem;
|
||||
}
|
||||
|
||||
.role-card.on {
|
||||
border-color: #99f6e4;
|
||||
box-shadow: 0 0 0 2px #ccfbf1;
|
||||
}
|
||||
|
||||
.parent-line {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.block-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.block-head h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.child-roles {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.child-role-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.child-role-btn small {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.access-wrap,
|
||||
.org-grid,
|
||||
.form {
|
||||
@@ -1635,7 +1911,8 @@ textarea {
|
||||
}
|
||||
|
||||
.org-grid,
|
||||
.roles-grid {
|
||||
.roles-grid,
|
||||
.roles-tree {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user