Files
CRM_Montzar/apps/web/src/views/TeamSettingsView.vue
T

2048 lines
51 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
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 {
ROLE_PERMISSION_ITEMS,
type InviteUserRequest,
type OrganizationDto,
type OrgRoleDto,
type OrgUserDto,
type RolePermissionKey,
type UpdateOrganizationRequest,
} from '@mento/shared';
import { canManageSettings, canManageUsers } from '@/lib/roles';
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
import {
calendarDaysBetween,
formatClockFa,
formatJalaliNumeric,
toFaDigits,
} from '@/lib/jalali';
import TeamsSettingsPanel from '@/components/TeamsSettingsPanel.vue';
type TabKey = 'users' | 'roles' | 'access' | 'teams';
type RoleColor = 'teal' | 'violet' | 'rose' | 'sky' | 'amber' | 'slate' | 'pink';
const PAGE_SIZE = 10;
const DEFAULT_AVATAR = '/brand/user-avatar.png';
const ROLE_COLOR_OPTIONS: Array<{ value: RoleColor; label: string }> = [
{ value: 'teal', label: 'سبز' },
{ value: 'violet', label: 'بنفش' },
{ value: 'rose', label: 'صورتی' },
{ value: 'sky', label: 'آبی' },
{ value: 'amber', label: 'کهربایی' },
{ value: 'slate', label: 'خاکستری' },
{ value: 'pink', label: 'سرخابی' },
];
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const error = ref('');
const ok = ref('');
const users = ref<OrgUserDto[]>([]);
const roles = ref<OrgRoleDto[]>([]);
const org = ref<OrganizationDto | null>(null);
const orgName = ref('');
const businessField = ref('');
const orgSaving = ref(false);
const accessSaving = ref(false);
const tab = ref<TabKey>('users');
const query = 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);
const isNarrow = ref(false);
const inviteOpen = ref(false);
const inviteSaving = ref(false);
const inviteName = ref('');
const inviteMobile = ref('');
const invitePassword = ref('');
const inviteRoleId = ref('');
const roleFormOpen = ref(false);
const roleFormSaving = ref(false);
const roleFormId = ref<string | null>(null);
const roleFormName = ref('');
const roleFormDesc = ref('');
const roleFormColor = ref<RoleColor>('teal');
const roleFormParentId = ref('');
const roleFormPerms = ref<RolePermissionKey[]>(ROLE_PERMISSION_ITEMS.map((p) => p.key));
const deleteOpen = ref(false);
const deleteSaving = ref(false);
const avatarUrls = ref<Record<string, string>>({});
const loadedAvatars = new Set<string>();
const canInvite = computed(() => canManageUsers(auth.user?.role));
const canEditOrg = computed(() => canManageSettings(auth.user?.role));
const canEditRoles = computed(() => canManageUsers(auth.user?.role));
const selectedRole = computed(
() => roles.value.find((r) => r.id === selectedRoleId.value) ?? null,
);
const accessFocusId = ref<string | null>(null);
const accessFocusRole = computed(
() => roles.value.find((r) => r.id === accessFocusId.value) ?? roles.value[0] ?? null,
);
function permCountLabel(role: OrgRoleDto) {
return `${toFaDigits(role.permissions.length)} از ${toFaDigits(ROLE_PERMISSION_ITEMS.length)}`;
}
const stats = computed(() => {
const total = users.value.length;
const active = users.value.filter((u) => u.isActive !== false).length;
return { total, active, inactive: total - active };
});
const filteredUsers = computed(() => {
const q = query.value.trim();
let list = users.value;
if (q) {
const needle = q.toLowerCase();
list = list.filter(
(u) =>
u.fullName.toLowerCase().includes(needle) ||
(u.mobile ?? '').includes(needle) ||
(u.orgRoleName ?? '').includes(q),
);
}
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 pageCount = computed(() => Math.max(1, Math.ceil(filteredUsers.value.length / PAGE_SIZE)));
const pagedUsers = computed(() => {
const start = (page.value - 1) * PAGE_SIZE;
return filteredUsers.value.slice(start, start + PAGE_SIZE);
});
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)} کاربر`;
});
const visibleMembers = computed(() => {
const role = selectedRole.value;
if (!role) return [];
return showAllMembers.value ? role.members : role.members.slice(0, 3);
});
const selectedChildRoles = computed(() => {
const id = selectedRole.value?.id;
if (!id) return [] as OrgRoleDto[];
return roles.value.filter((r) => r.parentId === id);
});
/** گزینه‌های والد در فرم نقش — خود نقش و زیرنقش‌هایش حذف می‌شوند */
const parentRoleOptions = computed(() => {
const editingId = roleFormId.value;
const blocked = new Set<string>();
if (editingId) {
blocked.add(editingId);
const queue = [editingId];
while (queue.length) {
const current = queue.shift()!;
for (const role of roles.value) {
if (role.parentId === current && !blocked.has(role.id)) {
blocked.add(role.id);
queue.push(role.id);
}
}
}
}
return roles.value.filter((r) => !blocked.has(r.id) && (r.depth ?? 0) < 4);
});
const inviteableRoles = computed(() =>
roles.value.filter((r) => r.systemKey !== 'owner' || auth.user?.role === 'owner'),
);
function roleTreeLabel(role: OrgRoleDto) {
const depth = role.depth ?? 0;
const prefix = depth > 0 ? `${'— '.repeat(depth)}` : '';
return `${prefix}${role.name}`;
}
function tabFromHash(hash: string): TabKey {
if (hash.includes('settings-org') || hash.includes('access')) return 'access';
if (hash.includes('roles')) return 'roles';
if (hash.includes('teams') || hash.includes('settings-teams')) return 'teams';
return 'users';
}
function setTab(next: TabKey) {
tab.value = next;
const hash =
next === 'access'
? 'settings-org'
: next === 'roles'
? 'roles'
: next === 'teams'
? 'settings-teams'
: 'settings-team';
if (route.hash.replace('#', '') !== hash) {
void router.replace({ hash: `#${hash}` });
}
if (next !== 'users' && next !== 'roles') closeSheet();
}
function closeSheet() {
if (isNarrow.value) selectedRoleId.value = null;
}
function ensureDesktopRole() {
if (isNarrow.value) return;
if (selectedRoleId.value && roles.value.some((r) => r.id === selectedRoleId.value)) return;
const sales = roles.value.find((r) => r.systemKey === 'sales') ?? roles.value[0];
selectedRoleId.value = sales?.id ?? null;
}
function pickRole(id: string) {
selectedRoleId.value = id;
showAllMembers.value = false;
menuUserId.value = null;
}
function onUserActivate(user: OrgUserDto) {
if (user.orgRoleId) pickRole(user.orgRoleId);
}
function formatLastLogin(iso: string | null | undefined) {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '—';
const days = calendarDaysBetween(d, new Date());
if (days <= 0) return `امروز - ${formatClockFa(d)}`;
if (days === 1) return `دیروز - ${formatClockFa(d)}`;
return formatJalaliNumeric(d);
}
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
}
return fallback;
}
async function loadAvatars(ids: Array<{ id: string; hasAvatar?: boolean }>) {
await Promise.all(
ids
.filter((item) => item.hasAvatar && !loadedAvatars.has(item.id))
.map(async (item) => {
loadedAvatars.add(item.id);
try {
const { data } = await api.get<Blob>(`/users/${item.id}/avatar`, {
responseType: 'blob',
});
if (!data || data.type.includes('json') || data.size === 0) return;
const next = URL.createObjectURL(data);
const prev = avatarUrls.value[item.id];
if (prev) URL.revokeObjectURL(prev);
avatarUrls.value = { ...avatarUrls.value, [item.id]: next };
} catch {
/* default avatar */
}
}),
);
}
function avatarOf(id: string) {
return avatarUrls.value[id] || DEFAULT_AVATAR;
}
async function load() {
loading.value = true;
error.value = '';
try {
const [usersRes, rolesRes, orgRes] = await Promise.all([
api.get<OrgUserDto[]>('/users'),
api.get<OrgRoleDto[]>('/org-roles'),
api.get<OrganizationDto>('/organizations/current'),
]);
users.value = usersRes.data;
roles.value = rolesRes.data;
org.value = orgRes.data;
orgName.value = orgRes.data.name;
businessField.value = orgRes.data.businessField ?? '';
if (!isNarrow.value) ensureDesktopRole();
await loadAvatars([
...users.value.map((u) => ({ id: u.id, hasAvatar: u.hasAvatar })),
...roles.value.flatMap((r) => r.members),
]);
} catch (e) {
error.value = axios.isAxiosError(e) && !e.response ? 'اتصال به API برقرار نشد.' : 'بارگذاری کاربران ناموفق بود.';
} finally {
loading.value = false;
}
}
async function saveOrg() {
if (!canEditOrg.value) return;
if (!businessField.value) {
error.value = 'زمینه فعالیت را انتخاب کنید.';
return;
}
orgSaving.value = true;
error.value = '';
ok.value = '';
try {
const payload: UpdateOrganizationRequest = {
name: orgName.value.trim(),
businessField: businessField.value as UpdateOrganizationRequest['businessField'],
};
const { data } = await api.patch<OrganizationDto>('/organizations/current', payload);
org.value = data;
orgName.value = data.name;
businessField.value = data.businessField ?? '';
ok.value = 'مشخصات سازمان ذخیره شد.';
} catch (e) {
error.value = apiError(e, 'ذخیره سازمان ناموفق بود');
} finally {
orgSaving.value = false;
}
}
function openInvite() {
inviteName.value = '';
inviteMobile.value = '';
invitePassword.value = '';
inviteRoleId.value =
inviteableRoles.value.find((r) => r.systemKey === 'sales')?.id ??
inviteableRoles.value[0]?.id ??
'';
inviteOpen.value = true;
error.value = '';
}
async function submitInvite() {
if (!inviteName.value.trim() || !inviteMobile.value.trim()) {
error.value = 'نام و شماره موبایل لازم است.';
return;
}
if (invitePassword.value && invitePassword.value.length < 8) {
error.value = 'رمز عبور در صورت ورود باید حداقل ۸ کاراکتر باشد.';
return;
}
inviteSaving.value = true;
error.value = '';
try {
const payload: InviteUserRequest = {
mobile: inviteMobile.value.trim(),
fullName: inviteName.value.trim(),
orgRoleId: inviteRoleId.value || undefined,
...(invitePassword.value ? { password: invitePassword.value } : {}),
};
await api.post('/users', payload);
inviteOpen.value = false;
ok.value = 'کاربر به این محیط اضافه شد.';
await load();
} catch (e) {
error.value = apiError(e, 'افزودن کاربر ناموفق بود');
} finally {
inviteSaving.value = false;
}
}
async function toggleStatus(user: OrgUserDto) {
menuUserId.value = null;
error.value = '';
try {
await api.patch(`/users/${user.id}/status`, { isActive: user.isActive === false });
await load();
} catch (e) {
error.value = apiError(e, 'تغییر وضعیت ناموفق بود');
}
}
async function changeCommission(user: OrgUserDto, raw: string) {
if (!canInvite.value) return;
const next = Number(raw);
if (!Number.isFinite(next) || next === Number(user.commissionPercent ?? 0)) return;
error.value = '';
ok.value = '';
try {
await api.patch(`/users/${user.id}/commission`, { commissionPercent: next });
ok.value = `پورسانت «${user.fullName}» ذخیره شد.`;
await load();
} catch (e) {
error.value = apiError(e, 'ذخیره پورسانت ناموفق بود');
}
}
function openCreateRole(parentId?: string | null) {
const parent = parentId ? roles.value.find((r) => r.id === parentId) : null;
roleFormId.value = null;
roleFormName.value = '';
roleFormDesc.value = '';
roleFormColor.value = (parent && ROLE_COLOR_OPTIONS.some((c) => c.value === parent.color)
? parent.color
: 'teal') as RoleColor;
roleFormParentId.value = parent?.id ?? '';
roleFormPerms.value = parent
? [...parent.permissions]
: ROLE_PERMISSION_ITEMS.map((p) => p.key);
roleFormOpen.value = true;
}
function openCreateChildRole() {
if (!selectedRole.value) return;
openCreateRole(selectedRole.value.id);
}
function openEditRole() {
const role = selectedRole.value;
if (!role) return;
roleFormId.value = role.id;
roleFormName.value = role.name;
roleFormDesc.value = role.description;
roleFormColor.value = (ROLE_COLOR_OPTIONS.some((c) => c.value === role.color)
? role.color
: 'teal') as RoleColor;
roleFormParentId.value = role.systemKey ? '' : (role.parentId ?? '');
roleFormPerms.value = [...role.permissions];
roleFormOpen.value = true;
}
function permOn(key: RolePermissionKey) {
return roleFormPerms.value.includes(key);
}
function toggleFormPerm(key: RolePermissionKey) {
roleFormPerms.value = permOn(key)
? roleFormPerms.value.filter((p) => p !== key)
: [...roleFormPerms.value, key];
}
async function submitRoleForm() {
if (roleFormName.value.trim().length < 2) {
error.value = 'نام نقش باید حداقل دو حرف باشد.';
return;
}
roleFormSaving.value = true;
error.value = '';
try {
const editing = roleFormId.value
? roles.value.find((r) => r.id === roleFormId.value)
: null;
const body: {
name: string;
description: string;
color: RoleColor;
permissions: RolePermissionKey[];
parentId?: string | null;
} = {
name: roleFormName.value.trim(),
description: roleFormDesc.value.trim(),
color: roleFormColor.value,
permissions: roleFormPerms.value,
};
if (!editing?.systemKey) {
body.parentId = roleFormParentId.value || null;
}
if (roleFormId.value) {
const { data } = await api.patch<OrgRoleDto>(`/org-roles/${roleFormId.value}`, body);
selectedRoleId.value = data.id;
} else {
const { data } = await api.post<OrgRoleDto>('/org-roles', body);
selectedRoleId.value = data.id;
}
roleFormOpen.value = false;
ok.value = roleFormId.value ? 'نقش به‌روز شد.' : 'نقش جدید ساخته شد.';
await load();
} catch (e) {
error.value = apiError(e, 'ذخیره نقش ناموفق بود');
} finally {
roleFormSaving.value = false;
}
}
async function confirmDeleteRole() {
const role = selectedRole.value;
if (!role || role.systemKey) return;
deleteSaving.value = true;
error.value = '';
try {
await api.delete(`/org-roles/${role.id}`);
deleteOpen.value = false;
selectedRoleId.value = null;
ok.value = 'نقش حذف شد.';
await load();
} catch (e) {
error.value = apiError(e, 'حذف نقش ناموفق بود');
} finally {
deleteSaving.value = false;
}
}
async function toggleAccess(role: OrgRoleDto, key: RolePermissionKey) {
const next = role.permissions.includes(key)
? role.permissions.filter((p) => p !== key)
: [...role.permissions, key];
role.permissions = next;
accessSaving.value = true;
error.value = '';
try {
await api.patch(`/org-roles/${role.id}`, { permissions: next });
} catch (e) {
error.value = apiError(e, 'ذخیره دسترسی ناموفق بود');
await load();
} finally {
accessSaving.value = false;
}
}
function onDocClick() {
menuUserId.value = null;
}
function syncViewport() {
isNarrow.value = window.matchMedia('(max-width: 900px)').matches;
}
watch(query, () => {
page.value = 1;
});
watch(
() => route.hash,
(hash) => {
tab.value = tabFromHash(hash);
},
{ immediate: true },
);
watch(pageCount, (count) => {
if (page.value > count) page.value = count;
});
watch(isNarrow, (narrow) => {
if (!narrow) ensureDesktopRole();
});
watch(
roles,
(list) => {
if (!list.length) return;
if (!list.some((r) => r.id === accessFocusId.value)) {
accessFocusId.value = list[0].id;
}
},
{ immediate: true },
);
onMounted(() => {
syncViewport();
window.addEventListener('resize', syncViewport);
document.addEventListener('click', onDocClick);
void load();
});
onUnmounted(() => {
window.removeEventListener('resize', syncViewport);
document.removeEventListener('click', onDocClick);
for (const url of Object.values(avatarUrls.value)) URL.revokeObjectURL(url);
});
</script>
<template>
<section class="page">
<header class="page-head">
<div class="head-copy">
<h1>
<span class="title-ico"><UiIcon name="users" :size="22" /></span>
مدیریت کاربران
</h1>
<p class="lead">کاربران سامانه را مدیریت کنید و نقش‌های مختلف را برای آن‌ها تعریف کنید.</p>
</div>
</header>
<nav class="tabs" aria-label="بخش‌های مدیریت کاربران">
<button type="button" :class="{ on: tab === 'users' }" @click="setTab('users')">لیست کاربران</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>
</nav>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<TeamsSettingsPanel
v-if="tab === 'teams'"
:users="users"
:can-edit="canInvite"
@refresh-users="load"
/>
<div v-if="tab === 'access'" class="access-wrap">
<section class="card org-card">
<h2>سازمان</h2>
<div class="org-grid">
<label>
نام سازمان
<input v-model="orgName" :disabled="!canEditOrg" />
</label>
<label>
زمینه فعالیت
<select v-model="businessField" :disabled="!canEditOrg">
<option value="" disabled>انتخاب کنید</option>
<option v-for="field in BUSINESS_FIELDS" :key="field" :value="field">
{{ BUSINESS_FIELD_LABELS[field] }}
</option>
</select>
</label>
</div>
<button v-if="canEditOrg" type="button" class="btn primary" :disabled="orgSaving" @click="saveOrg">
{{ orgSaving ? 'در حال ذخیره…' : 'ذخیره سازمان' }}
</button>
</section>
<section class="card matrix-card">
<div class="matrix-head">
<h2>مجوزهای نقش‌ها</h2>
<span v-if="accessSaving" class="muted">در حال ذخیره…</span>
</div>
<div class="matrix-mobile">
<div class="role-chips" role="tablist" aria-label="انتخاب نقش">
<button
v-for="role in roles"
:key="role.id"
type="button"
class="role-chip"
:class="{ on: accessFocusRole?.id === role.id }"
role="tab"
:aria-selected="accessFocusRole?.id === role.id"
@click="accessFocusId = role.id"
>
<span class="pill" :data-color="role.color">{{ roleTreeLabel(role) }}</span>
<small>{{ permCountLabel(role) }}</small>
</button>
</div>
<ul v-if="accessFocusRole" class="access-list">
<li v-for="perm in ROLE_PERMISSION_ITEMS" :key="perm.key">
<label class="access-row">
<span>{{ perm.label }}</span>
<span class="check">
<input
type="checkbox"
:checked="accessFocusRole.permissions.includes(perm.key)"
:disabled="!canEditRoles"
@change="toggleAccess(accessFocusRole, perm.key)"
/>
<span />
</span>
</label>
</li>
</ul>
</div>
<div class="matrix-scroll">
<table class="matrix">
<thead>
<tr>
<th>مجوز</th>
<th v-for="role in roles" :key="role.id">{{ roleTreeLabel(role) }}</th>
</tr>
</thead>
<tbody>
<tr v-for="perm in ROLE_PERMISSION_ITEMS" :key="perm.key">
<th>{{ perm.label }}</th>
<td v-for="role in roles" :key="role.id + perm.key">
<label class="check">
<input
type="checkbox"
:checked="role.permissions.includes(perm.key)"
:disabled="!canEditRoles"
@change="toggleAccess(role, perm.key)"
/>
<span />
</label>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
<div v-else-if="tab === 'users' || tab === 'roles'" class="stage" :class="{ 'panel-open': Boolean(selectedRole) }">
<aside v-if="selectedRole" class="role-panel" :class="{ sheet: isNarrow }">
<div v-if="isNarrow" class="sheet-handle" />
<header class="panel-head">
<div class="panel-title">
<span class="title-ico sm"><UiIcon name="user" :size="16" /></span>
<h2>جزئیات نقش</h2>
</div>
<button
type="button"
class="ghost-ico"
:aria-label="isNarrow ? 'بستن' : 'بازگشت'"
@click="isNarrow ? (selectedRoleId = null) : undefined"
>
<UiIcon :name="isNarrow ? 'x' : 'chevron'" :size="18" :class="{ 'back-chevron': !isNarrow }" />
</button>
</header>
<div class="panel-body">
<div class="role-hero">
<span class="title-ico"><UiIcon name="user" :size="18" /></span>
<div>
<h3>{{ selectedRole.name }}</h3>
<p>{{ selectedRole.description || '—' }}</p>
<p v-if="selectedRole.parentName" class="parent-line">
زیرمجموعهٔ
<button
type="button"
class="linkish"
@click="pickRole(selectedRole.parentId!)"
>
{{ selectedRole.parentName }}
</button>
</p>
</div>
</div>
<section v-if="selectedChildRoles.length || canEditRoles" class="panel-block">
<div class="block-head">
<h4>زیرنقش‌ها</h4>
<button
v-if="canEditRoles && (selectedRole.depth ?? 0) < 4"
type="button"
class="linkish"
@click="openCreateChildRole"
>
+ زیرنقش
</button>
</div>
<ul v-if="selectedChildRoles.length" class="child-roles">
<li v-for="child in selectedChildRoles" :key="child.id">
<button type="button" class="child-role-btn" @click="pickRole(child.id)">
<span class="pill" :data-color="child.color">{{ child.name }}</span>
<small>{{ toFaDigits(child.userCount) }} کاربر</small>
</button>
</li>
</ul>
<p v-else class="empty">هنوز زیرنقشی تعریف نشده.</p>
</section>
<section class="panel-block">
<h4>مجوزهای دسترسی</h4>
<ul class="perms">
<li v-for="perm in ROLE_PERMISSION_ITEMS" :key="perm.key">
<span
class="perm-check"
:class="{ on: selectedRole.permissions.includes(perm.key) }"
>
<UiIcon name="check-plain" :size="14" />
</span>
{{ perm.label }}
</li>
</ul>
</section>
<section v-if="!isNarrow" class="panel-block">
<h4>کاربران دارای این نقش</h4>
<ul class="members">
<li v-for="member in visibleMembers" :key="member.id">
<img :src="avatarOf(member.id)" :alt="member.fullName" />
<span>{{ member.fullName }}</span>
</li>
<li v-if="!selectedRole.members.length" class="empty">هنوز کاربری با این نقش نیست.</li>
</ul>
<button
v-if="selectedRole.members.length > 3"
type="button"
class="linkish"
@click="showAllMembers = !showAllMembers"
>
{{ showAllMembers ? 'نمایش کمتر' : `مشاهده همه (${toFaDigits(selectedRole.userCount)})` }}
</button>
</section>
</div>
<footer v-if="canEditRoles" class="panel-actions">
<button
v-if="(selectedRole.depth ?? 0) < 4"
type="button"
class="btn outline"
@click="openCreateChildRole"
>
+ زیرنقش
</button>
<button type="button" class="btn outline" @click="openEditRole">
<UiIcon name="pencil" :size="16" />
ویرایش نقش
</button>
<button
v-if="!isNarrow && !selectedRole.systemKey"
type="button"
class="btn danger-outline"
@click="deleteOpen = true"
>
<UiIcon name="trash" :size="16" />
حذف نقش
</button>
</footer>
</aside>
<div v-if="isNarrow && selectedRoleId" class="sheet-backdrop" @click="selectedRoleId = null" />
<div class="main-col">
<div class="toolbar">
<label v-if="tab === 'users'" class="search">
<UiIcon name="search" :size="18" />
<input v-model="query" type="search" placeholder="جستجو در کاربران..." />
</label>
<button v-if="canInvite && tab === 'users'" type="button" class="btn primary add" @click="openInvite">
<span>+</span>
کاربر جدید
</button>
<button v-else-if="canEditRoles && tab === 'roles'" type="button" class="btn primary add" @click="openCreateRole()">
<span>+</span>
نقش جدید
</button>
</div>
<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>
</article>
<article>
<div>
<p>فعال</p>
<strong>{{ toFaDigits(stats.active) }}</strong>
</div>
<span class="stat-ico on"><UiIcon name="check-plain" :size="16" /></span>
</article>
<article>
<div>
<p>غیرفعال</p>
<strong>{{ toFaDigits(stats.inactive) }}</strong>
</div>
<span class="stat-ico off"><UiIcon name="x-circle" :size="16" /></span>
</article>
</div>
<div v-if="tab === 'users'" class="table-card desktop-table">
<table>
<thead>
<tr>
<th>نام و نام خانوادگی</th>
<th>نقش</th>
<th>پورسانت %</th>
<th>وضعیت</th>
<th>
<button type="button" class="sort" @click="sortLoginDesc = !sortLoginDesc">
آخرین ورود
<span :class="{ flip: !sortLoginDesc }">▴</span>
</button>
</th>
<th class="ops" />
</tr>
</thead>
<tbody>
<tr v-for="user in pagedUsers" :key="user.id" @click="onUserActivate(user)">
<td>
<div class="who">
<img :src="avatarOf(user.id)" :alt="user.fullName" />
<strong>{{ user.fullName }}</strong>
</div>
</td>
<td>
<span class="pill" :data-color="user.orgRoleColor || 'teal'">
{{ user.orgRoleName || '—' }}
</span>
</td>
<td @click.stop>
<label class="comm-field">
<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>
</td>
<td>
<span class="status" :class="{ off: user.isActive === false }">
<i />
{{ user.isActive === false ? 'غیرفعال' : 'فعال' }}
</span>
</td>
<td class="muted">{{ formatLastLogin(user.lastLoginAt) }}</td>
<td class="ops" @click.stop>
<UiDropdownMenu
:open="menuUserId === user.id"
align="end"
@update:open="(v) => (menuUserId = v ? user.id : null)"
>
<template #trigger="{ toggle }">
<button type="button" class="ghost-ico" aria-label="عملیات" @click.stop="toggle">
<UiIcon name="dots-v" :size="16" />
</button>
</template>
<button type="button" @click="toggleStatus(user)">
{{ user.isActive === false ? 'فعال کردن' : 'غیرفعال کردن' }}
</button>
</UiDropdownMenu>
</td>
</tr>
<tr v-if="!loading && !pagedUsers.length">
<td colspan="6" class="empty">کاربری یافت نشد.</td>
</tr>
</tbody>
</table>
</div>
<div v-if="tab === 'users'" class="mobile-list">
<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'">
{{ user.orgRoleName || '—' }}
</span>
</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>
</div>
</article>
<p v-if="!loading && !pagedUsers.length" class="empty">کاربری یافت نشد.</p>
</div>
<div v-if="tab === 'roles'" class="roles-tree">
<button
v-for="role in roles"
:key="role.id"
type="button"
class="role-card"
:class="{ on: selectedRole?.id === role.id, child: (role.depth ?? 0) > 0 }"
:style="{ '--role-depth': String(role.depth ?? 0) }"
@click="pickRole(role.id)"
>
<div class="role-card-top">
<span class="pill" :data-color="role.color">{{ role.name }}</span>
<span v-if="role.childCount" class="child-badge">
{{ toFaDigits(role.childCount) }} زیرنقش
</span>
</div>
<p>{{ role.description || 'بدون توضیح' }}</p>
<small>
{{ toFaDigits(role.userCount) }} کاربر
<template v-if="role.parentName"> · زیرمجموعهٔ {{ role.parentName }}</template>
</small>
</button>
</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>
</div>
</div>
<UiModal :open="inviteOpen" title="کاربر جدید" @close="inviteOpen = false">
<form class="form" @submit.prevent="submitInvite">
<label>نام و نام خانوادگی<input v-model="inviteName" required minlength="2" /></label>
<label>شماره موبایل<input v-model="inviteMobile" dir="ltr" inputmode="tel" placeholder="0912…" /></label>
<label>
رمز عبور
<input v-model="invitePassword" type="password" minlength="8" placeholder="فقط برای کاربر جدید" />
</label>
<p class="hint">اگر این شماره از قبل حساب دارد، بدون رمز به همین محیط اضافه می‌شود.</p>
<label>
نقش
<select v-model="inviteRoleId">
<option v-for="role in inviteableRoles" :key="role.id" :value="role.id">
{{ roleTreeLabel(role) }}
</option>
</select>
</label>
</form>
<template #footer>
<button type="button" class="btn ghost" @click="inviteOpen = false">انصراف</button>
<button type="button" class="btn primary" :disabled="inviteSaving" @click="submitInvite">
{{ inviteSaving ? 'در حال ذخیره…' : 'افزودن کاربر' }}
</button>
</template>
</UiModal>
<UiModal
:open="roleFormOpen"
:title="roleFormId ? 'ویرایش نقش' : roleFormParentId ? 'زیرنقش جدید' : 'نقش جدید'"
@close="roleFormOpen = false"
>
<form class="form" @submit.prevent="submitRoleForm">
<label>نام نقش<input v-model="roleFormName" required minlength="2" /></label>
<label>توضیح<textarea v-model="roleFormDesc" rows="3" /></label>
<label v-if="!(roleFormId && roles.find((r) => r.id === roleFormId)?.systemKey)">
نقش والد
<select v-model="roleFormParentId">
<option value="">بدون والد (نقش اصلی)</option>
<option v-for="role in parentRoleOptions" :key="role.id" :value="role.id">
{{ roleTreeLabel(role) }}
</option>
</select>
</label>
<label>
رنگ
<select v-model="roleFormColor">
<option v-for="color in ROLE_COLOR_OPTIONS" :key="color.value" :value="color.value">
{{ color.label }}
</option>
</select>
</label>
<fieldset class="perm-set">
<legend>مجوزهای دسترسی</legend>
<label v-for="perm in ROLE_PERMISSION_ITEMS" :key="perm.key" class="perm-row">
<input type="checkbox" :checked="permOn(perm.key)" @change="toggleFormPerm(perm.key)" />
{{ perm.label }}
</label>
</fieldset>
</form>
<template #footer>
<button type="button" class="btn ghost" @click="roleFormOpen = false">انصراف</button>
<button type="button" class="btn primary" :disabled="roleFormSaving" @click="submitRoleForm">
{{ roleFormSaving ? 'در حال ذخیره…' : 'ذخیره نقش' }}
</button>
</template>
</UiModal>
<UiModal :open="deleteOpen" title="حذف نقش" @close="deleteOpen = false">
<p>آیا از حذف این نقش مطمئن هستید؟ کاربران آن بدون نقش سفارشی می‌مانند.</p>
<template #footer>
<button type="button" class="btn ghost" @click="deleteOpen = false">انصراف</button>
<button type="button" class="btn danger" :disabled="deleteSaving" @click="confirmDeleteRole">
{{ deleteSaving ? 'در حال حذف…' : 'حذف نقش' }}
</button>
</template>
</UiModal>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
min-width: 0;
}
.page-head {
display: flex;
justify-content: flex-start;
width: 100%;
}
.head-copy {
text-align: right;
width: 100%;
}
h1 {
margin: 0 0 0.35rem;
font-size: clamp(1.35rem, 2.2vw, 1.7rem);
font-weight: 800;
display: inline-flex;
align-items: center;
gap: 0.55rem;
}
.title-ico {
width: 2.15rem;
height: 2.15rem;
border-radius: 999px;
display: grid;
place-items: center;
background: #e6fbf7;
color: #0d9488;
flex: 0 0 auto;
}
.title-ico.sm {
width: 1.7rem;
height: 1.7rem;
}
.lead,
.muted,
.hint,
.empty {
margin: 0;
color: #94a3b8;
font-size: 0.92rem;
}
.error {
margin: 0;
color: #b91c1c;
}
.ok {
margin: 0;
color: #0f766e;
font-weight: 600;
}
.tabs {
display: flex;
justify-content: flex-start;
gap: 1.75rem;
border-bottom: 1px solid #e2e8f0;
}
.tabs button {
appearance: none;
background: none;
border: 0;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
padding: 0.55rem 0.15rem;
color: #94a3b8;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.tabs button.on {
color: #0d9488;
border-bottom-color: #14b8a6;
}
.stage {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 340px);
grid-template-areas: 'list panel';
gap: 1rem;
align-items: start;
}
.main-col {
grid-area: list;
}
.role-panel {
grid-area: panel;
}
.main-col,
.role-panel,
.card {
min-width: 0;
}
.toolbar {
display: flex;
flex-direction: row;
direction: rtl;
gap: 0.75rem;
align-items: center;
margin-bottom: 0.85rem;
}
.search {
flex: 1;
order: 1;
display: flex;
align-items: center;
gap: 0.45rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
min-height: 42px;
padding: 0 0.85rem;
color: #94a3b8;
}
.search input {
flex: 1;
border: 0;
outline: none;
font: inherit;
background: transparent;
min-width: 0;
}
.stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 0.85rem;
}
.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;
display: flex;
justify-content: space-between;
align-items: center;
}
.stats p {
margin: 0 0 0.2rem;
color: #94a3b8;
font-size: 0.82rem;
}
.stats strong {
font-size: 1.35rem;
}
.stat-ico {
width: 2rem;
height: 2rem;
border-radius: 999px;
display: grid;
place-items: center;
}
.stat-ico.users {
background: #eef2ff;
color: #6366f1;
}
.stat-ico.on {
background: #dcfce7;
color: #16a34a;
}
.stat-ico.off {
background: #f1f5f9;
color: #64748b;
}
.table-card,
.role-panel,
.card,
.user-card,
.role-card {
background: #fff;
border: 1px solid #eef2f6;
border-radius: 18px;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.04);
}
.table-card {
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 0.85rem 1rem;
text-align: right;
font-size: 0.9rem;
}
thead th {
color: #94a3b8;
font-weight: 600;
border-bottom: 1px solid #f1f5f9;
}
tbody tr {
cursor: pointer;
}
tbody tr + tr td {
border-top: 1px solid #f8fafc;
}
.ops {
width: 44px;
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;
gap: 0.7rem;
}
.who img,
.members img {
width: 40px;
height: 40px;
border-radius: 999px;
object-fit: cover;
background: #e2e8f0;
}
.who strong {
display: block;
}
.pill {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 0.18rem 0.7rem;
font-size: 0.78rem;
font-weight: 700;
background: #ccfbf1;
color: #0f766e;
}
.pill[data-color='violet'] {
background: #f3e8ff;
color: #7c3aed;
}
.pill[data-color='rose'],
.pill[data-color='pink'] {
background: #fce7f3;
color: #db2777;
}
.pill[data-color='sky'] {
background: #e0f2fe;
color: #0284c7;
}
.pill[data-color='amber'] {
background: #fef3c7;
color: #b45309;
}
.pill[data-color='slate'] {
background: #f1f5f9;
color: #475569;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: #16a34a;
font-weight: 700;
font-size: 0.85rem;
}
.status i {
width: 8px;
height: 8px;
border-radius: 999px;
background: currentColor;
}
.status.off {
color: #94a3b8;
}
.ghost-ico {
width: 32px;
height: 32px;
border: 0;
background: transparent;
color: #94a3b8;
display: grid;
place-items: center;
cursor: pointer;
border-radius: 8px;
}
.role-panel {
position: sticky;
top: 0.75rem;
display: flex;
flex-direction: column;
min-height: 34rem;
padding: 1rem 1.05rem 1.1rem;
}
.panel-head,
.panel-title,
.role-hero {
display: flex;
align-items: center;
gap: 0.5rem;
}
.panel-head {
justify-content: space-between;
}
.panel-head h2,
.role-hero h3 {
margin: 0;
font-size: 0.95rem;
color: #0d9488;
}
.role-hero {
align-items: flex-start;
margin: 1rem 0 1.15rem;
}
.role-hero h3 {
font-size: 1.05rem;
}
.role-hero p,
.panel-block p,
.role-card p {
margin: 0.25rem 0 0;
color: #94a3b8;
font-size: 0.82rem;
line-height: 1.7;
}
.panel-block + .panel-block {
margin-top: 1.15rem;
}
.panel-block h4 {
margin: 0 0 0.7rem;
font-size: 0.92rem;
}
.perms,
.members {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.55rem;
}
.perms li,
.members li {
display: flex;
align-items: center;
gap: 0.55rem;
font-size: 0.88rem;
}
.perm-check {
width: 1.15rem;
height: 1.15rem;
border-radius: 999px;
display: grid;
place-items: center;
color: #cbd5e1;
background: #f8fafc;
}
.perm-check.on {
color: #16a34a;
background: #dcfce7;
}
.linkish {
appearance: none;
background: none;
border: 0;
color: #0d9488;
font: inherit;
font-weight: 700;
padding: 0.7rem 0 0;
cursor: pointer;
}
.panel-actions {
margin-top: auto;
display: grid;
gap: 0.55rem;
padding-top: 1.1rem;
}
.panel-body {
flex: 1;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 42px;
padding: 0 1rem;
border-radius: 12px;
font: inherit;
font-weight: 800;
border: 1px solid transparent;
cursor: pointer;
}
.btn.primary,
.btn.add {
background: #14b8a6;
color: #fff;
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.22);
}
.btn.add {
white-space: nowrap;
order: 2;
}
.btn.outline {
background: #fff;
color: #0d9488;
border-color: #99f6e4;
}
.btn.danger-outline {
background: #fff;
color: #e11d48;
border-color: #fecdd3;
}
.btn.danger {
background: #e11d48;
color: #fff;
}
.btn.ghost {
background: #fff;
border-color: #e2e8f0;
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;
}
.mobile-list {
display: none;
gap: 0.65rem;
}
.user-card {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
padding: 0.85rem 1rem;
}
.user-card .pill {
margin-top: 0.35rem;
}
.card-meta {
display: grid;
justify-items: end;
gap: 0.45rem;
}
.comm-field {
display: grid;
gap: 0.15rem;
font-size: 0.75rem;
color: #64748b;
}
.comm-field input {
width: 4.8rem;
min-height: 2rem;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 0 0.45rem;
font: inherit;
background: #fff;
box-sizing: border-box;
}
.comm-field input:disabled {
background: #f8fafc;
color: #94a3b8;
}
.roles-grid,
.roles-tree {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.roles-tree {
grid-template-columns: 1fr;
}
.role-card {
text-align: right;
padding: 1rem;
cursor: pointer;
font: inherit;
}
.roles-tree .role-card {
margin-inline-start: calc(var(--role-depth, 0) * 1.25rem);
border-inline-start: 3px solid transparent;
}
.roles-tree .role-card.child {
border-inline-start-color: #99f6e4;
background: #f8fffd;
}
.role-card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
.child-badge {
font-size: 0.72rem;
font-weight: 700;
color: #0f766e;
background: #ccfbf1;
border-radius: 999px;
padding: 0.15rem 0.55rem;
}
.role-card.on {
border-color: #99f6e4;
box-shadow: 0 0 0 2px #ccfbf1;
}
.parent-line {
margin: 0.35rem 0 0;
font-size: 0.82rem;
color: #64748b;
}
.block-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.45rem;
}
.block-head h4 {
margin: 0;
}
.child-roles {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.4rem;
}
.child-role-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 12px;
padding: 0.55rem 0.7rem;
font: inherit;
cursor: pointer;
text-align: right;
}
.child-role-btn small {
color: #94a3b8;
}
.access-wrap,
.org-grid,
.form {
display: grid;
gap: 1rem;
}
.card {
padding: 1.1rem;
}
.card h2 {
margin: 0 0 0.85rem;
font-size: 1rem;
}
.org-card {
display: grid;
gap: 1.15rem;
}
.org-card h2 {
margin: 0;
}
.org-card > .btn {
justify-self: start;
margin-top: 0.35rem;
}
.org-grid {
grid-template-columns: 1fr 1fr;
}
label,
.perm-row {
display: grid;
gap: 0.35rem;
font-size: 0.88rem;
color: #64748b;
}
input,
select,
textarea {
border: 1px solid #e2e8f0;
border-radius: 12px;
min-height: 42px;
padding: 0.55rem 0.85rem;
font: inherit;
background: #fff;
}
.matrix-head {
display: flex;
justify-content: space-between;
align-items: center;
}
.matrix-mobile {
display: none;
}
.matrix-scroll {
overflow: auto;
}
.matrix th,
.matrix td {
text-align: center;
white-space: nowrap;
}
.matrix th:first-child,
.matrix td:first-child {
text-align: right;
position: sticky;
inset-inline-start: 0;
background: #fff;
}
.check {
display: grid;
place-items: center;
}
.perm-set {
border: 1px solid #eef2f6;
border-radius: 14px;
padding: 0.85rem;
}
.perm-row {
grid-template-columns: auto 1fr;
align-items: center;
color: #0f172a;
}
.sheet-handle,
.sheet-backdrop {
display: none;
}
.back-chevron {
transform: rotate(180deg);
}
@media (max-width: 1100px) {
.stage {
grid-template-columns: minmax(0, 1fr) 280px;
grid-template-areas: 'list panel';
}
}
@media (max-width: 900px) {
.stage {
grid-template-columns: 1fr;
grid-template-areas: 'list';
}
.main-col,
.role-panel {
grid-area: auto;
grid-column: auto;
}
.desktop-table {
display: none;
}
.mobile-list {
display: grid;
}
.stats {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.stats article {
flex-direction: column-reverse;
align-items: flex-start;
gap: 0.45rem;
padding: 0.75rem;
}
.toolbar {
flex-wrap: nowrap;
}
.search {
flex: 1;
order: 2;
}
.btn.add {
flex: 0 0 auto;
order: 1;
}
.org-grid,
.roles-grid,
.roles-tree {
grid-template-columns: 1fr;
}
.matrix-scroll {
display: none;
}
.matrix-mobile {
display: grid;
gap: 0.85rem;
margin-top: 0.75rem;
}
.matrix-card {
overflow-x: clip;
}
.role-chips {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.role-chip {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.3rem;
min-width: 0;
min-height: 52px;
padding: 0.55rem 0.7rem;
border: 1px solid #eef2f6;
border-radius: 14px;
background: #f8fafc;
font: inherit;
text-align: right;
cursor: pointer;
}
.role-chip .pill {
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.role-chip small {
color: #94a3b8;
font-size: 0.75rem;
}
.role-chip.on {
background: #f0fdfa;
border-color: #99f6e4;
box-shadow: 0 0 0 2px #ccfbf1;
}
.access-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.35rem;
}
.access-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
min-height: 46px;
margin: 0;
padding: 0.35rem 0.15rem;
color: #0f172a;
font-size: 0.92rem;
}
.access-row .check {
flex: 0 0 auto;
}
.sheet-backdrop {
display: block;
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.28);
z-index: 70;
}
.role-panel.sheet {
position: fixed;
inset-inline: 0;
bottom: var(--mobile-tabbar-h, 64px);
top: auto;
z-index: 71;
min-height: 0;
max-height: min(78dvh, 640px);
border-radius: 22px 22px 0 0;
padding-bottom: 0.85rem;
animation: sheet-up 0.22s ease;
}
.sheet-handle {
display: block;
width: 42px;
height: 4px;
border-radius: 99px;
background: #e2e8f0;
margin: 0 auto 0.4rem;
}
.panel-actions {
position: sticky;
bottom: 0;
background: #fff;
padding-top: 0.75rem;
}
.pager {
flex-direction: column;
}
}
@keyframes sheet-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
</style>