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
+14 -5
View File
@@ -18,8 +18,10 @@ const props = withDefaults(
tableKey: string;
label?: string;
align?: 'start' | 'end';
/** فقط آیکون تنظیمات (بدون متن «ستون‌ها») */
iconOnly?: boolean;
}>(),
{ label: 'ستون‌ها', align: 'start' },
{ label: 'ستون‌ها', align: 'start', iconOnly: false },
);
const open = ref(false);
@@ -60,11 +62,18 @@ const placement = computed(() =>
</a-button>
</a-flex>
</template>
<a-button :type="hiddenCount > 0 ? 'primary' : 'default'" :ghost="hiddenCount > 0">
<a-button
:type="hiddenCount > 0 ? 'primary' : 'default'"
:ghost="hiddenCount > 0"
:title="iconOnly ? (hiddenCount ? `${label} · ${toFaDigits(hiddenCount)} مخفی` : label) : undefined"
:aria-label="label"
>
<template #icon><SettingOutlined /></template>
{{ label }}
<template v-if="hiddenCount">
· {{ toFaDigits(hiddenCount) }} مخفی
<template v-if="!iconOnly">
{{ label }}
<template v-if="hiddenCount">
· {{ toFaDigits(hiddenCount) }} مخفی
</template>
</template>
</a-button>
</a-popover>
+60 -4
View File
@@ -22,6 +22,7 @@ import {
import { UiButton, UiEmptyState, UiField, UiInput } from '@/components/ui';
import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue';
import { formatLocalDateInput, parseLocalInput, toFaDigits } from '@/lib/jalali';
import { matchesSearch } from '@/lib/search';
const props = defineProps<{
users: OrgUserDto[];
@@ -74,6 +75,29 @@ const editableUsers = computed(() =>
props.users.filter((u) => u.isActive !== false && u.role !== 'owner' && u.role !== 'admin'),
);
const teamsQuery = ref('');
const accessQuery = ref('');
const filteredTeams = computed(() => {
const q = teamsQuery.value.trim();
if (!q) return teams.value;
return teams.value.filter((team) =>
matchesSearch(q, [
team.name,
team.description,
...team.members.map((m) => m.fullName),
]),
);
});
const filteredAccessUsers = computed(() => {
const q = accessQuery.value.trim();
if (!q) return editableUsers.value;
return editableUsers.value.filter((u) =>
matchesSearch(q, [u.fullName, u.mobile, u.email, u.orgRoleName, accessSummary(u)]),
);
});
async function setPriorArchive(userId: string, enabled: boolean) {
priorSavingId.value = userId;
priorPick.value = '';
@@ -383,14 +407,22 @@ onMounted(() => {
</section>
<section class="card">
<h3>تیم‌ها</h3>
<header class="card-head">
<h3>تیم‌ها</h3>
<UiInput
v-if="teams.length"
v-model="teamsQuery"
placeholder="جستجوی تیم یا عضو…"
/>
</header>
<UiEmptyState
v-if="!loading && !teams.length"
title="تیمی تعریف نشده"
description="تیم بسازید تا اعضای فروش پرونده‌های هم‌تیمی را ببینند."
/>
<p v-else-if="!filteredTeams.length" class="hint">تیمی با این جستجو پیدا نشد.</p>
<div v-else class="team-list">
<article v-for="t in teams" :key="t.id" class="team">
<article v-for="t in filteredTeams" :key="t.id" class="team">
<header>
<div>
<strong>{{ t.name }}</strong>
@@ -504,7 +536,14 @@ onMounted(() => {
</section>
<section class="card">
<h3>محدوده داده</h3>
<header class="card-head">
<h3>محدوده داده</h3>
<UiInput
v-if="editableUsers.length"
v-model="accessQuery"
placeholder="جستجوی کاربر…"
/>
</header>
<p class="hint">
مالک و مدیر همیشه همه را می‌بینند. پیش‌فرض از نقش می‌آید؛ برای کاربر خاص می‌توانید فیلتر
جدا بگذارید.
@@ -514,8 +553,9 @@ onMounted(() => {
title="کاربر قابل تنظیم نیست"
description="فقط نقش‌های فروش و پشتیبانی محدوده داده جدا دارند."
/>
<p v-else-if="!filteredAccessUsers.length" class="hint">کاربری با این جستجو پیدا نشد.</p>
<div v-else class="vis-table">
<div v-for="u in editableUsers" :key="u.id" class="vis-user">
<div v-for="u in filteredAccessUsers" :key="u.id" class="vis-user">
<div class="vis-row">
<span>
{{ u.fullName }}
@@ -567,6 +607,22 @@ onMounted(() => {
margin: 0 0 0.75rem;
font-size: 1rem;
}
.card-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
margin-bottom: 0.75rem;
}
.card-head h3 {
margin: 0;
}
.card-head :deep(.field),
.card-head :deep(input) {
min-width: min(16rem, 100%);
max-width: 18rem;
}
.prior-head {
display: flex;
align-items: center;
+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 {