Files
CRM_Montzar/apps/web/src/views/TeamSettingsView.vue
T
MahdiandCursor 25a24772fc feat(access): keep a single owner per organization
The person who registered stays the only owner. Full day-to-day access is granted through the manager role instead of a second owner.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 14:59:32 +03:30

2684 lines
74 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 { useEscClose } from '@/lib/modal-stack';
import { matchesSearch } from '@/lib/search';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import JalaliDateInput from '@/components/JalaliDateInput.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 {
DATA_ACCESS_SECTIONS,
ROLE_PERMISSION_ITEMS,
type DataAccessPolicy,
type FinancialAccountDto,
type InviteUserRequest,
type OrganizationDto,
type OrgModuleKey,
type OrgRoleDto,
type OrgUserDto,
type RolePermissionKey,
type UpdateOrganizationRequest,
type UpdateOrgUserRequest,
} 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';
import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue';
type TabKey = 'users' | 'roles' | 'access' | 'teams';
type RoleColor = 'teal' | 'violet' | 'rose' | 'sky' | 'amber' | 'slate' | 'pink';
const PAGE_SIZE = 10;
const 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 PERMISSION_MODULES: Partial<Record<RolePermissionKey, OrgModuleKey[]>> = {
customers: ['customers'],
assign_customers: ['customers'],
leads: ['leads'],
assign_leads: ['leads'],
sales: ['pipeline'],
assign_sales: ['pipeline'],
billing: ['invoices', 'quotes'],
products: ['products'],
activities: ['activities'],
assign_activities: ['activities'],
assign_routines: ['routines'],
assign_projects: ['project_boards'],
assign_tickets: ['tickets'],
notes: ['notes'],
reports: ['reports'],
audit: ['audit_logs'],
conversations: ['conversations'],
conversations_groups: ['conversations'],
treasury: ['treasury'],
treasury_dashboard: ['treasury'],
treasury_incomes: ['treasury'],
treasury_expenses: ['treasury'],
treasury_receivables: ['treasury'],
treasury_accounts: ['treasury'],
treasury_payroll: ['treasury'],
treasury_recurring: ['treasury'],
treasury_reports: ['treasury'],
treasury_audit: ['treasury'],
treasury_categories: ['treasury'],
behavior: ['behavior'],
};
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 visibleRolePermissions = computed(() => {
const enabled = org.value?.enabledModules ?? auth.user?.enabledModules;
if (!enabled) return ROLE_PERMISSION_ITEMS;
return ROLE_PERMISSION_ITEMS.filter((item) => {
const modules = PERMISSION_MODULES[item.key];
if (!modules?.length) return true;
return modules.some((module) => enabled.includes(module));
});
});
const orgName = ref('');
const businessField = ref('');
const orgSaving = ref(false);
const accessSaving = ref(false);
const roleFormDataAccess = ref<DataAccessPolicy>({});
const focusDataAccess = ref<DataAccessPolicy>({});
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);
useEscClose(() => isNarrow.value && selectedRoleId.value, () => { selectedRoleId.value = null; });
const inviteOpen = ref(false);
const inviteSaving = ref(false);
const inviteName = ref('');
const inviteMobile = ref('');
const invitePassword = ref('');
const inviteRoleId = ref('');
const editOpen = ref(false);
const editSaving = ref(false);
const editUserId = ref<string | null>(null);
const editName = ref('');
const editMobile = ref('');
const editPassword = ref('');
const editRoleLabel = ref('');
const editRoleId = ref('');
const editInitialRoleId = ref('');
/** نقش مالک قفل است؛ مالک همان ثبت‌نام‌کننده می‌ماند */
const editRoleLocked = ref(false);
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 roleFormAccountIds = ref<string[]>([]);
const roleFormOwnAccounts = ref(false);
const roleFormAccounts = ref<Array<{ id: string; name: string; responsibleName: string }>>([]);
const roleAccountQuery = ref('');
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 canSetLogoutTime = computed(() => auth.user?.role === 'owner');
const canEditOrg = computed(() => canManageSettings(auth.user?.role));
const canEditRoles = computed(() => canManageUsers(auth.user?.role));
/** مالک واقعی می‌تواند از جدول کاربران وارد مشاهده شود / برگردد */
const canViewAsUsers = computed(
() =>
auth.user?.role === 'owner' || Boolean(auth.user?.viewAs?.canOpenTeamSettings),
);
const viewAsBusy = ref(false);
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) {
const visible = visibleRolePermissions.value;
const owned = visible.filter((item) => role.permissions.includes(item.key)).length;
return `${toFaDigits(owned)} از ${toFaDigits(visible.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) {
list = list.filter((u) => matchesSearch(q, [u.fullName, u.mobile, u.email, u.orgRoleName]));
}
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'),
);
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;
}
async function onUserActivate(user: OrgUserDto) {
if (!canViewAsUsers.value) {
if (user.orgRoleId) pickRole(user.orgRoleId);
return;
}
const impersonatorId = auth.user?.viewAs?.impersonatorId;
if (impersonatorId && user.id === impersonatorId) {
await switchViewAs(user.id);
return;
}
if (user.id === auth.user?.id) {
if (user.orgRoleId) pickRole(user.orgRoleId);
return;
}
await switchViewAs(user.id);
}
async function switchViewAs(targetUserId: string) {
if (viewAsBusy.value) return;
viewAsBusy.value = true;
error.value = '';
ok.value = '';
try {
await auth.viewAs(targetUserId);
ok.value = auth.user?.viewAs
? `در حال مشاهده به‌جای ${auth.user.fullName}`
: 'به حساب خودتان برگشتید.';
} catch (e) {
error.value = apiError(e, 'تغییر حالت مشاهده ناموفق بود.');
} finally {
viewAsBusy.value = false;
}
}
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;
}
}
function openEditUser(user: OrgUserDto) {
menuUserId.value = null;
editUserId.value = user.id;
editName.value = user.fullName;
editMobile.value = user.mobile ?? '';
editPassword.value = '';
editRoleLabel.value = user.orgRoleName || '—';
editRoleId.value = user.orgRoleId ?? '';
editInitialRoleId.value = user.orgRoleId ?? '';
editRoleLocked.value = user.role === 'owner';
editOpen.value = true;
error.value = '';
ok.value = '';
}
async function submitEditUser() {
if (!editUserId.value) return;
if (!editName.value.trim() || !editMobile.value.trim()) {
error.value = 'نام و شماره موبایل لازم است.';
return;
}
if (editPassword.value && editPassword.value.length < 8) {
error.value = 'رمز عبور در صورت ورود باید حداقل ۸ کاراکتر باشد.';
return;
}
editSaving.value = true;
error.value = '';
try {
const payload: UpdateOrgUserRequest = {
fullName: editName.value.trim(),
mobile: editMobile.value.trim(),
...(editPassword.value ? { password: editPassword.value } : {}),
};
await api.patch(`/users/${editUserId.value}`, payload);
if (!editRoleLocked.value && editRoleId.value && editRoleId.value !== editInitialRoleId.value) {
await api.patch(`/users/${editUserId.value}/role`, { orgRoleId: editRoleId.value });
}
editOpen.value = false;
ok.value = 'مشخصات کاربر ذخیره شد.';
await load();
} catch (e) {
error.value = apiError(e, 'ویرایش کاربر ناموفق بود');
} finally {
editSaving.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, 'تغییر وضعیت ناموفق بود');
}
}
const LOGIN_OTP_ONLY_TIP =
'ورود فقط با کد پیامکی؛ رمز عبور کار نمی‌کند — حتی اگر از قبل رمز داشته باشد.';
async function toggleLoginOtpOnly(user: OrgUserDto) {
menuUserId.value = null;
if (user.role === 'owner') return;
error.value = '';
ok.value = '';
try {
const next = !user.loginOtpOnly;
await api.patch(`/users/${user.id}/login-otp-only`, { loginOtpOnly: next });
ok.value = next
? `«${user.fullName}» فقط با کد پیامکی وارد می‌شود.`
: `ورود با رمز برای «${user.fullName}» دوباره فعال شد.`;
await load();
} catch (e) {
error.value = apiError(e, 'تغییر شیوهٔ ورود ناموفق بود');
}
}
const logoutModal = ref<{ user: OrgUserDto; time: string } | null>(null);
const logoutSaving = ref(false);
function openLogoutModal(user: OrgUserDto) {
if (!canSetLogoutTime.value || user.role === 'owner') return;
logoutModal.value = { user, time: user.logoutTime || '18:00' };
}
async function changeLogoutTime(user: OrgUserDto, raw: string) {
if (!canSetLogoutTime.value || user.role === 'owner') return;
const next = raw.trim();
if (next === (user.logoutTime ?? '')) {
logoutModal.value = null;
return;
}
error.value = '';
ok.value = '';
logoutSaving.value = true;
try {
await api.patch(`/users/${user.id}/logout-time`, { logoutTime: next || null });
ok.value = next
? `ساعت خروج «${user.fullName}» روی ${toFaDigits(next)} تنظیم شد.`
: `محدودیت خروج «${user.fullName}» برداشته شد.`;
logoutModal.value = null;
await load();
} catch (e) {
error.value = apiError(e, 'ذخیره ساعت خروج ناموفق بود');
} finally {
logoutSaving.value = false;
}
}
async function toggleBlockAfterLogout(user: OrgUserDto) {
if (!canSetLogoutTime.value || user.role === 'owner' || !user.logoutTime) return;
const next = !user.blockLoginAfterLogout;
error.value = '';
ok.value = '';
try {
await api.patch(`/users/${user.id}/logout-time`, {
logoutTime: user.logoutTime,
blockLoginAfterLogout: next,
});
ok.value = next
? `«${user.fullName}» بعد از ساعت ${toFaDigits(user.logoutTime)} دیگر نمی‌تواند وارد شود.`
: `«${user.fullName}» بعد از ساعت خروج هم می‌تواند وارد شود.`;
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]
: visibleRolePermissions.value.map((p) => p.key);
roleFormDataAccess.value = clonePolicy(parent?.dataAccessPolicy);
roleFormAccountIds.value = [...(parent?.treasuryAccountIds ?? [])];
roleFormOwnAccounts.value = Boolean(parent?.treasuryOwnAccounts);
roleAccountQuery.value = '';
roleFormOpen.value = true;
void loadRoleAccounts();
}
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];
roleFormDataAccess.value = clonePolicy(role.dataAccessPolicy);
roleFormAccountIds.value = [...(role.treasuryAccountIds ?? [])];
roleFormOwnAccounts.value = Boolean(role.treasuryOwnAccounts);
roleAccountQuery.value = '';
roleFormOpen.value = true;
void loadRoleAccounts();
}
function isTreasuryDetail(key: RolePermissionKey) {
return key.startsWith('treasury_');
}
const filteredRoleAccounts = computed(() => {
const query = roleAccountQuery.value.trim();
if (!query) return roleFormAccounts.value;
return roleFormAccounts.value.filter((account) => account.name.includes(query));
});
async function loadRoleAccounts() {
try {
const { data } = await api.get<FinancialAccountDto[]>('/treasury/accounts');
roleFormAccounts.value = data.map((account) => ({
id: account.id,
name: account.name,
responsibleName: account.responsibleName?.trim() ?? '',
}));
} catch {
roleFormAccounts.value = [];
}
}
function toggleRoleAccount(id: string) {
roleFormAccountIds.value = roleFormAccountIds.value.includes(id)
? roleFormAccountIds.value.filter((item) => item !== id)
: [...roleFormAccountIds.value, id];
}
function permOn(key: RolePermissionKey) {
return roleFormPerms.value.includes(key);
}
function toggleFormPerm(key: RolePermissionKey) {
if (key === 'treasury' && permOn('treasury')) {
roleFormPerms.value = roleFormPerms.value.filter(
(item) => item !== 'treasury' && !item.startsWith('treasury_'),
);
roleFormAccountIds.value = [];
roleFormOwnAccounts.value = false;
return;
}
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[];
dataAccessPolicy?: OrgRoleDto['dataAccessPolicy'];
parentId?: string | null;
treasuryAccountIds?: string[];
treasuryOwnAccounts?: boolean;
} = {
name: roleFormName.value.trim(),
description: roleFormDesc.value.trim(),
color: roleFormColor.value,
permissions: roleFormPerms.value,
dataAccessPolicy: roleFormDataAccess.value,
treasuryAccountIds: roleFormAccountIds.value,
treasuryOwnAccounts: roleFormOwnAccounts.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 clonePolicy(policy?: DataAccessPolicy | null): DataAccessPolicy {
const src = policy ?? {};
const out: DataAccessPolicy = {};
for (const [key, section] of Object.entries(src)) {
if (!section) continue;
out[key as keyof DataAccessPolicy] = {
mode: section.mode,
rules: (section.rules ?? []).map((r) => ({ ...r })),
};
}
return out;
}
function withOpenSections(policy: DataAccessPolicy): DataAccessPolicy {
const out = clonePolicy(policy);
for (const key of DATA_ACCESS_SECTIONS) {
if (!out[key]) out[key] = { mode: 'all', rules: [] };
}
return out;
}
function syncFocusDataAccess() {
focusDataAccess.value = clonePolicy(accessFocusRole.value?.dataAccessPolicy);
}
async function saveFocusDataAccess() {
const role = accessFocusRole.value;
if (!role || !canEditRoles.value) return;
accessSaving.value = true;
error.value = '';
try {
const dataAccessPolicy = withOpenSections(focusDataAccess.value);
focusDataAccess.value = dataAccessPolicy;
await api.patch(`/org-roles/${role.id}`, { dataAccessPolicy });
ok.value = `محدوده داده برای نقش «${role.name}» ذخیره شد.`;
await load();
syncFocusDataAccess();
} catch (e) {
error.value = apiError(e, 'ذخیره محدوده داده ناموفق بود');
} 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;
}
syncFocusDataAccess();
},
{ immediate: true },
);
watch(accessFocusId, () => {
syncFocusDataAccess();
});
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 visibleRolePermissions" :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 visibleRolePermissions" :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>
<div v-if="accessFocusRole" class="data-access-block">
<div class="matrix-head">
<h3>محدوده داده — {{ accessFocusRole.name }}</h3>
<button
v-if="canEditRoles"
type="button"
class="btn primary"
:disabled="accessSaving"
@click="saveFocusDataAccess"
>
{{ accessSaving ? 'در حال ذخیره…' : 'ذخیره محدوده' }}
</button>
</div>
<p class="hint">
برای هر بخش می‌توانید همه را باز بگذارید یا با فیلتر (مالک، گروه، مرحله و…) محدود کنید.
</p>
<DataAccessPolicyPanel
v-model="focusDataAccess"
:enabled-permissions="accessFocusRole.permissions"
:disabled="!canEditRoles"
/>
</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 visibleRolePermissions" :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" aria-hidden="true" />
<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="tc-row desktop-table">
<TableColumnsMenu table-key="team.users" align="end" />
</div>
<div v-if="tab === 'users'" class="table-card desktop-table">
<table v-table-cols="'team.users'">
<thead>
<tr>
<th>نام و نام خانوادگی</th>
<th>موبایل</th>
<th>نقش</th>
<th>پورسانت %</th>
<th>وضعیت</th>
<th>
<button type="button" class="sort" @click="sortLoginDesc = !sortLoginDesc">
آخرین ورود
<span :class="{ flip: !sortLoginDesc }">▴</span>
</button>
</th>
<th v-if="canSetLogoutTime">ساعت خروج</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 class="muted" dir="ltr">{{ user.mobile || '—' }}</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>
<span
v-if="user.loginOtpOnly"
class="otp-badge"
:title="LOGIN_OTP_ONLY_TIP"
>
ورود با کد
</span>
</td>
<td class="muted">{{ formatLastLogin(user.lastLoginAt) }}</td>
<td v-if="canSetLogoutTime" @click.stop>
<span v-if="user.role === 'owner'" class="muted">—</span>
<span v-else class="logout-cell">
<button
type="button"
class="logout-btn"
:class="{ set: Boolean(user.logoutTime) }"
:aria-label="user.logoutTime ? `ویرایش ساعت خروج ${user.fullName}` : `تعیین ساعت خروج ${user.fullName}`"
:title="user.logoutTime ? 'ویرایش ساعت خروج' : 'تعیین ساعت خروج'"
@click="openLogoutModal(user)"
>
<span v-if="user.logoutTime" dir="ltr">{{ toFaDigits(user.logoutTime) }}</span>
<UiIcon v-else name="clock" :size="16" />
</button>
<button
v-if="user.logoutTime"
type="button"
class="block-btn"
:class="{ on: user.blockLoginAfterLogout }"
:aria-pressed="Boolean(user.blockLoginAfterLogout)"
:title="
user.blockLoginAfterLogout
? 'ورود بعد از ساعت خروج مسدود است — برای آزاد کردن بزنید'
: 'ورود بعد از ساعت خروج آزاد است — برای مسدود کردن بزنید'
"
@click="toggleBlockAfterLogout(user)"
>
<UiIcon :name="user.blockLoginAfterLogout ? 'check' : 'x'" :size="14" />
</button>
</span>
</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
v-if="canInvite"
type="button"
@click="openEditUser(user)"
>
ویرایش مشخصات
</button>
<button
v-if="canInvite && user.role !== 'owner'"
type="button"
:class="{ on: user.loginOtpOnly }"
:title="LOGIN_OTP_ONLY_TIP"
@click="toggleLoginOtpOnly(user)"
>
{{ user.loginOtpOnly ? 'لغو ورود با کد' : 'ورود با کد' }}
</button>
<button type="button" @click="toggleStatus(user)">
{{ user.isActive === false ? 'فعال کردن' : 'غیرفعال کردن' }}
</button>
</UiDropdownMenu>
</td>
</tr>
<tr v-if="!loading && !pagedUsers.length">
<td colspan="7" 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>
<p v-if="user.mobile" class="muted mobile-line" dir="ltr">{{ user.mobile }}</p>
</div>
</div>
<div class="card-meta" @click.stop>
<label class="comm-field">
<span>پورسانت %</span>
<input
type="number"
min="0"
max="100"
step="0.1"
dir="ltr"
:value="user.commissionPercent ?? 0"
:disabled="!canInvite"
:aria-label="`پورسانت ${user.fullName}`"
@change="changeCommission(user, ($event.target as HTMLInputElement).value)"
/>
</label>
<span class="status" :class="{ off: user.isActive === false }">
<i />
{{ user.isActive === false ? 'غیرفعال' : 'فعال' }}
</span>
<button
v-if="canInvite"
type="button"
class="btn ghost"
@click="openEditUser(user)"
>
ویرایش
</button>
<button
v-if="canInvite && user.role !== 'owner'"
type="button"
class="btn ghost"
:class="{ 'otp-on': user.loginOtpOnly }"
:title="LOGIN_OTP_ONLY_TIP"
@click="toggleLoginOtpOnly(user)"
>
{{ user.loginOtpOnly ? 'لغو ورود با کد' : 'ورود با کد' }}
</button>
</div>
</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="Boolean(logoutModal)"
:title="logoutModal ? `ساعت خروج ${logoutModal.user.fullName}` : 'ساعت خروج'"
@close="logoutModal = null"
>
<form v-if="logoutModal" class="form" @submit.prevent="changeLogoutTime(logoutModal.user, logoutModal.time)">
<label>
ساعت خروج روزانه
<JalaliDateInput v-model="logoutModal.time" mode="time" :clearable="false" />
</label>
<p class="hint">
در این ساعت (به وقت تهران) کاربر خودکار از سامانه خارج می‌شود. با تیک کنار ساعت می‌توانید
ورود دوباره تا پایان همان روز را هم ببندید.
</p>
</form>
<template #footer>
<button
v-if="logoutModal?.user.logoutTime"
type="button"
class="btn ghost"
:disabled="logoutSaving"
@click="logoutModal && changeLogoutTime(logoutModal.user, '')"
>
حذف ساعت خروج
</button>
<button type="button" class="btn ghost" @click="logoutModal = null">انصراف</button>
<button
type="button"
class="btn primary"
:disabled="logoutSaving || !logoutModal?.time"
@click="logoutModal && changeLogoutTime(logoutModal.user, logoutModal.time)"
>
{{ logoutSaving ? 'در حال ذخیره…' : 'ذخیره' }}
</button>
</template>
</UiModal>
<UiModal :open="editOpen" title="ویرایش مشخصات کاربر" @close="editOpen = false">
<form class="form" @submit.prevent="submitEditUser">
<label>نام و نام خانوادگی<input v-model="editName" required minlength="2" /></label>
<label>
شماره موبایل
<input v-model="editMobile" dir="ltr" inputmode="tel" placeholder="0912…" required />
</label>
<label>
رمز عبور جدید
<input
v-model="editPassword"
type="password"
minlength="8"
placeholder="خالی بگذارید اگر تغییر نمی‌دهید"
/>
</label>
<label v-if="!editRoleLocked">
نقش
<select v-model="editRoleId">
<option v-for="role in inviteableRoles" :key="role.id" :value="role.id">
{{ roleTreeLabel(role) }}
</option>
</select>
</label>
<p v-else class="hint">نقش فعلی: {{ editRoleLabel }} — مالک همان کسی است که ثبت‌نام کرده و عوض نمی‌شود. برای دسترسی کامل نقش مدیر را بدهید.</p>
</form>
<template #footer>
<button type="button" class="btn ghost" @click="editOpen = false">انصراف</button>
<button type="button" class="btn primary" :disabled="editSaving" @click="submitEditUser">
{{ editSaving ? 'در حال ذخیره…' : 'ذخیره' }}
</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>
<p class="hint">
تیک مدیریت مالی در را باز می‌کند. بخش‌های ریز جدا انتخاب می‌شوند و با روشن کردن مدیریت مالی خودبه‌خود تیک نمی‌خورند.
تا وقتی هیچ بخش ریزی ذخیره نشده، همه بخش‌ها به‌جز حقوق باز می‌مانند. با ذخیره حتی یک بخش، فقط همان‌ها می‌مانند. حقوق جداست.
</p>
<label
v-for="perm in visibleRolePermissions"
v-show="!isTreasuryDetail(perm.key) || permOn('treasury')"
:key="perm.key"
class="perm-row"
:class="{ 'perm-sub': isTreasuryDetail(perm.key) }"
>
<input type="checkbox" :checked="permOn(perm.key)" @change="toggleFormPerm(perm.key)" />
{{ perm.label }}
</label>
</fieldset>
<fieldset v-if="permOn('treasury')" class="perm-set">
<legend>دسترسی حساب‌ها</legend>
<p class="hint">
این بخش جدا از تیک بخش‌های مالی است. می‌توانید حساب‌هایی را بدهید که خود عضو مسئول آن‌هاست،
و جدا از آن حساب‌های دیگر را هم انتخاب کنید. اگر هیچ‌کدام انتخاب نشود، همه حساب‌ها باز می‌ماند.
</p>
<label class="perm-row">
<input
type="checkbox"
:checked="roleFormOwnAccounts"
@change="roleFormOwnAccounts = !roleFormOwnAccounts"
/>
حساب‌هایی که خودش مسئول آن‌هاست
</label>
<p class="hint">حساب‌های دیگر</p>
<input v-model="roleAccountQuery" placeholder="جستجوی حساب" />
<label v-for="account in filteredRoleAccounts" :key="account.id" class="perm-row">
<input
type="checkbox"
:checked="roleFormAccountIds.includes(account.id)"
@change="toggleRoleAccount(account.id)"
/>
{{ account.name }}
<template v-if="account.responsibleName"> — مسئول: {{ account.responsibleName }}</template>
</label>
<p v-if="!roleFormAccounts.length" class="hint">حسابی برای انتخاب نیست.</p>
</fieldset>
<fieldset class="perm-set">
<legend>محدوده داده</legend>
<DataAccessPolicyPanel
v-model="roleFormDataAccess"
:enabled-permissions="roleFormPerms"
compact
/>
</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;
}
.data-access-block {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #eef2f7;
display: grid;
gap: 0.75rem;
}
.data-access-block .hint {
color: #64748b;
font-size: 0.85rem;
margin: 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;
min-width: 0;
}
.search input {
flex: 1;
width: 100%;
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;
}
.otp-badge {
display: inline-block;
margin-inline-start: 0.4rem;
padding: 0.12rem 0.45rem;
border-radius: 999px;
background: #ecfdf5;
color: #0f766e;
font-size: 0.72rem;
font-weight: 700;
vertical-align: middle;
cursor: help;
}
.btn.otp-on {
color: #0f766e;
border-color: #99f6e4;
background: #ecfdf5;
}
.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;
}
.mobile-line {
margin: 0.2rem 0 0;
font-size: 0.78rem;
}
.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;
}
.logout-cell {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.logout-btn,
.block-btn {
display: inline-grid;
place-items: center;
height: 1.9rem;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
color: #64748b;
font: inherit;
cursor: pointer;
}
.logout-btn {
min-width: 1.9rem;
padding: 0 0.4rem;
}
.logout-btn.set {
color: #0f172a;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.logout-btn:hover,
.block-btn:hover {
border-color: #0d9488;
color: #0f766e;
}
.block-btn {
width: 1.9rem;
color: #94a3b8;
}
.block-btn.on {
border-color: #99f6e4;
background: #f0fdfa;
color: #0f766e;
}
.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;
}
.perm-sub {
padding-inline-start: 1.4rem;
}
.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>