Improve team settings responsive layout, narrower role panel, and mobile pagination.

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-12 15:31:58 +03:30
co-authored by Cursor
parent 57e0fdd600
commit f4f2311ae4
3 changed files with 485 additions and 172 deletions
@@ -778,5 +778,35 @@ select {
.prior-picker {
grid-template-columns: 1fr;
}
.card-head {
flex-direction: column;
align-items: stretch;
}
.card-head :deep(.field),
.card-head :deep(input) {
max-width: none;
min-width: 0;
width: 100%;
}
.team header {
flex-direction: column;
align-items: stretch;
}
.team .actions {
width: 100%;
}
.vis-row {
align-items: start;
}
.vis-badge {
display: inline-block;
margin: 0.25rem 0 0;
}
}
</style>
+37 -2
View File
@@ -184,8 +184,43 @@ function onSizeChange(e: Event) {
@media (max-width: 900px) {
.ui-pagination {
flex-direction: column;
align-items: center;
gap: var(--sp-3);
align-items: stretch;
gap: 0.65rem;
}
.pagination-info {
width: 100%;
flex-wrap: nowrap;
justify-content: space-between;
gap: 0.35rem;
font-size: 0.78rem;
}
.page-count {
flex: 0 1 auto;
white-space: nowrap;
}
.size-picker {
margin-inline-start: 0;
flex: 0 0 auto;
gap: 0.25rem;
white-space: nowrap;
}
.size-picker span:last-child {
display: none;
}
.size-picker select {
height: 28px;
min-width: 3.25rem;
padding: 0 0.3rem;
}
.pagination-controls {
width: 100%;
justify-content: center;
}
}
</style>
+418 -170
View File
@@ -9,7 +9,7 @@ import axios from 'axios';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import UiIcon from '@/components/UiIcon.vue';
import { UiDropdownMenu, UiModal } from '@/components/ui';
import { UiDropdownMenu, UiModal, UiPagination } from '@/components/ui';
import {
DATA_ACCESS_SECTIONS,
ROLE_PERMISSION_ITEMS,
@@ -32,13 +32,14 @@ import {
formatJalaliNumeric,
toFaDigits,
} from '@/lib/jalali';
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
import TeamsSettingsPanel from '@/components/TeamsSettingsPanel.vue';
import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue';
type TabKey = 'users' | 'roles' | 'access' | 'teams';
type RoleColor = 'teal' | 'violet' | 'rose' | 'sky' | 'amber' | 'slate' | 'pink';
const PAGE_SIZE = 10;
const pageSize = usePageSize('team.users', 10);
const DEFAULT_AVATAR = '/brand/user-avatar.png';
const ROLE_COLOR_OPTIONS: Array<{ value: RoleColor; label: string }> = [
{ value: 'teal', label: 'سبز' },
@@ -228,20 +229,23 @@ const filteredRoles = computed(() => {
);
});
const pageCount = computed(() => Math.max(1, Math.ceil(filteredUsers.value.length / PAGE_SIZE)));
const pageCount = computed(() =>
Math.max(1, Math.ceil(filteredUsers.value.length / pageSize.value)),
);
const pagedUsers = computed(() => {
const start = (page.value - 1) * PAGE_SIZE;
return filteredUsers.value.slice(start, start + PAGE_SIZE);
const start = (page.value - 1) * pageSize.value;
return filteredUsers.value.slice(start, start + pageSize.value);
});
const pageLabel = computed(() => {
const total = filteredUsers.value.length;
if (!total) return '۰ کاربر';
const from = (page.value - 1) * PAGE_SIZE + 1;
const to = Math.min(page.value * PAGE_SIZE, total);
return `${toFaDigits(from)} تا ${toFaDigits(to)} از ${toFaDigits(total)} کاربر`;
});
function setPage(next: number) {
page.value = next;
}
function onPageSize(size: number) {
pageSize.value = size;
page.value = 1;
}
const visibleMembers = computed(() => {
const role = selectedRole.value;
@@ -930,10 +934,19 @@ onUnmounted(() => {
</header>
<nav class="tabs" aria-label="بخش‌های مدیریت کاربران">
<button type="button" :class="{ on: tab === 'users' }" @click="setTab('users')">لیست کاربران</button>
<button type="button" :class="{ on: tab === 'users' }" @click="setTab('users')">
<span class="tab-full">لیست کاربران</span>
<span class="tab-short">کاربران</span>
</button>
<button type="button" :class="{ on: tab === 'roles' }" @click="setTab('roles')">نقش‌ها</button>
<button type="button" :class="{ on: tab === 'teams' }" @click="setTab('teams')">تیم‌ها و سطح دسترسی به پرونده‌ها</button>
<button type="button" :class="{ on: tab === 'access' }" @click="setTab('access')">تنظیمات دسترسی</button>
<button type="button" :class="{ on: tab === 'teams' }" @click="setTab('teams')">
<span class="tab-full">تیم‌ها و سطح دسترسی به پرونده‌ها</span>
<span class="tab-short">تیم‌ها و دسترسی</span>
</button>
<button type="button" :class="{ on: tab === 'access' }" @click="setTab('access')">
<span class="tab-full">تنظیمات دسترسی</span>
<span class="tab-short">دسترسی</span>
</button>
</nav>
<p v-if="error" class="error">{{ error }}</p>
@@ -1228,25 +1241,25 @@ onUnmounted(() => {
<table v-table-cols="'team.users'">
<thead>
<tr>
<th>نام و نام خانوادگی</th>
<th>موبایل</th>
<th class="col-who">نام و نام خانوادگی</th>
<th class="col-mobile">موبایل</th>
<th class="col-role">نقش</th>
<th>پورسانت %</th>
<th>وضعیت</th>
<th>آخرین ورود</th>
<th v-if="canSetLogoutTime">ساعت خروج</th>
<th class="col-comm">پورسانت %</th>
<th class="col-status">وضعیت</th>
<th class="col-login">آخرین ورود</th>
<th v-if="canSetLogoutTime" class="col-logout">ساعت خروج</th>
<th class="ops" />
</tr>
</thead>
<tbody>
<tr v-for="user in pagedUsers" :key="user.id" @click="onUserActivate(user)">
<td>
<td class="col-who">
<div class="who">
<img :src="avatarOf(user.id)" :alt="user.fullName" />
<strong>{{ user.fullName }}</strong>
<strong class="who-name" :title="user.fullName">{{ user.fullName }}</strong>
</div>
</td>
<td class="muted" dir="ltr">{{ user.mobile || '—' }}</td>
<td class="col-mobile muted" dir="ltr">{{ user.mobile || '—' }}</td>
<td class="col-role">
<span
class="pill"
@@ -1256,7 +1269,7 @@ onUnmounted(() => {
{{ user.orgRoleName || '—' }}
</span>
</td>
<td @click.stop>
<td class="col-comm" @click.stop>
<label class="comm-field">
<input
type="number"
@@ -1271,7 +1284,7 @@ onUnmounted(() => {
/>
</label>
</td>
<td>
<td class="col-status">
<span class="status" :class="{ off: user.isActive === false }">
<i />
{{ user.isActive === false ? 'غیرفعال' : 'فعال' }}
@@ -1284,8 +1297,8 @@ onUnmounted(() => {
ورود با کد
</span>
</td>
<td class="muted">{{ formatLastLogin(user.lastLoginAt) }}</td>
<td v-if="canSetLogoutTime" @click.stop>
<td class="col-login muted">{{ formatLastLogin(user.lastLoginAt) }}</td>
<td v-if="canSetLogoutTime" class="col-logout" @click.stop>
<span v-if="user.role === 'owner'" class="muted">—</span>
<span v-else class="logout-cell">
<button
@@ -1360,55 +1373,69 @@ onUnmounted(() => {
<article v-for="user in pagedUsers" :key="user.id" class="user-card" @click="onUserActivate(user)">
<div class="who">
<img :src="avatarOf(user.id)" :alt="user.fullName" />
<div>
<strong>{{ user.fullName }}</strong>
<span
class="pill"
:data-color="user.orgRoleColor || 'teal'"
:title="user.orgRoleName || undefined"
<strong class="who-name" :title="user.fullName">{{ user.fullName }}</strong>
<span
class="pill"
:data-color="user.orgRoleColor || 'teal'"
:title="user.orgRoleName || undefined"
>
{{ user.orgRoleName || '—' }}
</span>
<div class="card-ops" @click.stop>
<UiDropdownMenu
:open="menuUserId === `m:${user.id}`"
align="end"
@update:open="(v) => (menuUserId = v ? `m:${user.id}` : null)"
>
{{ user.orgRoleName || '—' }}
</span>
<p v-if="user.mobile" class="muted mobile-line" dir="ltr">{{ user.mobile }}</p>
<template #trigger="{ toggle }">
<button type="button" class="ghost-ico" aria-label="عملیات" @click.stop="toggle">
<UiIcon name="dots-v" :size="16" />
</button>
</template>
<button
v-if="canInvite"
type="button"
@click="openEditUser(user)"
>
ویرایش مشخصات
</button>
<button
v-if="canInvite && user.role !== 'owner'"
type="button"
:class="{ on: user.loginOtpOnly }"
:title="LOGIN_OTP_ONLY_TIP"
@click="toggleLoginOtpOnly(user)"
>
{{ user.loginOtpOnly ? 'لغو ورود با کد' : 'ورود با کد' }}
</button>
<button type="button" @click="toggleStatus(user)">
{{ user.isActive === false ? 'فعال کردن' : 'غیرفعال کردن' }}
</button>
</UiDropdownMenu>
</div>
</div>
<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>
<button
v-if="canInvite"
type="button"
class="btn ghost"
@click="openEditUser(user)"
>
ویرایش
</button>
<button
v-if="canInvite && user.role !== 'owner'"
type="button"
class="btn ghost"
:class="{ 'otp-on': user.loginOtpOnly }"
:title="LOGIN_OTP_ONLY_TIP"
@click="toggleLoginOtpOnly(user)"
>
{{ user.loginOtpOnly ? 'لغو ورود با کد' : 'ورود با کد' }}
</button>
<div class="card-meta-row">
<p class="muted mobile-line" dir="ltr">{{ user.mobile || '—' }}</p>
<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>
</div>
</article>
<p v-if="!loading && !pagedUsers.length" class="empty">کاربری یافت نشد.</p>
@@ -1439,22 +1466,16 @@ onUnmounted(() => {
<p v-if="!loading && !filteredRoles.length" class="empty">نقشی با این جستجو پیدا نشد.</p>
</div>
<footer v-if="tab === 'users' && filteredUsers.length" class="pager">
<span>{{ pageLabel }}</span>
<div class="pages">
<button type="button" :disabled="page <= 1" @click="page -= 1">‹</button>
<button
v-for="n in pageCount"
:key="n"
type="button"
:class="{ on: n === page }"
@click="page = n"
>
{{ toFaDigits(n) }}
</button>
<button type="button" :disabled="page >= pageCount" @click="page += 1">›</button>
</div>
</footer>
<UiPagination
v-if="tab === 'users' && filteredUsers.length"
:page="page"
:total-pages="pageCount"
:total-items="filteredUsers.length"
:page-size="pageSize"
:page-size-options="PAGE_SIZE_OPTIONS"
@update:page="setPage"
@update:page-size="onPageSize"
/>
</div>
</div>
@@ -1733,8 +1754,12 @@ h1 {
.tabs {
display: flex;
justify-content: flex-start;
gap: 1.75rem;
gap: 1.25rem;
border-bottom: 1px solid #e2e8f0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
overscroll-behavior-x: contain;
}
.tabs button {
@@ -1748,6 +1773,8 @@ h1 {
font: inherit;
font-weight: 700;
cursor: pointer;
flex: 0 0 auto;
white-space: nowrap;
}
.tabs button.on {
@@ -1755,11 +1782,15 @@ h1 {
border-bottom-color: #14b8a6;
}
.tab-short {
display: none;
}
.stage {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 340px);
grid-template-columns: minmax(0, 1fr) 220px;
grid-template-areas: 'list panel';
gap: 1rem;
gap: 0.85rem;
align-items: start;
}
@@ -1925,25 +1956,30 @@ h1 {
}
.table-card {
overflow: hidden;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
table {
width: 100%;
width: max-content;
min-width: 100%;
border-collapse: collapse;
table-layout: auto;
}
th,
td {
padding: 0.85rem 1rem;
padding: 0.55rem 0.55rem;
text-align: right;
font-size: 0.9rem;
font-size: 0.88rem;
vertical-align: middle;
}
thead th {
color: #94a3b8;
font-weight: 600;
border-bottom: 1px solid #f1f5f9;
white-space: nowrap;
}
tbody tr {
@@ -1955,27 +1991,105 @@ tbody tr + tr td {
}
.ops {
width: 44px;
padding-inline: 0.4rem;
width: 2.5rem;
padding-inline: 0.25rem;
}
.col-who {
width: 1%;
white-space: nowrap;
padding-inline-start: 0.65rem;
}
.col-mobile {
width: 1%;
white-space: nowrap;
font-variant-numeric: tabular-nums;
letter-spacing: 0.01em;
}
.col-role {
width: 1%;
}
.col-comm {
width: 1%;
white-space: nowrap;
}
.col-status {
width: 1%;
white-space: nowrap;
}
.col-login {
width: 1%;
white-space: nowrap;
}
.col-logout {
width: 1%;
white-space: nowrap;
}
.who {
display: inline-flex;
align-items: center;
gap: 0.5rem;
max-width: 14rem;
min-width: 0;
}
.who-text {
display: grid;
gap: 0.3rem;
min-width: 0;
flex: 1;
}
.who-title {
display: flex;
align-items: center;
gap: 0.7rem;
justify-content: space-between;
gap: 0.5rem;
min-width: 0;
}
.who-title .who-name {
flex: 1 1 auto;
min-width: 0;
}
.who-title .pill {
flex: 0 0 auto;
margin: 0;
}
.who img,
.members img {
width: 40px;
height: 40px;
flex: 0 0 36px;
width: 36px;
height: 36px;
min-width: 36px;
min-height: 36px;
aspect-ratio: 1 / 1;
border-radius: 999px;
object-fit: cover;
object-position: center;
background: #e2e8f0;
}
.who-name,
.who strong {
display: block;
min-width: 0;
max-width: 11rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.35;
font-weight: 700;
color: #0f172a;
}
.pill {
@@ -1983,9 +2097,9 @@ tbody tr + tr td {
align-items: center;
justify-content: center;
border-radius: 6px;
padding: 0.2rem 0.5rem;
padding: 0.15rem 0.45rem;
line-height: 1.3;
font-size: 0.78rem;
font-size: 0.76rem;
font-weight: 600;
text-align: center;
background: #ccfbf1;
@@ -1993,12 +2107,17 @@ tbody tr + tr td {
}
.table-card .col-role {
width: 6.5rem;
max-width: 6.5rem;
vertical-align: middle;
}
.table-card .pill,
.table-card .pill {
width: max-content;
max-width: 6.25rem;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.user-card .pill {
width: max-content;
max-width: 5.75rem;
@@ -2007,6 +2126,25 @@ tbody tr + tr td {
word-break: break-word;
}
.table-card .comm-field {
margin: 0;
}
.table-card .comm-field input {
width: 3.6rem;
min-height: 2rem;
padding: 0 0.35rem;
}
.table-card .col-status .status,
.table-card .col-status .otp-badge {
white-space: nowrap;
}
.table-card .otp-badge {
margin-inline-start: 0.25rem;
}
.pill[data-color='violet'] {
background: #f3e8ff;
color: #7c3aed;
@@ -2085,8 +2223,11 @@ tbody tr + tr td {
top: 0.75rem;
display: flex;
flex-direction: column;
min-height: 34rem;
padding: 1rem 1.05rem 1.1rem;
width: 220px;
max-width: 220px;
min-height: 0;
padding: 0.85rem 0.75rem 0.95rem;
box-sizing: border-box;
}
.panel-head,
@@ -2104,17 +2245,18 @@ tbody tr + tr td {
.panel-head h2,
.role-hero h3 {
margin: 0;
font-size: 0.95rem;
font-size: 0.88rem;
color: #0d9488;
}
.role-hero {
align-items: flex-start;
margin: 1rem 0 1.15rem;
margin: 0.75rem 0 0.9rem;
}
.role-hero h3 {
font-size: 1.05rem;
font-size: 0.95rem;
line-height: 1.4;
}
.role-hero p,
@@ -2122,17 +2264,17 @@ tbody tr + tr td {
.role-card p {
margin: 0.25rem 0 0;
color: #94a3b8;
font-size: 0.82rem;
line-height: 1.7;
font-size: 0.78rem;
line-height: 1.55;
}
.panel-block + .panel-block {
margin-top: 1.15rem;
margin-top: 1rem;
}
.panel-block h4 {
margin: 0 0 0.7rem;
font-size: 0.92rem;
margin: 0 0 0.55rem;
font-size: 0.85rem;
}
.perms,
@@ -2141,15 +2283,15 @@ tbody tr + tr td {
margin: 0;
padding: 0;
display: grid;
gap: 0.55rem;
gap: 0.45rem;
}
.perms li,
.members li {
display: flex;
align-items: center;
gap: 0.55rem;
font-size: 0.88rem;
gap: 0.4rem;
font-size: 0.8rem;
}
.perm-check {
@@ -2212,7 +2354,8 @@ tbody tr + tr td {
.btn.add {
white-space: nowrap;
order: 2;
order: 4;
flex: 0 0 auto;
}
.btn.outline {
@@ -2238,36 +2381,8 @@ tbody tr + tr td {
color: #334155;
}
.pager {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
padding: 0.85rem 0.2rem 0;
color: #94a3b8;
font-size: 0.85rem;
}
.pages {
display: flex;
gap: 0.35rem;
}
.pages button {
width: 32px;
height: 32px;
border: 0;
border-radius: 10px;
background: transparent;
color: #64748b;
font: inherit;
cursor: pointer;
}
.pages button.on {
background: #ccfbf1;
color: #0f766e;
font-weight: 800;
.main-col :deep(.ui-pagination) {
margin-top: 0.75rem;
}
.mobile-list {
@@ -2276,26 +2391,93 @@ tbody tr + tr td {
}
.user-card {
display: grid;
gap: 0.55rem;
padding: 0.9rem 1rem;
min-width: 0;
}
.user-card .who {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
padding: 0.85rem 1rem;
gap: 0.45rem;
max-width: none;
width: 100%;
min-width: 0;
}
.user-card .who-name {
flex: 1 1 auto;
min-width: 0;
max-width: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.25;
}
.user-card .pill {
margin-top: 0.35rem;
flex: 0 1 auto;
max-width: 7.5rem;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mobile-line {
margin: 0.2rem 0 0;
font-size: 0.78rem;
.user-card .card-ops {
flex: 0 0 auto;
margin-inline-start: -0.15rem;
}
.user-card .card-ops .ghost-ico {
width: 28px;
height: 28px;
}
.card-meta {
padding-top: 0.55rem;
margin-top: 0.15rem;
border-top: 1px solid #f1f5f9;
}
.card-meta-row {
display: grid;
justify-items: end;
gap: 0.45rem;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 0.5rem;
min-width: 0;
min-height: 2rem;
}
.card-meta-row .mobile-line {
margin: 0;
justify-self: start;
font-size: 0.78rem;
line-height: 1.3;
white-space: nowrap;
}
.card-meta-row .comm-field {
display: inline-flex;
align-items: center;
justify-self: center;
gap: 0.35rem;
margin: 0;
font-size: 0.75rem;
white-space: nowrap;
}
.card-meta-row .comm-field input {
width: 3.4rem;
min-height: 2rem;
}
.card-meta-row .status {
justify-self: end;
flex: 0 0 auto;
line-height: 1;
white-space: nowrap;
}
.comm-field {
@@ -2574,14 +2756,36 @@ textarea {
transform: rotate(180deg);
}
@media (max-width: 1100px) {
@media (max-width: 1200px) {
.stage {
grid-template-columns: minmax(0, 1fr) 280px;
grid-template-columns: minmax(0, 1fr) 220px;
grid-template-areas: 'list panel';
}
.who {
max-width: 12rem;
}
.who-name,
.who strong {
max-width: 9.5rem;
}
}
@media (max-width: 900px) {
.tab-full {
display: none;
}
.tab-short {
display: inline;
}
.tabs {
gap: 1rem;
padding-bottom: 1px;
}
.stage {
grid-template-columns: 1fr;
grid-template-areas: 'list';
@@ -2603,37 +2807,54 @@ textarea {
.stats {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.4rem;
}
.stats article {
flex-direction: row;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.55rem;
gap: 0.3rem;
padding: 0.4rem 0.5rem;
}
.stats p {
flex-wrap: wrap;
font-size: 0.72rem;
}
.stats strong {
font-size: 0.88rem;
}
.stat-ico {
display: none;
}
.toolbar {
flex-wrap: wrap;
gap: 0.55rem;
}
.search {
flex: 1 1 100%;
order: 4;
order: 1;
}
.role-filter {
flex: 1 1 auto;
flex: 1 1 0;
order: 2;
min-width: 0;
width: auto;
}
.toolbar-cols {
order: 3;
display: none;
}
.btn.add {
flex: 0 0 auto;
order: 1;
order: 3;
padding-inline: 0.75rem;
}
.org-grid,
@@ -2642,6 +2863,15 @@ textarea {
grid-template-columns: 1fr;
}
.roles-tree .role-card {
margin-inline-start: calc(var(--role-depth, 0) * 0.65rem);
padding: 0.85rem;
}
.role-card-top {
gap: 0.35rem;
}
.matrix-scroll {
display: none;
}
@@ -2734,10 +2964,13 @@ textarea {
bottom: var(--mobile-tabbar-h, 64px);
top: auto;
z-index: 71;
width: auto;
max-width: none;
min-height: 0;
max-height: min(78dvh, 640px);
overflow: auto;
border-radius: 22px 22px 0 0;
padding-bottom: 0.85rem;
padding: 0.85rem 1rem 0.85rem;
animation: sheet-up 0.22s ease;
}
@@ -2757,8 +2990,23 @@ textarea {
padding-top: 0.75rem;
}
.pager {
flex-direction: column;
}
@media (max-width: 480px) {
.page {
gap: 0.75rem;
}
.lead {
font-size: 0.85rem;
}
.role-chips {
grid-template-columns: 1fr;
}
.user-card .pill {
max-width: 6.25rem;
}
}