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