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>
1709 lines
59 KiB
Vue
1709 lines
59 KiB
Vue
<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>
|
||
|