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

1782 lines
43 KiB
Vue
Raw Normal View History

<script setup lang="ts">
2026-09-15 17:33:05 +03:30
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';
2026-09-15 17:33:05 +03:30
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';
2026-09-15 17:33:05 +03:30
import { canManageSettings, canManageUsers } from '@/lib/roles';
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
2026-09-15 17:33:05 +03:30
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();
2026-09-15 17:33:05 +03:30
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const error = ref('');
2026-09-15 17:33:05 +03:30
const ok = ref('');
const users = ref<OrgUserDto[]>([]);
2026-09-15 17:33:05 +03:30
const roles = ref<OrgRoleDto[]>([]);
const org = ref<OrganizationDto | null>(null);
const orgName = ref('');
const businessField = ref('');
const orgSaving = ref(false);
2026-09-15 17:33:05 +03:30
const accessSaving = ref(false);
2026-09-15 17:33:05 +03:30
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);
2026-09-15 17:33:05 +03:30
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));
2026-09-15 17:33:05 +03:30
const canEditRoles = computed(() => canManageUsers(auth.user?.role));
2026-09-15 17:33:05 +03:30
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 {
2026-09-15 17:33:05 +03:30
const [usersRes, rolesRes, orgRes] = await Promise.all([
api.get<OrgUserDto[]>('/users'),
2026-09-15 17:33:05 +03:30
api.get<OrgRoleDto[]>('/org-roles'),
api.get<OrganizationDto>('/organizations/current'),
]);
users.value = usersRes.data;
2026-09-15 17:33:05 +03:30
roles.value = rolesRes.data;
org.value = orgRes.data;
orgName.value = orgRes.data.name;
businessField.value = orgRes.data.businessField ?? '';
2026-09-15 17:33:05 +03:30
if (!isNarrow.value) ensureDesktopRole();
await loadAvatars([
...users.value.map((u) => ({ id: u.id, hasAvatar: u.hasAvatar })),
...roles.value.flatMap((r) => r.members),
]);
} catch (e) {
2026-09-15 17:33:05 +03:30
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 = '';
2026-09-15 17:33:05 +03:30
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 ?? '';
2026-09-15 17:33:05 +03:30
ok.value = 'مشخصات سازمان ذخیره شد.';
} catch (e) {
2026-09-15 17:33:05 +03:30
error.value = apiError(e, 'ذخیره سازمان ناموفق بود');
} finally {
orgSaving.value = false;
}
}
2026-09-15 17:33:05 +03:30
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;
}
2026-09-15 17:33:05 +03:30
inviteSaving.value = true;
error.value = '';
try {
const payload: InviteUserRequest = {
2026-09-15 17:33:05 +03:30
mobile: inviteMobile.value.trim(),
fullName: inviteName.value.trim(),
orgRoleId: inviteRoleId.value || undefined,
...(invitePassword.value ? { password: invitePassword.value } : {}),
};
await api.post('/users', payload);
2026-09-15 17:33:05 +03:30
inviteOpen.value = false;
ok.value = 'کاربر به این محیط اضافه شد.';
await load();
} catch (e) {
2026-09-15 17:33:05 +03:30
error.value = apiError(e, 'افزودن کاربر ناموفق بود');
} finally {
2026-09-15 17:33:05 +03:30
inviteSaving.value = false;
}
}
2026-09-15 17:33:05 +03:30
async function toggleStatus(user: OrgUserDto) {
menuUserId.value = null;
error.value = '';
try {
2026-09-15 17:33:05 +03:30
await api.patch(`/users/${user.id}/status`, { isActive: user.isActive === false });
await load();
} catch (e) {
2026-09-15 17:33:05 +03:30
error.value = apiError(e, 'تغییر وضعیت ناموفق بود');
}
}
2026-09-15 17:33:05 +03:30
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 {
2026-09-15 17:33:05 +03:30
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) {
2026-09-15 17:33:05 +03:30
error.value = apiError(e, 'ذخیره نقش ناموفق بود');
} finally {
roleFormSaving.value = false;
}
}
2026-09-15 17:33:05 +03:30
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">
2026-09-15 17:33:05 +03:30
<h1>
<span class="title-ico"><UiIcon name="users" :size="22" /></span>
مدیریت کاربران
</h1>
<p class="lead">کاربران سامانه را مدیریت کنید و نقش‌های مختلف را برای آن‌ها تعریف کنید.</p>
</div>
</header>
2026-09-15 17:33:05 +03:30
<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>
2026-09-15 17:33:05 +03:30
<p v-if="ok" class="ok">{{ ok }}</p>
2026-09-15 17:33:05 +03:30
<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>
نقش
2026-09-15 17:33:05 +03:30
<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>
2026-09-15 17:33:05 +03:30
<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>
2026-09-15 17:33:05 +03:30
</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;
2026-09-15 17:33:05 +03:30
justify-content: flex-start;
width: 100%;
}
.head-copy {
2026-09-15 17:33:05 +03:30
text-align: right;
width: 100%;
}
h1 {
2026-09-15 17:33:05 +03:30
margin: 0 0 0.35rem;
font-size: clamp(1.35rem, 2.2vw, 1.7rem);
font-weight: 800;
2026-09-15 17:33:05 +03:30
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,
2026-09-15 17:33:05 +03:30
.muted,
.hint,
2026-09-15 17:33:05 +03:30
.empty {
margin: 0;
2026-09-15 17:33:05 +03:30
color: #94a3b8;
font-size: 0.92rem;
}
.error {
margin: 0;
color: #b91c1c;
}
.ok {
margin: 0;
color: #0f766e;
font-weight: 600;
}
2026-09-15 17:33:05 +03:30
.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;
2026-09-15 17:33:05 +03:30
border-radius: 12px;
min-height: 42px;
padding: 0 0.85rem;
color: #94a3b8;
}
2026-09-15 17:33:05 +03:30
.search input {
flex: 1;
border: 0;
outline: none;
font: inherit;
background: transparent;
min-width: 0;
}
.stats {
display: grid;
2026-09-15 17:33:05 +03:30
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
2026-09-15 17:33:05 +03:30
margin-bottom: 0.85rem;
}
2026-09-15 17:33:05 +03:30
.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;
2026-09-15 17:33:05 +03:30
place-items: center;
}
.stat-ico.users {
background: #eef2ff;
color: #6366f1;
}
.stat-ico.on {
background: #dcfce7;
color: #16a34a;
}
.stat-ico.off {
background: #f1f5f9;
color: #64748b;
}
2026-09-15 17:33:05 +03:30
.table-card,
.role-panel,
.card,
.user-card,
.role-card {
background: #fff;
2026-09-15 17:33:05 +03:30
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;
2026-09-15 17:33:05 +03:30
gap: 0.4rem;
min-height: 42px;
padding: 0 1rem;
border-radius: 12px;
font: inherit;
font-weight: 800;
border: 1px solid transparent;
cursor: pointer;
2026-09-15 17:33:05 +03:30
}
.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;
}
.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;
}
2026-09-15 17:33:05 +03:30
.role-card.on {
border-color: #99f6e4;
box-shadow: 0 0 0 2px #ccfbf1;
}
2026-09-15 17:33:05 +03:30
.access-wrap,
.org-grid,
.form {
display: grid;
gap: 1rem;
}
2026-09-15 17:33:05 +03:30
.card {
padding: 1.1rem;
}
2026-09-15 17:33:05 +03:30
.card h2 {
margin: 0 0 0.85rem;
font-size: 1rem;
}
2026-09-15 17:33:05 +03:30
.org-card {
display: grid;
gap: 1.15rem;
}
.org-card h2 {
margin: 0;
2026-09-15 17:33:05 +03:30
}
.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;
}
2026-09-15 17:33:05 +03:30
input,
select,
textarea {
border: 1px solid #e2e8f0;
border-radius: 12px;
min-height: 42px;
padding: 0.55rem 0.85rem;
font: inherit;
background: #fff;
}
2026-09-15 17:33:05 +03:30
.matrix-head {
display: flex;
justify-content: space-between;
align-items: center;
}
2026-09-15 17:33:05 +03:30
.matrix-mobile {
display: none;
}
2026-09-15 17:33:05 +03:30
.matrix-scroll {
overflow: auto;
}
2026-09-15 17:33:05 +03:30
.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;
}
2026-09-15 17:33:05 +03:30
.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;
2026-09-15 17:33:05 +03:30
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;
2026-09-15 17:33:05 +03:30
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>