Selection of multiple officials and role sub-categories

This commit is contained in:
2026-10-03 13:16:13 +03:30
parent 9f97371391
commit 3b128bd980
14 changed files with 904 additions and 95 deletions
+296 -19
View File
@@ -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;
}