Improve team settings search, user sorting, and role badge layout.

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-12 14:01:08 +03:30
co-authored by Cursor
parent b583858088
commit 57e0fdd600
3 changed files with 235 additions and 80 deletions
+161 -71
View File
@@ -114,8 +114,10 @@ const focusDataAccess = ref<DataAccessPolicy>({});
const tab = ref<TabKey>('users');
const query = ref('');
const rolesQuery = ref('');
/** خالی = همهٔ نقش‌ها */
const roleFilter = ref('');
const page = ref(1);
const sortLoginDesc = ref(true);
const selectedRoleId = ref<string | null>(null);
const showAllMembers = ref(false);
const menuUserId = ref<string | null>(null);
@@ -192,19 +194,40 @@ const stats = computed(() => {
return { total, active, inactive: total - active };
});
/** آخرین واژهٔ نام کامل ≈ نام خانوادگی */
function familyNameKey(fullName: string): string {
const parts = fullName.trim().replace(/\s+/g, ' ').split(' ').filter(Boolean);
if (!parts.length) return '';
return parts[parts.length - 1]!;
}
const filteredUsers = computed(() => {
const q = query.value.trim();
let list = users.value;
const q = query.value.trim();
if (q) {
list = list.filter((u) => matchesSearch(q, [u.fullName, u.mobile, u.email, u.orgRoleName]));
}
if (roleFilter.value) {
list = list.filter((u) => u.orgRoleId === roleFilter.value);
}
return [...list].sort((a, b) => {
const at = a.lastLoginAt ? new Date(a.lastLoginAt).getTime() : 0;
const bt = b.lastLoginAt ? new Date(b.lastLoginAt).getTime() : 0;
return sortLoginDesc.value ? bt - at : at - bt;
const aOwner = a.role === 'owner' ? 0 : 1;
const bOwner = b.role === 'owner' ? 0 : 1;
if (aOwner !== bOwner) return aOwner - bOwner;
const byFamily = familyNameKey(a.fullName).localeCompare(familyNameKey(b.fullName), 'fa');
if (byFamily) return byFamily;
return a.fullName.localeCompare(b.fullName, 'fa');
});
});
const filteredRoles = computed(() => {
const q = rolesQuery.value.trim();
if (!q) return roles.value;
return roles.value.filter((role) =>
matchesSearch(q, [role.name, role.description, role.parentName, role.systemKey]),
);
});
const pageCount = computed(() => Math.max(1, Math.ceil(filteredUsers.value.length / PAGE_SIZE)));
const pagedUsers = computed(() => {
@@ -844,7 +867,7 @@ function syncViewport() {
isNarrow.value = window.matchMedia('(max-width: 900px)').matches;
}
watch(query, () => {
watch([query, roleFilter], () => {
page.value = 1;
});
@@ -1163,6 +1186,19 @@ onUnmounted(() => {
<UiIcon name="search" :size="18" />
<input v-model="query" type="search" placeholder="جستجو در کاربران..." />
</label>
<label v-else-if="tab === 'roles'" class="search">
<UiIcon name="search" :size="18" />
<input v-model="rolesQuery" type="search" placeholder="جستجو در نقش‌ها..." />
</label>
<label v-if="tab === 'users'" class="role-filter">
<select v-model="roleFilter" aria-label="فیلتر نقش">
<option value="">همه نقش‌ها</option>
<option v-for="role in roles" :key="role.id" :value="role.id">{{ role.name }}</option>
</select>
</label>
<div v-if="tab === 'users'" class="toolbar-cols desktop-table">
<TableColumnsMenu table-key="team.users" align="end" icon-only />
</div>
<button v-if="canInvite && tab === 'users'" type="button" class="btn primary add" @click="openInvite">
<span>+</span>
کاربر جدید
@@ -1175,46 +1211,29 @@ onUnmounted(() => {
<div v-if="tab === 'users'" class="stats">
<article>
<div>
<p>کل کاربران</p>
<strong>{{ toFaDigits(stats.total) }}</strong>
</div>
<span class="stat-ico users"><UiIcon name="users" :size="18" /></span>
<p>کل کاربران <strong>{{ toFaDigits(stats.total) }}</strong></p>
<span class="stat-ico users"><UiIcon name="users" :size="14" /></span>
</article>
<article>
<div>
<p>فعال</p>
<strong>{{ toFaDigits(stats.active) }}</strong>
</div>
<span class="stat-ico on"><UiIcon name="check-plain" :size="16" /></span>
<p>فعال <strong>{{ toFaDigits(stats.active) }}</strong></p>
<span class="stat-ico on"><UiIcon name="check-plain" :size="13" /></span>
</article>
<article>
<div>
<p>غیرفعال</p>
<strong>{{ toFaDigits(stats.inactive) }}</strong>
</div>
<span class="stat-ico off"><UiIcon name="x-circle" :size="16" /></span>
<p>غیرفعال <strong>{{ toFaDigits(stats.inactive) }}</strong></p>
<span class="stat-ico off"><UiIcon name="x-circle" :size="13" /></span>
</article>
</div>
<div v-if="tab === 'users'" class="tc-row desktop-table">
<TableColumnsMenu table-key="team.users" align="end" />
</div>
<div v-if="tab === 'users'" class="table-card desktop-table">
<table v-table-cols="'team.users'">
<thead>
<tr>
<th>نام و نام خانوادگی</th>
<th>موبایل</th>
<th>نقش</th>
<th class="col-role">نقش</th>
<th>پورسانت %</th>
<th>وضعیت</th>
<th>
<button type="button" class="sort" @click="sortLoginDesc = !sortLoginDesc">
آخرین ورود
<span :class="{ flip: !sortLoginDesc }">▴</span>
</button>
</th>
<th>آخرین ورود</th>
<th v-if="canSetLogoutTime">ساعت خروج</th>
<th class="ops" />
</tr>
@@ -1228,8 +1247,12 @@ onUnmounted(() => {
</div>
</td>
<td class="muted" dir="ltr">{{ user.mobile || '—' }}</td>
<td>
<span class="pill" :data-color="user.orgRoleColor || 'teal'">
<td class="col-role">
<span
class="pill"
:data-color="user.orgRoleColor || 'teal'"
:title="user.orgRoleName || undefined"
>
{{ user.orgRoleName || '—' }}
</span>
</td>
@@ -1339,7 +1362,11 @@ onUnmounted(() => {
<img :src="avatarOf(user.id)" :alt="user.fullName" />
<div>
<strong>{{ user.fullName }}</strong>
<span class="pill" :data-color="user.orgRoleColor || 'teal'">
<span
class="pill"
:data-color="user.orgRoleColor || 'teal'"
:title="user.orgRoleName || undefined"
>
{{ user.orgRoleName || '—' }}
</span>
<p v-if="user.mobile" class="muted mobile-line" dir="ltr">{{ user.mobile }}</p>
@@ -1389,7 +1416,7 @@ onUnmounted(() => {
<div v-if="tab === 'roles'" class="roles-tree">
<button
v-for="role in roles"
v-for="role in filteredRoles"
:key="role.id"
type="button"
class="role-card"
@@ -1409,6 +1436,7 @@ onUnmounted(() => {
<template v-if="role.parentName"> · زیرمجموعهٔ {{ role.parentName }}</template>
</small>
</button>
<p v-if="!loading && !filteredRoles.length" class="empty">نقشی با این جستجو پیدا نشد.</p>
</div>
<footer v-if="tab === 'users' && filteredUsers.length" class="pager">
@@ -1783,37 +1811,88 @@ h1 {
min-width: 0;
}
.role-filter {
order: 2;
display: flex;
align-items: center;
gap: 0.4rem;
flex: 0 1 12rem;
min-width: 9rem;
min-height: 42px;
padding: 0 0.75rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
color: #64748b;
font-size: 0.85rem;
}
.role-filter select {
width: 100%;
min-width: 0;
border: 0;
outline: none;
background: transparent;
font: inherit;
color: #0f172a;
cursor: pointer;
}
.toolbar-cols {
order: 3;
flex: 0 0 auto;
display: flex;
align-items: center;
}
.toolbar-cols :deep(.ant-btn) {
width: 42px;
height: 42px;
min-width: 42px;
padding: 0;
}
.stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 0.85rem;
gap: 0.45rem;
margin-bottom: 0.65rem;
}
.stats article {
background: #fff;
border: 1px solid #eef2f6;
border-radius: 16px;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04);
padding: 0.9rem 1rem;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.03);
padding: 0.4rem 0.7rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
min-height: 0;
}
.stats p {
margin: 0 0 0.2rem;
margin: 0;
display: inline-flex;
align-items: baseline;
gap: 0.4rem;
color: #94a3b8;
font-size: 0.82rem;
font-size: 0.78rem;
line-height: 1.2;
min-width: 0;
}
.stats strong {
font-size: 1.35rem;
color: #0f172a;
font-size: 0.95rem;
font-weight: 700;
line-height: 1.2;
}
.stat-ico {
width: 2rem;
height: 2rem;
width: 1.55rem;
height: 1.55rem;
border-radius: 999px;
display: grid;
place-items: center;
@@ -1880,23 +1959,6 @@ tbody tr + tr td {
padding-inline: 0.4rem;
}
.sort {
appearance: none;
background: none;
border: 0;
color: inherit;
font: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.sort .flip {
display: inline-block;
transform: rotate(180deg);
}
.who {
display: flex;
align-items: center;
@@ -1919,14 +1981,32 @@ tbody tr + tr td {
.pill {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 0.18rem 0.7rem;
justify-content: center;
border-radius: 6px;
padding: 0.2rem 0.5rem;
line-height: 1.3;
font-size: 0.78rem;
font-weight: 700;
font-weight: 600;
text-align: center;
background: #ccfbf1;
color: #0f766e;
}
.table-card .col-role {
width: 6.5rem;
max-width: 6.5rem;
vertical-align: middle;
}
.table-card .pill,
.user-card .pill {
width: max-content;
max-width: 5.75rem;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.pill[data-color='violet'] {
background: #f3e8ff;
color: #7c3aed;
@@ -2526,19 +2606,29 @@ textarea {
}
.stats article {
flex-direction: column-reverse;
align-items: flex-start;
gap: 0.45rem;
padding: 0.75rem;
flex-direction: row;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.55rem;
}
.toolbar {
flex-wrap: nowrap;
flex-wrap: wrap;
}
.search {
flex: 1;
flex: 1 1 100%;
order: 4;
}
.role-filter {
flex: 1 1 auto;
order: 2;
min-width: 0;
}
.toolbar-cols {
order: 3;
}
.btn.add {