Improve team settings search, user sorting, and role badge layout.
EOF Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user