Files
CRM_Montzar/apps/web/src/views/TeamSettingsView.vue
T
2026-09-21 17:55:05 +03:30

1783 lines
43 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 UiModal from '@/components/ui/UiModal.vue';
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';
type TabKey = 'users' | 'roles' | 'access';
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 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 inviteableRoles = computed(() =>
roles.value.filter((r) => r.systemKey !== 'owner' || auth.user?.role === 'owner'),
);
function tabFromHash(hash: string): TabKey {
if (hash.includes('settings-org') || hash.includes('access')) return 'access';
if (hash.includes('roles')) return 'roles';
return 'users';
}
function setTab(next: TabKey) {
tab.value = next;
const hash = next === 'access' ? 'settings-org' : next === 'roles' ? 'roles' : '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, 'تغییر وضعیت ناموفق بود');
}
}
function openCreateRole() {
roleFormId.value = null;
roleFormName.value = '';
roleFormDesc.value = '';
roleFormColor.value = 'teal';
roleFormPerms.value = ROLE_PERMISSION_ITEMS.map((p) => p.key);
roleFormOpen.value = true;
}
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;
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 body = {
name: roleFormName.value.trim(),
description: roleFormDesc.value.trim(),
color: roleFormColor.value,
permissions: roleFormPerms.value,
};
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 === 'access' }" @click="setTab('access')">تنظیمات دسترسی</button>
</nav>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<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">{{ role.name }}</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">{{ role.name }}</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 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>
</div>
</div>
<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 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>
<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>
<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>
<div class="kebab">
<button type="button" class="ghost-ico" aria-label="عملیات" @click.stop="menuUserId = menuUserId === user.id ? null : user.id">
<UiIcon name="dots-v" :size="16" />
</button>
<div v-if="menuUserId === user.id" class="menu" @click.stop>
<button type="button" @click="toggleStatus(user)">
{{ user.isActive === false ? 'فعال کردن' : 'غیرفعال کردن' }}
</button>
</div>
</div>
</td>
</tr>
<tr v-if="!loading && !pagedUsers.length">
<td colspan="5" 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>
<span class="status" :class="{ off: user.isActive === false }">
<i />
{{ user.isActive === false ? 'غیرفعال' : 'فعال' }}
</span>
</article>
<p v-if="!loading && !pagedUsers.length" class="empty">کاربری یافت نشد.</p>
</div>
<div v-if="tab === 'roles'" class="roles-grid">
<button
v-for="role in roles"
:key="role.id"
type="button"
class="role-card"
:class="{ on: selectedRole?.id === role.id }"
@click="pickRole(role.id)"
>
<span class="pill" :data-color="role.color">{{ role.name }}</span>
<p>{{ role.description || 'بدون توضیح' }}</p>
<small>{{ toFaDigits(role.userCount) }} کاربر</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">{{ role.name }}</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 ? 'ویرایش نقش' : 'نقش جدید'" @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>
رنگ
<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;
}
.kebab {
position: relative;
}
.menu {
position: absolute;
inset-inline-start: 0;
top: 100%;
z-index: 5;
min-width: 9rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.12);
padding: 0.35rem;
}
.menu button {
width: 100%;
appearance: none;
background: none;
border: 0;
text-align: right;
padding: 0.45rem 0.55rem;
border-radius: 8px;
font: inherit;
cursor: pointer;
}
.menu button:hover {
background: #f8fafc;
}
.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;
padding: 0.85rem 1rem;
}
.user-card .pill {
margin-top: 0.35rem;
}
.roles-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.role-card {
text-align: right;
padding: 1rem;
cursor: pointer;
font: inherit;
}
.role-card.on {
border-color: #99f6e4;
box-shadow: 0 0 0 2px #ccfbf1;
}
.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 {
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>