2026-08-29 21:06:30 +03:30
|
|
|
|
<script setup lang="ts">
|
2026-09-15 17:33:05 +03:30
|
|
|
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
2026-10-07 15:23:14 +03:30
|
|
|
|
import { useEscClose } from '@/lib/modal-stack';
|
2026-10-06 15:04:46 +03:30
|
|
|
|
import { matchesSearch } from '@/lib/search';
|
2026-10-05 11:56:49 +03:30
|
|
|
|
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
2026-10-21 14:20:15 +03:30
|
|
|
|
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
2026-09-15 17:33:05 +03:30
|
|
|
|
import { useRoute, useRouter } from 'vue-router';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
import axios from 'axios';
|
|
|
|
|
|
import { api } from '@/lib/api';
|
|
|
|
|
|
import { useAuthStore } from '@/stores/auth';
|
2026-11-04 10:01:11 +03:30
|
|
|
|
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
2026-09-15 17:33:05 +03:30
|
|
|
|
import {
|
2026-10-27 18:07:42 +03:30
|
|
|
|
DATA_ACCESS_SECTIONS,
|
2026-09-15 17:33:05 +03:30
|
|
|
|
ROLE_PERMISSION_ITEMS,
|
2026-10-22 11:53:26 +03:30
|
|
|
|
type DataAccessPolicy,
|
2026-09-15 17:33:05 +03:30
|
|
|
|
type InviteUserRequest,
|
|
|
|
|
|
type OrganizationDto,
|
2026-10-19 12:20:52 +03:30
|
|
|
|
type OrgModuleKey,
|
2026-09-15 17:33:05 +03:30
|
|
|
|
type OrgRoleDto,
|
|
|
|
|
|
type OrgUserDto,
|
|
|
|
|
|
type RolePermissionKey,
|
|
|
|
|
|
type UpdateOrganizationRequest,
|
2026-10-04 12:31:07 +03:30
|
|
|
|
type UpdateOrgUserRequest,
|
2026-08-29 21:06:30 +03:30
|
|
|
|
} from '@mento/shared';
|
2026-09-15 17:33:05 +03:30
|
|
|
|
import { canManageSettings, canManageUsers } from '@/lib/roles';
|
2026-09-03 16:45:59 +03:30
|
|
|
|
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
|
2026-09-15 17:33:05 +03:30
|
|
|
|
import {
|
|
|
|
|
|
calendarDaysBetween,
|
|
|
|
|
|
formatClockFa,
|
|
|
|
|
|
formatJalaliNumeric,
|
|
|
|
|
|
toFaDigits,
|
|
|
|
|
|
} from '@/lib/jalali';
|
2026-09-26 17:15:09 +03:30
|
|
|
|
import TeamsSettingsPanel from '@/components/TeamsSettingsPanel.vue';
|
2026-11-04 10:01:11 +03:30
|
|
|
|
import RoleDetailPanel from '@/components/RoleDetailPanel.vue';
|
2026-10-22 11:53:26 +03:30
|
|
|
|
import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue';
|
2026-11-04 10:01:11 +03:30
|
|
|
|
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;
|
2026-09-15 17:33:05 +03:30
|
|
|
|
|
2026-09-26 17:15:09 +03:30
|
|
|
|
type TabKey = 'users' | 'roles' | 'access' | 'teams';
|
2026-09-15 17:33:05 +03:30
|
|
|
|
type RoleColor = 'teal' | 'violet' | 'rose' | 'sky' | 'amber' | 'slate' | 'pink';
|
|
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
const USER_COL_LABELS = [
|
|
|
|
|
|
'نام و نام خانوادگی',
|
|
|
|
|
|
'موبایل',
|
|
|
|
|
|
'نقش',
|
|
|
|
|
|
'پورسانت %',
|
|
|
|
|
|
'وضعیت',
|
|
|
|
|
|
'آخرین ورود',
|
|
|
|
|
|
'ساعت خروج',
|
|
|
|
|
|
'عملیات',
|
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
|
|
registerTableColumns('team.users', [...USER_COL_LABELS]);
|
|
|
|
|
|
const tableCols = useTableColumns(() => 'team.users');
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
const PAGE_SIZE = 10;
|
2026-11-04 10:01:11 +03:30
|
|
|
|
|
|
|
|
|
|
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';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-15 17:33:05 +03:30
|
|
|
|
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: 'سرخابی' },
|
|
|
|
|
|
];
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
|
|
|
|
|
const auth = useAuthStore();
|
2026-09-15 17:33:05 +03:30
|
|
|
|
const route = useRoute();
|
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
|
|
2026-10-19 12:20:52 +03:30
|
|
|
|
/** مجوزی که ماژولش در سوپرادمین خاموش است در نقشها نشان داده نمیشود. */
|
|
|
|
|
|
const PERMISSION_MODULES: Partial<Record<RolePermissionKey, OrgModuleKey[]>> = {
|
|
|
|
|
|
customers: ['customers'],
|
2026-11-03 13:15:21 +03:30
|
|
|
|
assign_customers: ['customers'],
|
2026-10-21 13:45:16 +03:30
|
|
|
|
leads: ['leads'],
|
2026-11-03 13:15:21 +03:30
|
|
|
|
assign_leads: ['leads'],
|
2026-10-19 12:20:52 +03:30
|
|
|
|
sales: ['pipeline'],
|
2026-11-03 13:15:21 +03:30
|
|
|
|
assign_sales: ['pipeline'],
|
2026-10-19 12:20:52 +03:30
|
|
|
|
billing: ['invoices', 'quotes'],
|
|
|
|
|
|
products: ['products'],
|
|
|
|
|
|
activities: ['activities'],
|
2026-11-03 13:15:21 +03:30
|
|
|
|
assign_activities: ['activities'],
|
|
|
|
|
|
assign_routines: ['routines'],
|
|
|
|
|
|
assign_projects: ['project_boards'],
|
|
|
|
|
|
assign_tickets: ['tickets'],
|
2026-10-19 12:20:52 +03:30
|
|
|
|
notes: ['notes'],
|
|
|
|
|
|
reports: ['reports'],
|
|
|
|
|
|
audit: ['audit_logs'],
|
|
|
|
|
|
conversations: ['conversations'],
|
|
|
|
|
|
conversations_groups: ['conversations'],
|
|
|
|
|
|
treasury: ['treasury'],
|
|
|
|
|
|
treasury_payroll: ['treasury'],
|
|
|
|
|
|
behavior: ['behavior'],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-29 21:06:30 +03:30
|
|
|
|
const loading = ref(false);
|
|
|
|
|
|
const error = ref('');
|
2026-09-15 17:33:05 +03:30
|
|
|
|
const ok = ref('');
|
2026-08-29 21:06:30 +03:30
|
|
|
|
const users = ref<OrgUserDto[]>([]);
|
2026-09-15 17:33:05 +03:30
|
|
|
|
const roles = ref<OrgRoleDto[]>([]);
|
2026-09-03 16:45:59 +03:30
|
|
|
|
const org = ref<OrganizationDto | null>(null);
|
2026-10-19 12:20:52 +03:30
|
|
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-03 16:45:59 +03:30
|
|
|
|
const orgName = ref('');
|
|
|
|
|
|
const businessField = ref('');
|
|
|
|
|
|
const orgSaving = ref(false);
|
2026-09-15 17:33:05 +03:30
|
|
|
|
const accessSaving = ref(false);
|
2026-10-22 11:53:26 +03:30
|
|
|
|
const roleFormDataAccess = ref<DataAccessPolicy>({});
|
|
|
|
|
|
const focusDataAccess = ref<DataAccessPolicy>({});
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
const tab = ref<TabKey>('users');
|
|
|
|
|
|
const query = ref('');
|
|
|
|
|
|
const page = ref(1);
|
|
|
|
|
|
const sortLoginDesc = ref(true);
|
|
|
|
|
|
const selectedRoleId = ref<string | null>(null);
|
|
|
|
|
|
const menuUserId = ref<string | null>(null);
|
|
|
|
|
|
const isNarrow = ref(false);
|
2026-10-07 15:23:14 +03:30
|
|
|
|
useEscClose(() => isNarrow.value && selectedRoleId.value, () => { selectedRoleId.value = null; });
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
const inviteOpen = ref(false);
|
|
|
|
|
|
const inviteSaving = ref(false);
|
|
|
|
|
|
const inviteName = ref('');
|
|
|
|
|
|
const inviteMobile = ref('');
|
|
|
|
|
|
const invitePassword = ref('');
|
|
|
|
|
|
const inviteRoleId = ref('');
|
|
|
|
|
|
|
2026-10-04 12:31:07 +03:30
|
|
|
|
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('');
|
2026-10-20 12:50:32 +03:30
|
|
|
|
const editRoleId = ref('');
|
|
|
|
|
|
const editInitialRoleId = ref('');
|
|
|
|
|
|
/** مدیر نمیتواند نقش مالک را عوض کند؛ آن موقع فقط برچسب نقش نشان داده میشود */
|
|
|
|
|
|
const editRoleLocked = ref(false);
|
2026-10-04 12:31:07 +03:30
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
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');
|
2026-10-03 13:16:13 +03:30
|
|
|
|
const roleFormParentId = ref('');
|
2026-09-15 17:33:05 +03:30
|
|
|
|
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>();
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
|
|
|
|
|
const canInvite = computed(() => canManageUsers(auth.user?.role));
|
2026-10-19 13:54:09 +03:30
|
|
|
|
const canSetLogoutTime = computed(() => auth.user?.role === 'owner');
|
2026-09-03 16:45:59 +03:30
|
|
|
|
const canEditOrg = computed(() => canManageSettings(auth.user?.role));
|
2026-09-15 17:33:05 +03:30
|
|
|
|
const canEditRoles = computed(() => canManageUsers(auth.user?.role));
|
2026-10-17 19:57:26 +03:30
|
|
|
|
/** مالک واقعی میتواند از جدول کاربران وارد مشاهده شود / برگردد */
|
|
|
|
|
|
const canViewAsUsers = computed(
|
|
|
|
|
|
() =>
|
|
|
|
|
|
auth.user?.role === 'owner' || Boolean(auth.user?.viewAs?.canOpenTeamSettings),
|
|
|
|
|
|
);
|
|
|
|
|
|
const viewAsBusy = ref(false);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
const selectedRole = computed(
|
|
|
|
|
|
() => roles.value.find((r) => r.id === selectedRoleId.value) ?? null,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const accessFocusId = ref<string | null>(null);
|
|
|
|
|
|
const accessFocusRole = computed(
|
|
|
|
|
|
() => roles.value.find((r) => r.id === accessFocusId.value) ?? roles.value[0] ?? null,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
function permCountLabel(role: OrgRoleDto) {
|
2026-10-19 12:20:52 +03:30
|
|
|
|
const visible = visibleRolePermissions.value;
|
|
|
|
|
|
const owned = visible.filter((item) => role.permissions.includes(item.key)).length;
|
|
|
|
|
|
return `${toFaDigits(owned)} از ${toFaDigits(visible.length)}`;
|
2026-09-15 17:33:05 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-10-06 15:04:46 +03:30
|
|
|
|
list = list.filter((u) => matchesSearch(q, [u.fullName, u.mobile, u.email, u.orgRoleName]));
|
2026-09-15 17:33:05 +03:30
|
|
|
|
}
|
|
|
|
|
|
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)} کاربر`;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-03 13:16:13 +03:30
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
const inviteableRoles = computed(() =>
|
|
|
|
|
|
roles.value.filter((r) => r.systemKey !== 'owner' || auth.user?.role === 'owner'),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-10-03 13:16:13 +03:30
|
|
|
|
function roleTreeLabel(role: OrgRoleDto) {
|
|
|
|
|
|
const depth = role.depth ?? 0;
|
|
|
|
|
|
const prefix = depth > 0 ? `${'— '.repeat(depth)}` : '';
|
|
|
|
|
|
return `${prefix}${role.name}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
function tabFromHash(hash: string): TabKey {
|
|
|
|
|
|
if (hash.includes('settings-org') || hash.includes('access')) return 'access';
|
|
|
|
|
|
if (hash.includes('roles')) return 'roles';
|
2026-09-26 17:15:09 +03:30
|
|
|
|
if (hash.includes('teams') || hash.includes('settings-teams')) return 'teams';
|
2026-09-15 17:33:05 +03:30
|
|
|
|
return 'users';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setTab(next: TabKey) {
|
|
|
|
|
|
tab.value = next;
|
2026-09-26 17:15:09 +03:30
|
|
|
|
const hash =
|
|
|
|
|
|
next === 'access'
|
|
|
|
|
|
? 'settings-org'
|
|
|
|
|
|
: next === 'roles'
|
|
|
|
|
|
? 'roles'
|
|
|
|
|
|
: next === 'teams'
|
|
|
|
|
|
? 'settings-teams'
|
|
|
|
|
|
: 'settings-team';
|
2026-09-15 17:33:05 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-17 19:57:26 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-09-15 17:33:05 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function load() {
|
|
|
|
|
|
loading.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
2026-09-15 17:33:05 +03:30
|
|
|
|
const [usersRes, rolesRes, orgRes] = await Promise.all([
|
2026-09-03 16:45:59 +03:30
|
|
|
|
api.get<OrgUserDto[]>('/users'),
|
2026-09-15 17:33:05 +03:30
|
|
|
|
api.get<OrgRoleDto[]>('/org-roles'),
|
2026-09-03 16:45:59 +03:30
|
|
|
|
api.get<OrganizationDto>('/organizations/current'),
|
|
|
|
|
|
]);
|
|
|
|
|
|
users.value = usersRes.data;
|
2026-09-15 17:33:05 +03:30
|
|
|
|
roles.value = rolesRes.data;
|
2026-09-03 16:45:59 +03:30
|
|
|
|
org.value = orgRes.data;
|
|
|
|
|
|
orgName.value = orgRes.data.name;
|
|
|
|
|
|
businessField.value = orgRes.data.businessField ?? '';
|
2026-09-15 17:33:05 +03:30
|
|
|
|
if (!isNarrow.value) ensureDesktopRole();
|
|
|
|
|
|
await loadAvatars([
|
|
|
|
|
|
...users.value.map((u) => ({ id: u.id, hasAvatar: u.hasAvatar })),
|
|
|
|
|
|
...roles.value.flatMap((r) => r.members),
|
|
|
|
|
|
]);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
} catch (e) {
|
2026-09-15 17:33:05 +03:30
|
|
|
|
error.value = axios.isAxiosError(e) && !e.response ? 'اتصال به API برقرار نشد.' : 'بارگذاری کاربران ناموفق بود.';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-03 16:45:59 +03:30
|
|
|
|
async function saveOrg() {
|
|
|
|
|
|
if (!canEditOrg.value) return;
|
|
|
|
|
|
if (!businessField.value) {
|
|
|
|
|
|
error.value = 'زمینه فعالیت را انتخاب کنید.';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
orgSaving.value = true;
|
|
|
|
|
|
error.value = '';
|
2026-09-15 17:33:05 +03:30
|
|
|
|
ok.value = '';
|
2026-09-03 16:45:59 +03:30
|
|
|
|
try {
|
|
|
|
|
|
const payload: UpdateOrganizationRequest = {
|
|
|
|
|
|
name: orgName.value.trim(),
|
|
|
|
|
|
businessField: businessField.value as UpdateOrganizationRequest['businessField'],
|
|
|
|
|
|
};
|
|
|
|
|
|
const { data } = await api.patch<OrganizationDto>('/organizations/current', payload);
|
|
|
|
|
|
org.value = data;
|
|
|
|
|
|
orgName.value = data.name;
|
|
|
|
|
|
businessField.value = data.businessField ?? '';
|
2026-09-15 17:33:05 +03:30
|
|
|
|
ok.value = 'مشخصات سازمان ذخیره شد.';
|
2026-09-03 16:45:59 +03:30
|
|
|
|
} catch (e) {
|
2026-09-15 17:33:05 +03:30
|
|
|
|
error.value = apiError(e, 'ذخیره سازمان ناموفق بود');
|
2026-09-03 16:45:59 +03:30
|
|
|
|
} finally {
|
|
|
|
|
|
orgSaving.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
function openInvite() {
|
|
|
|
|
|
inviteName.value = '';
|
|
|
|
|
|
inviteMobile.value = '';
|
|
|
|
|
|
invitePassword.value = '';
|
|
|
|
|
|
inviteRoleId.value =
|
|
|
|
|
|
inviteableRoles.value.find((r) => r.systemKey === 'sales')?.id ??
|
|
|
|
|
|
inviteableRoles.value[0]?.id ??
|
|
|
|
|
|
'';
|
|
|
|
|
|
inviteOpen.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function submitInvite() {
|
2026-09-15 21:37:58 +03:30
|
|
|
|
if (!inviteName.value.trim() || !inviteMobile.value.trim()) {
|
|
|
|
|
|
error.value = 'نام و شماره موبایل لازم است.';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (invitePassword.value && invitePassword.value.length < 8) {
|
|
|
|
|
|
error.value = 'رمز عبور در صورت ورود باید حداقل ۸ کاراکتر باشد.';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-15 17:33:05 +03:30
|
|
|
|
inviteSaving.value = true;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const payload: InviteUserRequest = {
|
2026-09-15 17:33:05 +03:30
|
|
|
|
mobile: inviteMobile.value.trim(),
|
|
|
|
|
|
fullName: inviteName.value.trim(),
|
|
|
|
|
|
orgRoleId: inviteRoleId.value || undefined,
|
2026-09-15 21:37:58 +03:30
|
|
|
|
...(invitePassword.value ? { password: invitePassword.value } : {}),
|
2026-08-29 21:06:30 +03:30
|
|
|
|
};
|
|
|
|
|
|
await api.post('/users', payload);
|
2026-09-15 17:33:05 +03:30
|
|
|
|
inviteOpen.value = false;
|
2026-09-15 21:37:58 +03:30
|
|
|
|
ok.value = 'کاربر به این محیط اضافه شد.';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
await load();
|
|
|
|
|
|
} catch (e) {
|
2026-09-15 17:33:05 +03:30
|
|
|
|
error.value = apiError(e, 'افزودن کاربر ناموفق بود');
|
2026-08-29 21:06:30 +03:30
|
|
|
|
} finally {
|
2026-09-15 17:33:05 +03:30
|
|
|
|
inviteSaving.value = false;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-04 12:31:07 +03:30
|
|
|
|
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 || '—';
|
2026-10-20 12:50:32 +03:30
|
|
|
|
editRoleId.value = user.orgRoleId ?? '';
|
|
|
|
|
|
editInitialRoleId.value = user.orgRoleId ?? '';
|
|
|
|
|
|
editRoleLocked.value = user.role === 'owner' && auth.user?.role !== 'owner';
|
2026-10-04 12:31:07 +03:30
|
|
|
|
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);
|
2026-10-20 12:50:32 +03:30
|
|
|
|
if (!editRoleLocked.value && editRoleId.value && editRoleId.value !== editInitialRoleId.value) {
|
|
|
|
|
|
await api.patch(`/users/${editUserId.value}/role`, { orgRoleId: editRoleId.value });
|
|
|
|
|
|
}
|
2026-10-04 12:31:07 +03:30
|
|
|
|
editOpen.value = false;
|
|
|
|
|
|
ok.value = 'مشخصات کاربر ذخیره شد.';
|
|
|
|
|
|
await load();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error.value = apiError(e, 'ویرایش کاربر ناموفق بود');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
editSaving.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
async function toggleStatus(user: OrgUserDto) {
|
|
|
|
|
|
menuUserId.value = null;
|
2026-09-11 01:09:59 +03:30
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
2026-09-15 17:33:05 +03:30
|
|
|
|
await api.patch(`/users/${user.id}/status`, { isActive: user.isActive === false });
|
2026-09-11 01:09:59 +03:30
|
|
|
|
await load();
|
|
|
|
|
|
} catch (e) {
|
2026-09-15 17:33:05 +03:30
|
|
|
|
error.value = apiError(e, 'تغییر وضعیت ناموفق بود');
|
2026-09-11 01:09:59 +03:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 15:43:24 +03:30
|
|
|
|
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, 'تغییر شیوهٔ ورود ناموفق بود');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-21 14:20:15 +03:30
|
|
|
|
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' };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 13:34:55 +03:30
|
|
|
|
async function changeLogoutTime(user: OrgUserDto, raw: string) {
|
2026-10-19 13:54:09 +03:30
|
|
|
|
if (!canSetLogoutTime.value || user.role === 'owner') return;
|
2026-10-19 13:34:55 +03:30
|
|
|
|
const next = raw.trim();
|
2026-10-21 14:20:15 +03:30
|
|
|
|
if (next === (user.logoutTime ?? '')) {
|
|
|
|
|
|
logoutModal.value = null;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-19 13:34:55 +03:30
|
|
|
|
error.value = '';
|
|
|
|
|
|
ok.value = '';
|
2026-10-21 14:20:15 +03:30
|
|
|
|
logoutSaving.value = true;
|
2026-10-19 13:34:55 +03:30
|
|
|
|
try {
|
|
|
|
|
|
await api.patch(`/users/${user.id}/logout-time`, { logoutTime: next || null });
|
|
|
|
|
|
ok.value = next
|
|
|
|
|
|
? `ساعت خروج «${user.fullName}» روی ${toFaDigits(next)} تنظیم شد.`
|
|
|
|
|
|
: `محدودیت خروج «${user.fullName}» برداشته شد.`;
|
2026-10-21 14:20:15 +03:30
|
|
|
|
logoutModal.value = null;
|
2026-10-19 13:34:55 +03:30
|
|
|
|
await load();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error.value = apiError(e, 'ذخیره ساعت خروج ناموفق بود');
|
2026-10-21 14:20:15 +03:30
|
|
|
|
} 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, 'تغییر محدودیت ورود ناموفق بود');
|
2026-10-19 13:34:55 +03:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 13:16:13 +03:30
|
|
|
|
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;
|
2026-09-15 17:33:05 +03:30
|
|
|
|
roleFormId.value = null;
|
|
|
|
|
|
roleFormName.value = '';
|
|
|
|
|
|
roleFormDesc.value = '';
|
2026-10-03 13:16:13 +03:30
|
|
|
|
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]
|
2026-10-19 12:20:52 +03:30
|
|
|
|
: visibleRolePermissions.value.map((p) => p.key);
|
2026-10-22 11:53:26 +03:30
|
|
|
|
roleFormDataAccess.value = clonePolicy(parent?.dataAccessPolicy);
|
2026-09-15 17:33:05 +03:30
|
|
|
|
roleFormOpen.value = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 13:16:13 +03:30
|
|
|
|
function openCreateChildRole() {
|
|
|
|
|
|
if (!selectedRole.value) return;
|
|
|
|
|
|
openCreateRole(selectedRole.value.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
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;
|
2026-10-03 13:16:13 +03:30
|
|
|
|
roleFormParentId.value = role.systemKey ? '' : (role.parentId ?? '');
|
2026-09-15 17:33:05 +03:30
|
|
|
|
roleFormPerms.value = [...role.permissions];
|
2026-10-22 11:53:26 +03:30
|
|
|
|
roleFormDataAccess.value = clonePolicy(role.dataAccessPolicy);
|
2026-09-15 17:33:05 +03:30
|
|
|
|
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;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
2026-10-03 13:16:13 +03:30
|
|
|
|
const editing = roleFormId.value
|
|
|
|
|
|
? roles.value.find((r) => r.id === roleFormId.value)
|
|
|
|
|
|
: null;
|
|
|
|
|
|
const body: {
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
description: string;
|
|
|
|
|
|
color: RoleColor;
|
|
|
|
|
|
permissions: RolePermissionKey[];
|
2026-10-22 11:53:26 +03:30
|
|
|
|
dataAccessPolicy?: OrgRoleDto['dataAccessPolicy'];
|
2026-10-03 13:16:13 +03:30
|
|
|
|
parentId?: string | null;
|
|
|
|
|
|
} = {
|
2026-09-15 17:33:05 +03:30
|
|
|
|
name: roleFormName.value.trim(),
|
|
|
|
|
|
description: roleFormDesc.value.trim(),
|
|
|
|
|
|
color: roleFormColor.value,
|
|
|
|
|
|
permissions: roleFormPerms.value,
|
2026-10-22 11:53:26 +03:30
|
|
|
|
dataAccessPolicy: roleFormDataAccess.value,
|
2026-09-15 17:33:05 +03:30
|
|
|
|
};
|
2026-10-03 13:16:13 +03:30
|
|
|
|
if (!editing?.systemKey) {
|
|
|
|
|
|
body.parentId = roleFormParentId.value || null;
|
|
|
|
|
|
}
|
2026-09-15 17:33:05 +03:30
|
|
|
|
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 ? 'نقش بهروز شد.' : 'نقش جدید ساخته شد.';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
await load();
|
|
|
|
|
|
} catch (e) {
|
2026-09-15 17:33:05 +03:30
|
|
|
|
error.value = apiError(e, 'ذخیره نقش ناموفق بود');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
roleFormSaving.value = false;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
async function confirmDeleteRole() {
|
|
|
|
|
|
const role = selectedRole.value;
|
|
|
|
|
|
if (!role || role.systemKey) return;
|
|
|
|
|
|
deleteSaving.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.delete(`/org-roles/${role.id}`);
|
|
|
|
|
|
deleteOpen.value = false;
|
|
|
|
|
|
selectedRoleId.value = null;
|
|
|
|
|
|
ok.value = 'نقش حذف شد.';
|
|
|
|
|
|
await load();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error.value = apiError(e, 'حذف نقش ناموفق بود');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
deleteSaving.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function toggleAccess(role: OrgRoleDto, key: RolePermissionKey) {
|
|
|
|
|
|
const next = role.permissions.includes(key)
|
|
|
|
|
|
? role.permissions.filter((p) => p !== key)
|
|
|
|
|
|
: [...role.permissions, key];
|
|
|
|
|
|
role.permissions = next;
|
|
|
|
|
|
accessSaving.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.patch(`/org-roles/${role.id}`, { permissions: next });
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error.value = apiError(e, 'ذخیره دسترسی ناموفق بود');
|
|
|
|
|
|
await load();
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
accessSaving.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 11:53:26 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-27 18:07:42 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 11:53:26 +03:30
|
|
|
|
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 {
|
2026-10-27 18:07:42 +03:30
|
|
|
|
const dataAccessPolicy = withOpenSections(focusDataAccess.value);
|
|
|
|
|
|
focusDataAccess.value = dataAccessPolicy;
|
|
|
|
|
|
await api.patch(`/org-roles/${role.id}`, { dataAccessPolicy });
|
2026-10-22 11:53:26 +03:30
|
|
|
|
ok.value = `محدوده داده برای نقش «${role.name}» ذخیره شد.`;
|
|
|
|
|
|
await load();
|
|
|
|
|
|
syncFocusDataAccess();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error.value = apiError(e, 'ذخیره محدوده داده ناموفق بود');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
accessSaving.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
function onDocClick() {
|
|
|
|
|
|
menuUserId.value = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-10-22 11:53:26 +03:30
|
|
|
|
syncFocusDataAccess();
|
2026-09-15 17:33:05 +03:30
|
|
|
|
},
|
|
|
|
|
|
{ immediate: true },
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-10-22 11:53:26 +03:30
|
|
|
|
watch(accessFocusId, () => {
|
|
|
|
|
|
syncFocusDataAccess();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<a-flex vertical :gap="16">
|
|
|
|
|
|
<a-typography-title :level="3" :style="{ margin: 0 }">
|
|
|
|
|
|
<TeamOutlined />
|
|
|
|
|
|
مدیریت کاربران
|
|
|
|
|
|
</a-typography-title>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<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>
|
2026-09-03 16:45:59 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<a-alert v-if="error" type="error" show-icon :message="error" />
|
|
|
|
|
|
<a-alert v-if="ok" type="success" show-icon :message="ok" />
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-09-26 17:15:09 +03:30
|
|
|
|
<TeamsSettingsPanel
|
|
|
|
|
|
v-if="tab === 'teams'"
|
|
|
|
|
|
:users="users"
|
|
|
|
|
|
:can-edit="canInvite"
|
|
|
|
|
|
@refresh-users="load"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<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>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<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>
|
2026-10-22 11:53:26 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<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
|
2026-10-22 11:53:26 +03:30
|
|
|
|
v-if="canEditRoles"
|
2026-11-04 10:01:11 +03:30
|
|
|
|
type="primary"
|
|
|
|
|
|
:loading="accessSaving"
|
2026-10-22 11:53:26 +03:30
|
|
|
|
@click="saveFocusDataAccess"
|
|
|
|
|
|
>
|
2026-11-04 10:01:11 +03:30
|
|
|
|
ذخیره محدوده
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
</a-flex>
|
|
|
|
|
|
<a-typography-text type="secondary">
|
2026-10-22 13:13:59 +03:30
|
|
|
|
برای هر بخش میتوانید همه را باز بگذارید یا با فیلتر (مالک، گروه، مرحله و…) محدود کنید.
|
2026-11-04 10:01:11 +03:30
|
|
|
|
</a-typography-text>
|
2026-10-22 11:53:26 +03:30
|
|
|
|
<DataAccessPolicyPanel
|
|
|
|
|
|
v-model="focusDataAccess"
|
|
|
|
|
|
:enabled-permissions="accessFocusRole.permissions"
|
|
|
|
|
|
:disabled="!canEditRoles"
|
|
|
|
|
|
/>
|
2026-11-04 10:01:11 +03:30
|
|
|
|
</a-flex>
|
|
|
|
|
|
</a-card>
|
|
|
|
|
|
</a-flex>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<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"
|
2026-10-03 13:16:13 +03:30
|
|
|
|
>
|
2026-11-04 10:01:11 +03:30
|
|
|
|
زیرنقش
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
<a-button type="link" @click="openEditRole">ویرایش</a-button>
|
|
|
|
|
|
<a-button
|
|
|
|
|
|
v-if="!selectedRole.systemKey"
|
|
|
|
|
|
type="link"
|
|
|
|
|
|
danger
|
|
|
|
|
|
@click="deleteOpen = true"
|
2026-09-15 17:33:05 +03:30
|
|
|
|
>
|
2026-11-04 10:01:11 +03:30
|
|
|
|
حذف
|
|
|
|
|
|
</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>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<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>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<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>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<a-spin :spinning="loading">
|
|
|
|
|
|
<template v-if="tab === 'users'">
|
|
|
|
|
|
<a-flex
|
|
|
|
|
|
justify="space-between"
|
|
|
|
|
|
align="center"
|
|
|
|
|
|
:gap="12"
|
|
|
|
|
|
:style="{ marginBottom: '12px' }"
|
2026-10-22 15:43:24 +03:30
|
|
|
|
>
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<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"
|
2026-09-22 11:02:08 +03:30
|
|
|
|
>
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<template #emptyText>
|
|
|
|
|
|
<a-empty description="کاربری یافت نشد." />
|
2026-09-22 11:02:08 +03:30
|
|
|
|
</template>
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<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)"
|
2026-10-04 12:31:07 +03:30
|
|
|
|
>
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<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>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<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"
|
2026-09-15 17:33:05 +03:30
|
|
|
|
>
|
2026-11-04 10:01:11 +03:30
|
|
|
|
زیرنقش
|
|
|
|
|
|
</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>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<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>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<a-modal
|
2026-10-21 14:20:15 +03:30
|
|
|
|
:open="Boolean(logoutModal)"
|
|
|
|
|
|
:title="logoutModal ? `ساعت خروج ${logoutModal.user.fullName}` : 'ساعت خروج'"
|
2026-11-04 10:01:11 +03:30
|
|
|
|
: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)"
|
2026-10-21 14:20:15 +03:30
|
|
|
|
>
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<a-form v-if="logoutModal" layout="vertical">
|
|
|
|
|
|
<a-form-item label="ساعت خروج روزانه">
|
2026-10-21 14:20:15 +03:30
|
|
|
|
<JalaliDateInput v-model="logoutModal.time" mode="time" :clearable="false" />
|
2026-11-04 10:01:11 +03:30
|
|
|
|
</a-form-item>
|
|
|
|
|
|
<a-typography-text type="secondary">
|
2026-10-21 14:20:15 +03:30
|
|
|
|
در این ساعت (به وقت تهران) کاربر خودکار از سامانه خارج میشود. با تیک کنار ساعت میتوانید
|
|
|
|
|
|
ورود دوباره تا پایان همان روز را هم ببندید.
|
2026-11-04 10:01:11 +03:30
|
|
|
|
</a-typography-text>
|
|
|
|
|
|
</a-form>
|
2026-10-21 14:20:15 +03:30
|
|
|
|
<template #footer>
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<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>
|
2026-10-21 14:20:15 +03:30
|
|
|
|
</template>
|
2026-11-04 10:01:11 +03:30
|
|
|
|
</a-modal>
|
2026-10-21 14:20:15 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<a-modal
|
|
|
|
|
|
v-model:open="editOpen"
|
|
|
|
|
|
title="ویرایش مشخصات کاربر"
|
|
|
|
|
|
destroy-on-close
|
|
|
|
|
|
:footer="null"
|
2026-10-03 13:16:13 +03:30
|
|
|
|
>
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<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="محدوده داده">
|
2026-10-22 11:53:26 +03:30
|
|
|
|
<DataAccessPolicyPanel
|
|
|
|
|
|
v-model="roleFormDataAccess"
|
|
|
|
|
|
:enabled-permissions="roleFormPerms"
|
|
|
|
|
|
compact
|
|
|
|
|
|
/>
|
2026-11-04 10:01:11 +03:30
|
|
|
|
</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>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<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>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</template>
|
|
|
|
|
|
|