Files
CRM_Montzar/apps/web/src/views/TeamSettingsView.vue
T
MahdiandCursor cb3a488b21 feat(web): continue Ant settings migration and tighten page chrome
Migrate remaining settings screens to Ant Design Vue, densify teams/roles UI, drop page-header subtitles project-wide, and polish notes cards plus contacts row actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 10:05:28 +03:30

1709 lines
59 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 { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
import {
DATA_ACCESS_SECTIONS,
ROLE_PERMISSION_ITEMS,
type DataAccessPolicy,
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 RoleDetailPanel from '@/components/RoleDetailPanel.vue';
import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue';
import {
Alert as AAlert,
Avatar as AAvatar,
Button as AButton,
Card as ACard,
Checkbox as ACheckbox,
Col as ACol,
Drawer as ADrawer,
Dropdown as ADropdown,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
InputNumber as AInputNumber,
InputPassword as AInputPassword,
List as AList,
ListItem as AListItem,
ListItemMeta as AListItemMeta,
Menu as AMenu,
MenuItem as AMenuItem,
Modal as AModal,
Pagination as APagination,
RadioButton as ARadioButton,
RadioGroup as ARadioGroup,
Row as ARow,
Select as ASelect,
Space as ASpace,
Spin as ASpin,
Statistic as AStatistic,
Table as ATable,
Tabs as ATabs,
TabPane as ATabPane,
Tag as ATag,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import {
CheckOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
MoreOutlined,
PlusOutlined,
SearchOutlined,
StopOutlined,
TeamOutlined,
} from '@ant-design/icons-vue';
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
Typography;
type TabKey = 'users' | 'roles' | 'access' | 'teams';
type RoleColor = 'teal' | 'violet' | 'rose' | 'sky' | 'amber' | 'slate' | 'pink';
const USER_COL_LABELS = [
'نام و نام خانوادگی',
'موبایل',
'نقش',
'پورسانت %',
'وضعیت',
'آخرین ورود',
'ساعت خروج',
'عملیات',
] as const;
registerTableColumns('team.users', [...USER_COL_LABELS]);
const tableCols = useTableColumns(() => 'team.users');
const PAGE_SIZE = 10;
function roleTagColor(color: string | null | undefined) {
switch (color) {
case 'teal':
return 'cyan';
case 'violet':
return 'purple';
case 'rose':
return 'magenta';
case 'sky':
return 'blue';
case 'amber':
return 'orange';
case 'pink':
return 'pink';
case 'slate':
default:
return 'default';
}
}
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_payroll: ['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 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 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 selectedChildRoles = computed(() => {
const id = selectedRole.value?.id;
if (!id) return [] as OrgRoleDto[];
return roles.value.filter((r) => r.parentId === id);
});
/** گزینه‌های والد در فرم نقش — خود نقش و زیرنقش‌هایش حذف می‌شوند */
const parentRoleOptions = computed(() => {
const editingId = roleFormId.value;
const blocked = new Set<string>();
if (editingId) {
blocked.add(editingId);
const queue = [editingId];
while (queue.length) {
const current = queue.shift()!;
for (const role of roles.value) {
if (role.parentId === current && !blocked.has(role.id)) {
blocked.add(role.id);
queue.push(role.id);
}
}
}
}
return roles.value.filter((r) => !blocked.has(r.id) && (r.depth ?? 0) < 4);
});
const inviteableRoles = computed(() =>
roles.value.filter((r) => r.systemKey !== 'owner' || auth.user?.role === 'owner'),
);
function roleTreeLabel(role: OrgRoleDto) {
const depth = role.depth ?? 0;
const prefix = depth > 0 ? `${'— '.repeat(depth)}` : '';
return `${prefix}${role.name}`;
}
function tabFromHash(hash: string): TabKey {
if (hash.includes('settings-org') || hash.includes('access')) return 'access';
if (hash.includes('roles')) return 'roles';
if (hash.includes('teams') || hash.includes('settings-teams')) return 'teams';
return 'users';
}
function setTab(next: TabKey) {
tab.value = next;
const hash =
next === 'access'
? 'settings-org'
: next === 'roles'
? 'roles'
: next === 'teams'
? 'settings-teams'
: 'settings-team';
if (route.hash.replace('#', '') !== hash) {
void router.replace({ hash: `#${hash}` });
}
if (next !== 'users' && next !== 'roles') closeSheet();
}
function closeSheet() {
if (isNarrow.value) selectedRoleId.value = null;
}
function ensureDesktopRole() {
if (isNarrow.value) return;
if (selectedRoleId.value && roles.value.some((r) => r.id === selectedRoleId.value)) return;
const sales = roles.value.find((r) => r.systemKey === 'sales') ?? roles.value[0];
selectedRoleId.value = sales?.id ?? null;
}
function pickRole(id: string) {
selectedRoleId.value = id;
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' && auth.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);
roleFormOpen.value = true;
}
function openCreateChildRole() {
if (!selectedRole.value) return;
openCreateRole(selectedRole.value.id);
}
function openEditRole() {
const role = selectedRole.value;
if (!role) return;
roleFormId.value = role.id;
roleFormName.value = role.name;
roleFormDesc.value = role.description;
roleFormColor.value = (ROLE_COLOR_OPTIONS.some((c) => c.value === role.color)
? role.color
: 'teal') as RoleColor;
roleFormParentId.value = role.systemKey ? '' : (role.parentId ?? '');
roleFormPerms.value = [...role.permissions];
roleFormDataAccess.value = clonePolicy(role.dataAccessPolicy);
roleFormOpen.value = true;
}
function permOn(key: RolePermissionKey) {
return roleFormPerms.value.includes(key);
}
function toggleFormPerm(key: RolePermissionKey) {
roleFormPerms.value = permOn(key)
? roleFormPerms.value.filter((p) => p !== key)
: [...roleFormPerms.value, key];
}
async function submitRoleForm() {
if (roleFormName.value.trim().length < 2) {
error.value = 'نام نقش باید حداقل دو حرف باشد.';
return;
}
roleFormSaving.value = true;
error.value = '';
try {
const editing = roleFormId.value
? roles.value.find((r) => r.id === roleFormId.value)
: null;
const body: {
name: string;
description: string;
color: RoleColor;
permissions: RolePermissionKey[];
dataAccessPolicy?: OrgRoleDto['dataAccessPolicy'];
parentId?: string | null;
} = {
name: roleFormName.value.trim(),
description: roleFormDesc.value.trim(),
color: roleFormColor.value,
permissions: roleFormPerms.value,
dataAccessPolicy: roleFormDataAccess.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;
}
const businessFieldOptions = BUSINESS_FIELDS.map((field) => ({
value: field,
label: BUSINESS_FIELD_LABELS[field],
}));
const inviteRoleOptions = computed(() =>
inviteableRoles.value.map((role) => ({ value: role.id, label: roleTreeLabel(role) })),
);
const parentRoleSelectOptions = computed(() => [
{ value: '', label: 'بدون والد (نقش اصلی)' },
...parentRoleOptions.value.map((role) => ({ value: role.id, label: roleTreeLabel(role) })),
]);
const roleColorSelectOptions = ROLE_COLOR_OPTIONS.map((color) => ({
value: color.value,
label: color.label,
}));
const userColumns = computed<TableColumnsType<OrgUserDto>>(() => {
const all: TableColumnsType<OrgUserDto> = [
{ title: 'نام و نام خانوادگی', key: 'نام و نام خانوادگی' },
{ title: 'موبایل', key: 'موبایل' },
{ title: 'نقش', key: 'نقش' },
{ title: 'پورسانت %', key: 'پورسانت %' },
{ title: 'وضعیت', key: 'وضعیت' },
{
title: 'آخرین ورود',
key: 'آخرین ورود',
sorter: true,
sortOrder: sortLoginDesc.value ? 'descend' : 'ascend',
},
...(canSetLogoutTime.value
? [{ title: 'ساعت خروج', key: 'ساعت خروج' } as const]
: []),
{ title: 'عملیات', key: 'عملیات', width: 72 },
];
return all.filter((col) => {
const key = String(col.key ?? '');
if (!USER_COL_LABELS.includes(key as (typeof USER_COL_LABELS)[number])) return true;
if (key === 'ساعت خروج' && !canSetLogoutTime.value) return false;
return tableCols.isVisible(key);
});
});
type MatrixRow = { key: RolePermissionKey; label: string };
const matrixColumns = computed<TableColumnsType<MatrixRow>>(() => [
{ title: 'مجوز', key: 'perm', dataIndex: 'label', fixed: 'left', width: 200 },
...roles.value.map((role) => ({
title: roleTreeLabel(role),
key: role.id,
align: 'center' as const,
width: 120,
})),
]);
const matrixRows = computed<MatrixRow[]>(() =>
visibleRolePermissions.value.map((perm) => ({ key: perm.key, label: perm.label })),
);
function onUsersTableChange(_pag: unknown, _filters: unknown, sorter: unknown) {
const s = sorter as { order?: 'ascend' | 'descend' | null; columnKey?: string };
if (s?.columnKey === 'آخرین ورود' && s.order) {
sortLoginDesc.value = s.order === 'descend';
}
}
function onUserMenu(info: { key: string | number }, user: OrgUserDto) {
menuUserId.value = null;
const key = String(info.key);
if (key === 'edit') openEditUser(user);
else if (key === 'otp') void toggleLoginOtpOnly(user);
else if (key === 'status') void toggleStatus(user);
}
function onTabChange(key: string | number) {
setTab(String(key) as TabKey);
}
function onCommissionChange(user: OrgUserDto, value: string | number | null) {
if (value == null || value === '') return;
void changeCommission(user, String(value));
}
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>
<a-flex vertical :gap="16">
<a-typography-title :level="3" :style="{ margin: 0 }">
<TeamOutlined />
مدیریت کاربران
</a-typography-title>
<a-tabs :active-key="tab" @change="onTabChange">
<a-tab-pane key="users" tab="لیست کاربران" />
<a-tab-pane key="roles" tab="نقش‌ها" />
<a-tab-pane key="teams" tab="تیم‌ها و سطح دسترسی به پرونده‌ها" />
<a-tab-pane key="access" tab="تنظیمات دسترسی" />
</a-tabs>
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="ok" type="success" show-icon :message="ok" />
<TeamsSettingsPanel
v-if="tab === 'teams'"
:users="users"
:can-edit="canInvite"
@refresh-users="load"
/>
<a-flex v-else-if="tab === 'access'" vertical :gap="16">
<a-card title="سازمان" :bordered="false">
<a-form layout="vertical" @finish="saveOrg">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="نام سازمان">
<a-input v-model:value="orgName" :disabled="!canEditOrg" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="زمینه فعالیت">
<a-select
v-model:value="businessField"
:options="businessFieldOptions"
:disabled="!canEditOrg"
placeholder="انتخاب کنید"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-button v-if="canEditOrg" type="primary" html-type="submit" :loading="orgSaving">
ذخیره سازمان
</a-button>
</a-form>
</a-card>
<a-card :bordered="false">
<template #title>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<span>مجوزهای نقش‌ها</span>
<a-typography-text v-if="accessSaving" type="secondary">در حال ذخیره…</a-typography-text>
</a-flex>
</template>
<a-radio-group
v-if="roles.length"
:value="accessFocusId ?? undefined"
:style="{ marginBottom: '16px' }"
@update:value="(v: string) => (accessFocusId = v)"
>
<a-radio-button v-for="role in roles" :key="role.id" :value="role.id">
{{ roleTreeLabel(role) }}
<a-typography-text type="secondary"> · {{ permCountLabel(role) }}</a-typography-text>
</a-radio-button>
</a-radio-group>
<a-table
:columns="matrixColumns"
:data-source="matrixRows"
:pagination="false"
row-key="key"
:scroll="{ x: 'max-content' }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'perm'">
{{ (record as MatrixRow).label }}
</template>
<template v-else>
<a-checkbox
:checked="
Boolean(
roles
.find((r) => r.id === String(column.key))
?.permissions.includes((record as MatrixRow).key),
)
"
:disabled="!canEditRoles"
@change="
() => {
const role = roles.find((r) => r.id === String(column.key));
if (role) void toggleAccess(role, (record as MatrixRow).key);
}
"
/>
</template>
</template>
</a-table>
<a-flex
v-if="accessFocusRole"
vertical
:gap="12"
:style="{ marginTop: '24px' }"
>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-typography-title :level="5" :style="{ margin: 0 }">
محدوده داده — {{ accessFocusRole.name }}
</a-typography-title>
<a-button
v-if="canEditRoles"
type="primary"
:loading="accessSaving"
@click="saveFocusDataAccess"
>
ذخیره محدوده
</a-button>
</a-flex>
<a-typography-text type="secondary">
برای هر بخش می‌توانید همه را باز بگذارید یا با فیلتر (مالک، گروه، مرحله و…) محدود کنید.
</a-typography-text>
<DataAccessPolicyPanel
v-model="focusDataAccess"
:enabled-permissions="accessFocusRole.permissions"
:disabled="!canEditRoles"
/>
</a-flex>
</a-card>
</a-flex>
<template v-else-if="tab === 'users' || tab === 'roles'">
<a-row :gutter="[16, 16]">
<a-col v-if="selectedRole && !isNarrow" :xs="24" :lg="8">
<a-card :bordered="false">
<template #title>
<a-tag :color="roleTagColor(selectedRole.color)" :bordered="false">
{{ selectedRole.name }}
</a-tag>
</template>
<template v-if="canEditRoles" #extra>
<a-space>
<a-button
v-if="(selectedRole.depth ?? 0) < 4"
type="link"
@click="openCreateChildRole"
>
زیرنقش
</a-button>
<a-button type="link" @click="openEditRole">ویرایش</a-button>
<a-button
v-if="!selectedRole.systemKey"
type="link"
danger
@click="deleteOpen = true"
>
حذف
</a-button>
</a-space>
</template>
<RoleDetailPanel
:role="selectedRole"
:child-roles="selectedChildRoles"
:permission-items="visibleRolePermissions"
:avatar-of="avatarOf"
:tag-color="roleTagColor"
@pick-role="pickRole"
/>
</a-card>
</a-col>
<a-col :xs="24" :lg="selectedRole && !isNarrow ? 16 : 24">
<a-card :bordered="false">
<a-flex vertical :gap="16">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
<a-input
v-if="tab === 'users'"
v-model:value="query"
allow-clear
placeholder="جستجو در کاربران..."
style="width: 280px; max-width: 100%"
>
<template #prefix><SearchOutlined /></template>
</a-input>
<span v-else />
<a-button
v-if="canInvite && tab === 'users'"
type="primary"
@click="openInvite"
>
<template #icon><PlusOutlined /></template>
کاربر جدید
</a-button>
<a-button
v-else-if="canEditRoles && tab === 'roles'"
type="primary"
@click="openCreateRole()"
>
<template #icon><PlusOutlined /></template>
نقش جدید
</a-button>
</a-flex>
<a-row v-if="tab === 'users'" :gutter="[16, 16]">
<a-col :xs="24" :sm="8">
<a-card :bordered="true">
<a-statistic title="کل کاربران" :value="stats.total">
<template #prefix><TeamOutlined /></template>
</a-statistic>
</a-card>
</a-col>
<a-col :xs="24" :sm="8">
<a-card :bordered="true">
<a-statistic title="فعال" :value="stats.active">
<template #prefix><CheckOutlined /></template>
</a-statistic>
</a-card>
</a-col>
<a-col :xs="24" :sm="8">
<a-card :bordered="true">
<a-statistic title="غیرفعال" :value="stats.inactive">
<template #prefix><CloseCircleOutlined /></template>
</a-statistic>
</a-card>
</a-col>
</a-row>
<a-spin :spinning="loading">
<template v-if="tab === 'users'">
<a-flex
justify="space-between"
align="center"
:gap="12"
:style="{ marginBottom: '12px' }"
>
<a-typography-text type="secondary">{{ pageLabel }}</a-typography-text>
<TableColumnsMenu table-key="team.users" align="end" />
</a-flex>
<a-table
:columns="userColumns"
:data-source="pagedUsers"
:pagination="false"
row-key="id"
:scroll="{ x: 'max-content' }"
:custom-row="
(record: OrgUserDto) => ({
onClick: () => onUserActivate(record),
style: { cursor: 'pointer' },
})
"
@change="onUsersTableChange"
>
<template #emptyText>
<a-empty description="کاربری یافت نشد." />
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'نام و نام خانوادگی'">
<a-space>
<a-avatar :src="avatarOf((record as OrgUserDto).id)" />
<a-typography-text strong>
{{ (record as OrgUserDto).fullName }}
</a-typography-text>
</a-space>
</template>
<template v-else-if="column.key === 'موبایل'">
<a-typography-text type="secondary" dir="ltr">
{{ (record as OrgUserDto).mobile || '—' }}
</a-typography-text>
</template>
<template v-else-if="column.key === 'نقش'">
<a-tag
:color="roleTagColor((record as OrgUserDto).orgRoleColor)"
:bordered="false"
>
{{ (record as OrgUserDto).orgRoleName || '—' }}
</a-tag>
</template>
<template v-else-if="column.key === 'پورسانت %'">
<a-input-number
:value="(record as OrgUserDto).commissionPercent ?? 0"
:min="0"
:max="100"
:step="0.1"
:disabled="!canInvite"
style="width: 100%"
@click.stop
@change="(v) => onCommissionChange(record as OrgUserDto, v as string | number | null)"
/>
</template>
<template v-else-if="column.key === 'وضعیت'">
<a-space wrap>
<a-tag
:color="(record as OrgUserDto).isActive === false ? 'default' : 'success'"
:bordered="false"
>
{{ (record as OrgUserDto).isActive === false ? 'غیرفعال' : 'فعال' }}
</a-tag>
<a-tag
v-if="(record as OrgUserDto).loginOtpOnly"
color="processing"
:bordered="false"
:title="LOGIN_OTP_ONLY_TIP"
>
ورود با کد
</a-tag>
</a-space>
</template>
<template v-else-if="column.key === 'آخرین ورود'">
<a-typography-text type="secondary">
{{ formatLastLogin((record as OrgUserDto).lastLoginAt) }}
</a-typography-text>
</template>
<template v-else-if="column.key === 'ساعت خروج'">
<span @click.stop>
<a-typography-text
v-if="(record as OrgUserDto).role === 'owner'"
type="secondary"
>
—
</a-typography-text>
<a-space v-else>
<a-button
type="default"
@click="openLogoutModal(record as OrgUserDto)"
>
<template v-if="!(record as OrgUserDto).logoutTime" #icon>
<ClockCircleOutlined />
</template>
<span v-if="(record as OrgUserDto).logoutTime" dir="ltr">
{{ toFaDigits((record as OrgUserDto).logoutTime!) }}
</span>
</a-button>
<a-button
v-if="(record as OrgUserDto).logoutTime"
:type="
(record as OrgUserDto).blockLoginAfterLogout ? 'primary' : 'default'
"
:title="
(record as OrgUserDto).blockLoginAfterLogout
? 'ورود بعد از ساعت خروج مسدود است — برای آزاد کردن بزنید'
: 'ورود بعد از ساعت خروج آزاد است — برای مسدود کردن بزنید'
"
@click="toggleBlockAfterLogout(record as OrgUserDto)"
>
<template #icon>
<StopOutlined
v-if="(record as OrgUserDto).blockLoginAfterLogout"
/>
<CheckOutlined v-else />
</template>
</a-button>
</a-space>
</span>
</template>
<template v-else-if="column.key === 'عملیات'">
<span @click.stop>
<a-dropdown
:open="menuUserId === (record as OrgUserDto).id"
trigger="click"
@update:open="
(v: boolean) =>
(menuUserId = v ? (record as OrgUserDto).id : null)
"
>
<a-button type="text" aria-label="عملیات">
<template #icon><MoreOutlined /></template>
</a-button>
<template #overlay>
<a-menu
@click="(info) => onUserMenu(info, record as OrgUserDto)"
>
<a-menu-item v-if="canInvite" key="edit">
ویرایش مشخصات
</a-menu-item>
<a-menu-item
v-if="canInvite && (record as OrgUserDto).role !== 'owner'"
key="otp"
:title="LOGIN_OTP_ONLY_TIP"
>
{{
(record as OrgUserDto).loginOtpOnly
? 'لغو ورود با کد'
: 'ورود با کد'
}}
</a-menu-item>
<a-menu-item key="status">
{{
(record as OrgUserDto).isActive === false
? 'فعال کردن'
: 'غیرفعال کردن'
}}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</span>
</template>
</template>
</a-table>
<a-flex
v-if="filteredUsers.length"
justify="flex-end"
:style="{ marginTop: '16px' }"
>
<a-pagination
v-model:current="page"
:page-size="PAGE_SIZE"
:total="filteredUsers.length"
:show-size-changer="false"
:show-total="() => pageLabel"
/>
</a-flex>
</template>
<a-list
v-else
item-layout="horizontal"
:data-source="roles"
bordered
>
<template #renderItem="{ item: role }">
<a-list-item
:style="{
cursor: 'pointer',
paddingInlineStart: `${12 + (role.depth ?? 0) * 16}px`,
background:
selectedRole?.id === role.id
? 'var(--ant-color-primary-bg)'
: undefined,
}"
@click="pickRole(role.id)"
>
<a-list-item-meta>
<template #title>
<a-space wrap>
<a-tag :color="roleTagColor(role.color)" :bordered="false">
{{ role.name }}
</a-tag>
<a-tag v-if="role.childCount" :bordered="false">
{{ toFaDigits(role.childCount) }} زیرنقش
</a-tag>
<a-typography-text type="secondary">
{{ toFaDigits(role.userCount) }} کاربر ·
{{ permCountLabel(role) }} مجوز
</a-typography-text>
</a-space>
</template>
<template #description>
{{ role.description || 'بدون توضیح' }}
<template v-if="role.parentName">
· زیرمجموعهٔ {{ role.parentName }}
</template>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-spin>
</a-flex>
</a-card>
</a-col>
</a-row>
<a-drawer
:open="isNarrow && Boolean(selectedRole)"
placement="bottom"
:height="'85%'"
:title="selectedRole ? selectedRole.name : 'جزئیات نقش'"
@close="selectedRoleId = null"
>
<template v-if="canEditRoles && selectedRole" #extra>
<a-space>
<a-button
v-if="(selectedRole.depth ?? 0) < 4"
type="link"
@click="openCreateChildRole"
>
زیرنقش
</a-button>
<a-button type="link" @click="openEditRole">ویرایش</a-button>
<a-button
v-if="!selectedRole.systemKey"
type="link"
danger
@click="deleteOpen = true"
>
حذف
</a-button>
</a-space>
</template>
<RoleDetailPanel
v-if="selectedRole"
:role="selectedRole"
:child-roles="selectedChildRoles"
:permission-items="visibleRolePermissions"
:avatar-of="avatarOf"
:tag-color="roleTagColor"
@pick-role="pickRole"
/>
</a-drawer>
</template>
<a-modal
v-model:open="inviteOpen"
title="کاربر جدید"
destroy-on-close
:footer="null"
>
<a-form layout="vertical" @finish="submitInvite">
<a-form-item label="نام و نام خانوادگی" required>
<a-input v-model:value="inviteName" style="width: 100%" />
</a-form-item>
<a-form-item label="شماره موبایل" required>
<a-input
v-model:value="inviteMobile"
dir="ltr"
placeholder="0912…"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="رمز عبور">
<a-input-password
v-model:value="invitePassword"
placeholder="فقط برای کاربر جدید"
style="width: 100%"
/>
</a-form-item>
<a-typography-text type="secondary">
اگر این شماره از قبل حساب دارد، بدون رمز به همین محیط اضافه می‌شود.
</a-typography-text>
<a-form-item label="نقش" :style="{ marginTop: '12px' }">
<a-select
v-model:value="inviteRoleId"
:options="inviteRoleOptions"
style="width: 100%"
/>
</a-form-item>
<a-space>
<a-button @click="inviteOpen = false">انصراف</a-button>
<a-button type="primary" html-type="submit" :loading="inviteSaving">
افزودن کاربر
</a-button>
</a-space>
</a-form>
</a-modal>
<a-modal
:open="Boolean(logoutModal)"
:title="logoutModal ? `ساعت خروج ${logoutModal.user.fullName}` : 'ساعت خروج'"
:confirm-loading="logoutSaving"
ok-text="ذخیره"
cancel-text="انصراف"
:ok-button-props="{ disabled: !logoutModal?.time }"
destroy-on-close
@cancel="logoutModal = null"
@ok="logoutModal && changeLogoutTime(logoutModal.user, logoutModal.time)"
>
<a-form v-if="logoutModal" layout="vertical">
<a-form-item label="ساعت خروج روزانه">
<JalaliDateInput v-model="logoutModal.time" mode="time" :clearable="false" />
</a-form-item>
<a-typography-text type="secondary">
در این ساعت (به وقت تهران) کاربر خودکار از سامانه خارج می‌شود. با تیک کنار ساعت می‌توانید
ورود دوباره تا پایان همان روز را هم ببندید.
</a-typography-text>
</a-form>
<template #footer>
<a-space wrap>
<a-button
v-if="logoutModal?.user.logoutTime"
:disabled="logoutSaving"
@click="logoutModal && changeLogoutTime(logoutModal.user, '')"
>
حذف ساعت خروج
</a-button>
<a-button @click="logoutModal = null">انصراف</a-button>
<a-button
type="primary"
:loading="logoutSaving"
:disabled="!logoutModal?.time"
@click="logoutModal && changeLogoutTime(logoutModal.user, logoutModal.time)"
>
ذخیره
</a-button>
</a-space>
</template>
</a-modal>
<a-modal
v-model:open="editOpen"
title="ویرایش مشخصات کاربر"
destroy-on-close
:footer="null"
>
<a-form layout="vertical" @finish="submitEditUser">
<a-form-item label="نام و نام خانوادگی" required>
<a-input v-model:value="editName" style="width: 100%" />
</a-form-item>
<a-form-item label="شماره موبایل" required>
<a-input
v-model:value="editMobile"
dir="ltr"
placeholder="0912…"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="رمز عبور جدید">
<a-input-password
v-model:value="editPassword"
placeholder="خالی بگذارید اگر تغییر نمی‌دهید"
style="width: 100%"
/>
</a-form-item>
<a-form-item v-if="!editRoleLocked" label="نقش">
<a-select
v-model:value="editRoleId"
:options="inviteRoleOptions"
style="width: 100%"
/>
</a-form-item>
<a-typography-text v-else type="secondary">
نقش فعلی: {{ editRoleLabel }} — نقش مالک را فقط مالک می‌تواند تغییر دهد.
</a-typography-text>
<a-space :style="{ marginTop: '12px' }">
<a-button @click="editOpen = false">انصراف</a-button>
<a-button type="primary" html-type="submit" :loading="editSaving">ذخیره</a-button>
</a-space>
</a-form>
</a-modal>
<a-modal
v-model:open="roleFormOpen"
:title="roleFormId ? 'ویرایش نقش' : roleFormParentId ? 'زیرنقش جدید' : 'نقش جدید'"
width="720px"
destroy-on-close
:footer="null"
>
<a-form layout="vertical" @finish="submitRoleForm">
<a-form-item label="نام نقش" required>
<a-input v-model:value="roleFormName" style="width: 100%" />
</a-form-item>
<a-form-item label="توضیح">
<a-textarea v-model:value="roleFormDesc" :rows="3" style="width: 100%" />
</a-form-item>
<a-form-item
v-if="!(roleFormId && roles.find((r) => r.id === roleFormId)?.systemKey)"
label="نقش والد"
>
<a-select
v-model:value="roleFormParentId"
:options="parentRoleSelectOptions"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="رنگ">
<a-select
v-model:value="roleFormColor"
:options="roleColorSelectOptions"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="مجوزهای دسترسی">
<a-flex vertical :gap="4">
<a-checkbox
v-for="perm in visibleRolePermissions"
:key="perm.key"
:checked="permOn(perm.key)"
@change="toggleFormPerm(perm.key)"
>
{{ perm.label }}
</a-checkbox>
</a-flex>
</a-form-item>
<a-form-item label="محدوده داده">
<DataAccessPolicyPanel
v-model="roleFormDataAccess"
:enabled-permissions="roleFormPerms"
compact
/>
</a-form-item>
<a-space>
<a-button @click="roleFormOpen = false">انصراف</a-button>
<a-button type="primary" html-type="submit" :loading="roleFormSaving">
ذخیره نقش
</a-button>
</a-space>
</a-form>
</a-modal>
<a-modal
v-model:open="deleteOpen"
title="حذف نقش"
destroy-on-close
:footer="null"
>
<a-typography-paragraph>
آیا از حذف این نقش مطمئن هستید؟ کاربران آن بدون نقش سفارشی می‌مانند.
</a-typography-paragraph>
<a-space>
<a-button @click="deleteOpen = false">انصراف</a-button>
<a-button type="primary" danger :loading="deleteSaving" @click="confirmDeleteRole">
حذف نقش
</a-button>
</a-space>
</a-modal>
</a-flex>
</template>