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-09-15 17:33:05 +03:30
|
|
|
|
import UiIcon from '@/components/UiIcon.vue';
|
2026-09-22 11:02:08 +03:30
|
|
|
|
import { UiDropdownMenu, UiModal } from '@/components/ui';
|
2026-09-15 17:33:05 +03:30
|
|
|
|
import {
|
|
|
|
|
|
ROLE_PERMISSION_ITEMS,
|
|
|
|
|
|
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-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';
|
|
|
|
|
|
|
|
|
|
|
|
const PAGE_SIZE = 10;
|
|
|
|
|
|
const DEFAULT_AVATAR = '/brand/user-avatar.png';
|
|
|
|
|
|
const ROLE_COLOR_OPTIONS: Array<{ value: RoleColor; label: string }> = [
|
|
|
|
|
|
{ value: 'teal', label: 'سبز' },
|
|
|
|
|
|
{ value: 'violet', label: 'بنفش' },
|
|
|
|
|
|
{ value: 'rose', label: 'صورتی' },
|
|
|
|
|
|
{ value: 'sky', label: 'آبی' },
|
|
|
|
|
|
{ value: 'amber', label: 'کهربایی' },
|
|
|
|
|
|
{ value: 'slate', label: 'خاکستری' },
|
|
|
|
|
|
{ value: 'pink', label: 'سرخابی' },
|
|
|
|
|
|
];
|
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-10-21 13:45:16 +03:30
|
|
|
|
leads: ['leads'],
|
2026-10-19 12:20:52 +03:30
|
|
|
|
sales: ['pipeline'],
|
|
|
|
|
|
billing: ['invoices', 'quotes'],
|
|
|
|
|
|
products: ['products'],
|
|
|
|
|
|
activities: ['activities'],
|
|
|
|
|
|
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-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 showAllMembers = ref(false);
|
|
|
|
|
|
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)} کاربر`;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const visibleMembers = computed(() => {
|
|
|
|
|
|
const role = selectedRole.value;
|
|
|
|
|
|
if (!role) return [];
|
|
|
|
|
|
return showAllMembers.value ? role.members : role.members.slice(0, 3);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
showAllMembers.value = false;
|
|
|
|
|
|
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-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-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];
|
|
|
|
|
|
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[];
|
|
|
|
|
|
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-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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onDocClick() {
|
|
|
|
|
|
menuUserId.value = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function syncViewport() {
|
|
|
|
|
|
isNarrow.value = window.matchMedia('(max-width: 900px)').matches;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
watch(query, () => {
|
|
|
|
|
|
page.value = 1;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
|
() => route.hash,
|
|
|
|
|
|
(hash) => {
|
|
|
|
|
|
tab.value = tabFromHash(hash);
|
|
|
|
|
|
},
|
|
|
|
|
|
{ immediate: true },
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
watch(pageCount, (count) => {
|
|
|
|
|
|
if (page.value > count) page.value = count;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
watch(isNarrow, (narrow) => {
|
|
|
|
|
|
if (!narrow) ensureDesktopRole();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
|
roles,
|
|
|
|
|
|
(list) => {
|
|
|
|
|
|
if (!list.length) return;
|
|
|
|
|
|
if (!list.some((r) => r.id === accessFocusId.value)) {
|
|
|
|
|
|
accessFocusId.value = list[0].id;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
{ immediate: true },
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
syncViewport();
|
|
|
|
|
|
window.addEventListener('resize', syncViewport);
|
|
|
|
|
|
document.addEventListener('click', onDocClick);
|
|
|
|
|
|
void load();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
window.removeEventListener('resize', syncViewport);
|
|
|
|
|
|
document.removeEventListener('click', onDocClick);
|
|
|
|
|
|
for (const url of Object.values(avatarUrls.value)) URL.revokeObjectURL(url);
|
|
|
|
|
|
});
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<section class="page">
|
2026-09-05 02:29:41 +03:30
|
|
|
|
<header class="page-head">
|
|
|
|
|
|
<div class="head-copy">
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<h1>
|
|
|
|
|
|
<span class="title-ico"><UiIcon name="users" :size="22" /></span>
|
|
|
|
|
|
مدیریت کاربران
|
|
|
|
|
|
</h1>
|
|
|
|
|
|
<p class="lead">کاربران سامانه را مدیریت کنید و نقشهای مختلف را برای آنها تعریف کنید.</p>
|
2026-09-05 02:29:41 +03:30
|
|
|
|
</div>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</header>
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<nav class="tabs" aria-label="بخشهای مدیریت کاربران">
|
|
|
|
|
|
<button type="button" :class="{ on: tab === 'users' }" @click="setTab('users')">لیست کاربران</button>
|
|
|
|
|
|
<button type="button" :class="{ on: tab === 'roles' }" @click="setTab('roles')">نقشها</button>
|
2026-09-27 09:38:17 +03:30
|
|
|
|
<button type="button" :class="{ on: tab === 'teams' }" @click="setTab('teams')">تیمها و سطح دسترسی به پروندهها</button>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<button type="button" :class="{ on: tab === 'access' }" @click="setTab('access')">تنظیمات دسترسی</button>
|
|
|
|
|
|
</nav>
|
2026-09-03 16:45:59 +03:30
|
|
|
|
|
2026-08-29 21:06:30 +03:30
|
|
|
|
<p v-if="error" class="error">{{ error }}</p>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<p v-if="ok" class="ok">{{ ok }}</p>
|
2026-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-09-15 17:33:05 +03:30
|
|
|
|
<div v-if="tab === 'access'" class="access-wrap">
|
|
|
|
|
|
<section class="card org-card">
|
|
|
|
|
|
<h2>سازمان</h2>
|
|
|
|
|
|
<div class="org-grid">
|
|
|
|
|
|
<label>
|
|
|
|
|
|
نام سازمان
|
|
|
|
|
|
<input v-model="orgName" :disabled="!canEditOrg" />
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label>
|
|
|
|
|
|
زمینه فعالیت
|
|
|
|
|
|
<select v-model="businessField" :disabled="!canEditOrg">
|
|
|
|
|
|
<option value="" disabled>انتخاب کنید</option>
|
|
|
|
|
|
<option v-for="field in BUSINESS_FIELDS" :key="field" :value="field">
|
|
|
|
|
|
{{ BUSINESS_FIELD_LABELS[field] }}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button v-if="canEditOrg" type="button" class="btn primary" :disabled="orgSaving" @click="saveOrg">
|
|
|
|
|
|
{{ orgSaving ? 'در حال ذخیره…' : 'ذخیره سازمان' }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section class="card matrix-card">
|
|
|
|
|
|
<div class="matrix-head">
|
|
|
|
|
|
<h2>مجوزهای نقشها</h2>
|
|
|
|
|
|
<span v-if="accessSaving" class="muted">در حال ذخیره…</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="matrix-mobile">
|
|
|
|
|
|
<div class="role-chips" role="tablist" aria-label="انتخاب نقش">
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-for="role in roles"
|
|
|
|
|
|
:key="role.id"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="role-chip"
|
|
|
|
|
|
:class="{ on: accessFocusRole?.id === role.id }"
|
|
|
|
|
|
role="tab"
|
|
|
|
|
|
:aria-selected="accessFocusRole?.id === role.id"
|
|
|
|
|
|
@click="accessFocusId = role.id"
|
|
|
|
|
|
>
|
2026-10-03 13:16:13 +03:30
|
|
|
|
<span class="pill" :data-color="role.color">{{ roleTreeLabel(role) }}</span>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<small>{{ permCountLabel(role) }}</small>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ul v-if="accessFocusRole" class="access-list">
|
2026-10-19 12:20:52 +03:30
|
|
|
|
<li v-for="perm in visibleRolePermissions" :key="perm.key">
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<label class="access-row">
|
|
|
|
|
|
<span>{{ perm.label }}</span>
|
|
|
|
|
|
<span class="check">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
:checked="accessFocusRole.permissions.includes(perm.key)"
|
|
|
|
|
|
:disabled="!canEditRoles"
|
|
|
|
|
|
@change="toggleAccess(accessFocusRole, perm.key)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="matrix-scroll">
|
|
|
|
|
|
<table class="matrix">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>مجوز</th>
|
2026-10-03 13:16:13 +03:30
|
|
|
|
<th v-for="role in roles" :key="role.id">{{ roleTreeLabel(role) }}</th>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
2026-10-19 12:20:52 +03:30
|
|
|
|
<tr v-for="perm in visibleRolePermissions" :key="perm.key">
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<th>{{ perm.label }}</th>
|
|
|
|
|
|
<td v-for="role in roles" :key="role.id + perm.key">
|
|
|
|
|
|
<label class="check">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
:checked="role.permissions.includes(perm.key)"
|
|
|
|
|
|
:disabled="!canEditRoles"
|
|
|
|
|
|
@change="toggleAccess(role, perm.key)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span />
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-09-26 17:15:09 +03:30
|
|
|
|
<div v-else-if="tab === 'users' || tab === 'roles'" class="stage" :class="{ 'panel-open': Boolean(selectedRole) }">
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<aside v-if="selectedRole" class="role-panel" :class="{ sheet: isNarrow }">
|
|
|
|
|
|
<div v-if="isNarrow" class="sheet-handle" />
|
|
|
|
|
|
<header class="panel-head">
|
|
|
|
|
|
<div class="panel-title">
|
|
|
|
|
|
<span class="title-ico sm"><UiIcon name="user" :size="16" /></span>
|
|
|
|
|
|
<h2>جزئیات نقش</h2>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="ghost-ico"
|
|
|
|
|
|
:aria-label="isNarrow ? 'بستن' : 'بازگشت'"
|
|
|
|
|
|
@click="isNarrow ? (selectedRoleId = null) : undefined"
|
|
|
|
|
|
>
|
|
|
|
|
|
<UiIcon :name="isNarrow ? 'x' : 'chevron'" :size="18" :class="{ 'back-chevron': !isNarrow }" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="panel-body">
|
|
|
|
|
|
<div class="role-hero">
|
|
|
|
|
|
<span class="title-ico"><UiIcon name="user" :size="18" /></span>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h3>{{ selectedRole.name }}</h3>
|
|
|
|
|
|
<p>{{ selectedRole.description || '—' }}</p>
|
2026-10-03 13:16:13 +03:30
|
|
|
|
<p v-if="selectedRole.parentName" class="parent-line">
|
|
|
|
|
|
زیرمجموعهٔ
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="linkish"
|
|
|
|
|
|
@click="pickRole(selectedRole.parentId!)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ selectedRole.parentName }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</p>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-10-03 13:16:13 +03:30
|
|
|
|
<section v-if="selectedChildRoles.length || canEditRoles" class="panel-block">
|
|
|
|
|
|
<div class="block-head">
|
|
|
|
|
|
<h4>زیرنقشها</h4>
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-if="canEditRoles && (selectedRole.depth ?? 0) < 4"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="linkish"
|
|
|
|
|
|
@click="openCreateChildRole"
|
|
|
|
|
|
>
|
|
|
|
|
|
+ زیرنقش
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ul v-if="selectedChildRoles.length" class="child-roles">
|
|
|
|
|
|
<li v-for="child in selectedChildRoles" :key="child.id">
|
|
|
|
|
|
<button type="button" class="child-role-btn" @click="pickRole(child.id)">
|
|
|
|
|
|
<span class="pill" :data-color="child.color">{{ child.name }}</span>
|
|
|
|
|
|
<small>{{ toFaDigits(child.userCount) }} کاربر</small>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
<p v-else class="empty">هنوز زیرنقشی تعریف نشده.</p>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<section class="panel-block">
|
|
|
|
|
|
<h4>مجوزهای دسترسی</h4>
|
|
|
|
|
|
<ul class="perms">
|
2026-10-19 12:20:52 +03:30
|
|
|
|
<li v-for="perm in visibleRolePermissions" :key="perm.key">
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<span
|
|
|
|
|
|
class="perm-check"
|
|
|
|
|
|
:class="{ on: selectedRole.permissions.includes(perm.key) }"
|
|
|
|
|
|
>
|
|
|
|
|
|
<UiIcon name="check-plain" :size="14" />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{{ perm.label }}
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section v-if="!isNarrow" class="panel-block">
|
|
|
|
|
|
<h4>کاربران دارای این نقش</h4>
|
|
|
|
|
|
<ul class="members">
|
|
|
|
|
|
<li v-for="member in visibleMembers" :key="member.id">
|
|
|
|
|
|
<img :src="avatarOf(member.id)" :alt="member.fullName" />
|
|
|
|
|
|
<span>{{ member.fullName }}</span>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
<li v-if="!selectedRole.members.length" class="empty">هنوز کاربری با این نقش نیست.</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-if="selectedRole.members.length > 3"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="linkish"
|
|
|
|
|
|
@click="showAllMembers = !showAllMembers"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ showAllMembers ? 'نمایش کمتر' : `مشاهده همه (${toFaDigits(selectedRole.userCount)})` }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<footer v-if="canEditRoles" class="panel-actions">
|
2026-10-03 13:16:13 +03:30
|
|
|
|
<button
|
|
|
|
|
|
v-if="(selectedRole.depth ?? 0) < 4"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="btn outline"
|
|
|
|
|
|
@click="openCreateChildRole"
|
|
|
|
|
|
>
|
|
|
|
|
|
+ زیرنقش
|
|
|
|
|
|
</button>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<button type="button" class="btn outline" @click="openEditRole">
|
|
|
|
|
|
<UiIcon name="pencil" :size="16" />
|
|
|
|
|
|
ویرایش نقش
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-if="!isNarrow && !selectedRole.systemKey"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="btn danger-outline"
|
|
|
|
|
|
@click="deleteOpen = true"
|
|
|
|
|
|
>
|
|
|
|
|
|
<UiIcon name="trash" :size="16" />
|
|
|
|
|
|
حذف نقش
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</footer>
|
|
|
|
|
|
</aside>
|
2026-10-07 15:23:14 +03:30
|
|
|
|
<div v-if="isNarrow && selectedRoleId" class="sheet-backdrop" aria-hidden="true" />
|
2026-09-15 17:33:05 +03:30
|
|
|
|
|
|
|
|
|
|
<div class="main-col">
|
|
|
|
|
|
<div class="toolbar">
|
|
|
|
|
|
<label v-if="tab === 'users'" class="search">
|
|
|
|
|
|
<UiIcon name="search" :size="18" />
|
|
|
|
|
|
<input v-model="query" type="search" placeholder="جستجو در کاربران..." />
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<button v-if="canInvite && tab === 'users'" type="button" class="btn primary add" @click="openInvite">
|
|
|
|
|
|
<span>+</span>
|
|
|
|
|
|
کاربر جدید
|
|
|
|
|
|
</button>
|
2026-10-03 13:27:16 +03:30
|
|
|
|
<button v-else-if="canEditRoles && tab === 'roles'" type="button" class="btn primary add" @click="openCreateRole()">
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<span>+</span>
|
|
|
|
|
|
نقش جدید
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="tab === 'users'" class="stats">
|
|
|
|
|
|
<article>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p>کل کاربران</p>
|
|
|
|
|
|
<strong>{{ toFaDigits(stats.total) }}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span class="stat-ico users"><UiIcon name="users" :size="18" /></span>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
<article>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p>فعال</p>
|
|
|
|
|
|
<strong>{{ toFaDigits(stats.active) }}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span class="stat-ico on"><UiIcon name="check-plain" :size="16" /></span>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
<article>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p>غیرفعال</p>
|
|
|
|
|
|
<strong>{{ toFaDigits(stats.inactive) }}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span class="stat-ico off"><UiIcon name="x-circle" :size="16" /></span>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-10-05 11:56:49 +03:30
|
|
|
|
<div v-if="tab === 'users'" class="tc-row desktop-table">
|
|
|
|
|
|
<TableColumnsMenu table-key="team.users" align="end" />
|
|
|
|
|
|
</div>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<div v-if="tab === 'users'" class="table-card desktop-table">
|
2026-10-05 11:56:49 +03:30
|
|
|
|
<table v-table-cols="'team.users'">
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>نام و نام خانوادگی</th>
|
2026-10-04 12:31:07 +03:30
|
|
|
|
<th>موبایل</th>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<th>نقش</th>
|
2026-10-03 13:16:13 +03:30
|
|
|
|
<th>پورسانت %</th>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<th>وضعیت</th>
|
|
|
|
|
|
<th>
|
|
|
|
|
|
<button type="button" class="sort" @click="sortLoginDesc = !sortLoginDesc">
|
|
|
|
|
|
آخرین ورود
|
|
|
|
|
|
<span :class="{ flip: !sortLoginDesc }">▴</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</th>
|
2026-10-19 13:54:09 +03:30
|
|
|
|
<th v-if="canSetLogoutTime">ساعت خروج</th>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<th class="ops" />
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr v-for="user in pagedUsers" :key="user.id" @click="onUserActivate(user)">
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<div class="who">
|
|
|
|
|
|
<img :src="avatarOf(user.id)" :alt="user.fullName" />
|
|
|
|
|
|
<strong>{{ user.fullName }}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
2026-10-04 12:31:07 +03:30
|
|
|
|
<td class="muted" dir="ltr">{{ user.mobile || '—' }}</td>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<td>
|
|
|
|
|
|
<span class="pill" :data-color="user.orgRoleColor || 'teal'">
|
|
|
|
|
|
{{ user.orgRoleName || '—' }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</td>
|
2026-10-03 13:16:13 +03:30
|
|
|
|
<td @click.stop>
|
|
|
|
|
|
<label class="comm-field">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="0"
|
|
|
|
|
|
max="100"
|
|
|
|
|
|
step="0.1"
|
|
|
|
|
|
dir="ltr"
|
|
|
|
|
|
:value="user.commissionPercent ?? 0"
|
|
|
|
|
|
:disabled="!canInvite"
|
|
|
|
|
|
:aria-label="`پورسانت ${user.fullName}`"
|
|
|
|
|
|
@change="changeCommission(user, ($event.target as HTMLInputElement).value)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</td>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<td>
|
|
|
|
|
|
<span class="status" :class="{ off: user.isActive === false }">
|
|
|
|
|
|
<i />
|
|
|
|
|
|
{{ user.isActive === false ? 'غیرفعال' : 'فعال' }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td class="muted">{{ formatLastLogin(user.lastLoginAt) }}</td>
|
2026-10-19 13:54:09 +03:30
|
|
|
|
<td v-if="canSetLogoutTime" @click.stop>
|
2026-10-19 13:34:55 +03:30
|
|
|
|
<span v-if="user.role === 'owner'" class="muted">—</span>
|
2026-10-21 14:20:15 +03:30
|
|
|
|
<span v-else class="logout-cell">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="logout-btn"
|
|
|
|
|
|
:class="{ set: Boolean(user.logoutTime) }"
|
|
|
|
|
|
:aria-label="user.logoutTime ? `ویرایش ساعت خروج ${user.fullName}` : `تعیین ساعت خروج ${user.fullName}`"
|
|
|
|
|
|
:title="user.logoutTime ? 'ویرایش ساعت خروج' : 'تعیین ساعت خروج'"
|
|
|
|
|
|
@click="openLogoutModal(user)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span v-if="user.logoutTime" dir="ltr">{{ toFaDigits(user.logoutTime) }}</span>
|
|
|
|
|
|
<UiIcon v-else name="clock" :size="16" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-if="user.logoutTime"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="block-btn"
|
|
|
|
|
|
:class="{ on: user.blockLoginAfterLogout }"
|
|
|
|
|
|
:aria-pressed="Boolean(user.blockLoginAfterLogout)"
|
|
|
|
|
|
:title="
|
|
|
|
|
|
user.blockLoginAfterLogout
|
|
|
|
|
|
? 'ورود بعد از ساعت خروج مسدود است — برای آزاد کردن بزنید'
|
|
|
|
|
|
: 'ورود بعد از ساعت خروج آزاد است — برای مسدود کردن بزنید'
|
|
|
|
|
|
"
|
|
|
|
|
|
@click="toggleBlockAfterLogout(user)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<UiIcon :name="user.blockLoginAfterLogout ? 'check' : 'x'" :size="14" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</span>
|
2026-10-19 13:34:55 +03:30
|
|
|
|
</td>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<td class="ops" @click.stop>
|
2026-09-22 11:02:08 +03:30
|
|
|
|
<UiDropdownMenu
|
|
|
|
|
|
:open="menuUserId === user.id"
|
|
|
|
|
|
align="end"
|
|
|
|
|
|
@update:open="(v) => (menuUserId = v ? user.id : null)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #trigger="{ toggle }">
|
|
|
|
|
|
<button type="button" class="ghost-ico" aria-label="عملیات" @click.stop="toggle">
|
|
|
|
|
|
<UiIcon name="dots-v" :size="16" />
|
2026-09-15 17:33:05 +03:30
|
|
|
|
</button>
|
2026-09-22 11:02:08 +03:30
|
|
|
|
</template>
|
2026-10-04 12:31:07 +03:30
|
|
|
|
<button
|
|
|
|
|
|
v-if="canInvite"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
@click="openEditUser(user)"
|
|
|
|
|
|
>
|
|
|
|
|
|
ویرایش مشخصات
|
|
|
|
|
|
</button>
|
2026-09-22 11:02:08 +03:30
|
|
|
|
<button type="button" @click="toggleStatus(user)">
|
|
|
|
|
|
{{ user.isActive === false ? 'فعال کردن' : 'غیرفعال کردن' }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</UiDropdownMenu>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
<tr v-if="!loading && !pagedUsers.length">
|
2026-10-04 12:31:07 +03:30
|
|
|
|
<td colspan="7" class="empty">کاربری یافت نشد.</td>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="tab === 'users'" class="mobile-list">
|
|
|
|
|
|
<article v-for="user in pagedUsers" :key="user.id" class="user-card" @click="onUserActivate(user)">
|
|
|
|
|
|
<div class="who">
|
|
|
|
|
|
<img :src="avatarOf(user.id)" :alt="user.fullName" />
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong>{{ user.fullName }}</strong>
|
|
|
|
|
|
<span class="pill" :data-color="user.orgRoleColor || 'teal'">
|
|
|
|
|
|
{{ user.orgRoleName || '—' }}
|
|
|
|
|
|
</span>
|
2026-10-04 12:31:07 +03:30
|
|
|
|
<p v-if="user.mobile" class="muted mobile-line" dir="ltr">{{ user.mobile }}</p>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-10-03 13:16:13 +03:30
|
|
|
|
<div class="card-meta" @click.stop>
|
|
|
|
|
|
<label class="comm-field">
|
|
|
|
|
|
<span>پورسانت %</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="0"
|
|
|
|
|
|
max="100"
|
|
|
|
|
|
step="0.1"
|
|
|
|
|
|
dir="ltr"
|
|
|
|
|
|
:value="user.commissionPercent ?? 0"
|
|
|
|
|
|
:disabled="!canInvite"
|
|
|
|
|
|
:aria-label="`پورسانت ${user.fullName}`"
|
|
|
|
|
|
@change="changeCommission(user, ($event.target as HTMLInputElement).value)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<span class="status" :class="{ off: user.isActive === false }">
|
|
|
|
|
|
<i />
|
|
|
|
|
|
{{ user.isActive === false ? 'غیرفعال' : 'فعال' }}
|
|
|
|
|
|
</span>
|
2026-10-04 12:31:07 +03:30
|
|
|
|
<button
|
|
|
|
|
|
v-if="canInvite"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="btn ghost"
|
|
|
|
|
|
@click="openEditUser(user)"
|
|
|
|
|
|
>
|
|
|
|
|
|
ویرایش
|
|
|
|
|
|
</button>
|
2026-10-03 13:16:13 +03:30
|
|
|
|
</div>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
</article>
|
|
|
|
|
|
<p v-if="!loading && !pagedUsers.length" class="empty">کاربری یافت نشد.</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-10-03 13:16:13 +03:30
|
|
|
|
<div v-if="tab === 'roles'" class="roles-tree">
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<button
|
|
|
|
|
|
v-for="role in roles"
|
|
|
|
|
|
:key="role.id"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="role-card"
|
2026-10-03 13:16:13 +03:30
|
|
|
|
:class="{ on: selectedRole?.id === role.id, child: (role.depth ?? 0) > 0 }"
|
|
|
|
|
|
:style="{ '--role-depth': String(role.depth ?? 0) }"
|
2026-09-15 17:33:05 +03:30
|
|
|
|
@click="pickRole(role.id)"
|
|
|
|
|
|
>
|
2026-10-03 13:16:13 +03:30
|
|
|
|
<div class="role-card-top">
|
|
|
|
|
|
<span class="pill" :data-color="role.color">{{ role.name }}</span>
|
|
|
|
|
|
<span v-if="role.childCount" class="child-badge">
|
|
|
|
|
|
{{ toFaDigits(role.childCount) }} زیرنقش
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<p>{{ role.description || 'بدون توضیح' }}</p>
|
2026-10-03 13:16:13 +03:30
|
|
|
|
<small>
|
|
|
|
|
|
{{ toFaDigits(role.userCount) }} کاربر
|
|
|
|
|
|
<template v-if="role.parentName"> · زیرمجموعهٔ {{ role.parentName }}</template>
|
|
|
|
|
|
</small>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<footer v-if="tab === 'users' && filteredUsers.length" class="pager">
|
|
|
|
|
|
<span>{{ pageLabel }}</span>
|
|
|
|
|
|
<div class="pages">
|
|
|
|
|
|
<button type="button" :disabled="page <= 1" @click="page -= 1">‹</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-for="n in pageCount"
|
|
|
|
|
|
:key="n"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
:class="{ on: n === page }"
|
|
|
|
|
|
@click="page = n"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ toFaDigits(n) }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" :disabled="page >= pageCount" @click="page += 1">›</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</footer>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<UiModal :open="inviteOpen" title="کاربر جدید" @close="inviteOpen = false">
|
|
|
|
|
|
<form class="form" @submit.prevent="submitInvite">
|
|
|
|
|
|
<label>نام و نام خانوادگی<input v-model="inviteName" required minlength="2" /></label>
|
|
|
|
|
|
<label>شماره موبایل<input v-model="inviteMobile" dir="ltr" inputmode="tel" placeholder="0912…" /></label>
|
2026-09-15 21:37:58 +03:30
|
|
|
|
<label>
|
|
|
|
|
|
رمز عبور
|
|
|
|
|
|
<input v-model="invitePassword" type="password" minlength="8" placeholder="فقط برای کاربر جدید" />
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<p class="hint">اگر این شماره از قبل حساب دارد، بدون رمز به همین محیط اضافه میشود.</p>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
<label>
|
|
|
|
|
|
نقش
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<select v-model="inviteRoleId">
|
2026-10-03 13:16:13 +03:30
|
|
|
|
<option v-for="role in inviteableRoles" :key="role.id" :value="role.id">
|
|
|
|
|
|
{{ roleTreeLabel(role) }}
|
|
|
|
|
|
</option>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<button type="button" class="btn ghost" @click="inviteOpen = false">انصراف</button>
|
|
|
|
|
|
<button type="button" class="btn primary" :disabled="inviteSaving" @click="submitInvite">
|
|
|
|
|
|
{{ inviteSaving ? 'در حال ذخیره…' : 'افزودن کاربر' }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</UiModal>
|
|
|
|
|
|
|
2026-10-21 14:20:15 +03:30
|
|
|
|
<UiModal
|
|
|
|
|
|
:open="Boolean(logoutModal)"
|
|
|
|
|
|
:title="logoutModal ? `ساعت خروج ${logoutModal.user.fullName}` : 'ساعت خروج'"
|
|
|
|
|
|
@close="logoutModal = null"
|
|
|
|
|
|
>
|
|
|
|
|
|
<form v-if="logoutModal" class="form" @submit.prevent="changeLogoutTime(logoutModal.user, logoutModal.time)">
|
|
|
|
|
|
<label>
|
|
|
|
|
|
ساعت خروج روزانه
|
|
|
|
|
|
<JalaliDateInput v-model="logoutModal.time" mode="time" :clearable="false" />
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<p class="hint">
|
|
|
|
|
|
در این ساعت (به وقت تهران) کاربر خودکار از سامانه خارج میشود. با تیک کنار ساعت میتوانید
|
|
|
|
|
|
ورود دوباره تا پایان همان روز را هم ببندید.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-if="logoutModal?.user.logoutTime"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="btn ghost"
|
|
|
|
|
|
:disabled="logoutSaving"
|
|
|
|
|
|
@click="logoutModal && changeLogoutTime(logoutModal.user, '')"
|
|
|
|
|
|
>
|
|
|
|
|
|
حذف ساعت خروج
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" class="btn ghost" @click="logoutModal = null">انصراف</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="btn primary"
|
|
|
|
|
|
:disabled="logoutSaving || !logoutModal?.time"
|
|
|
|
|
|
@click="logoutModal && changeLogoutTime(logoutModal.user, logoutModal.time)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ logoutSaving ? 'در حال ذخیره…' : 'ذخیره' }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</UiModal>
|
|
|
|
|
|
|
2026-10-04 12:31:07 +03:30
|
|
|
|
<UiModal :open="editOpen" title="ویرایش مشخصات کاربر" @close="editOpen = false">
|
|
|
|
|
|
<form class="form" @submit.prevent="submitEditUser">
|
|
|
|
|
|
<label>نام و نام خانوادگی<input v-model="editName" required minlength="2" /></label>
|
|
|
|
|
|
<label>
|
|
|
|
|
|
شماره موبایل
|
|
|
|
|
|
<input v-model="editMobile" dir="ltr" inputmode="tel" placeholder="0912…" required />
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label>
|
|
|
|
|
|
رمز عبور جدید
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-model="editPassword"
|
|
|
|
|
|
type="password"
|
|
|
|
|
|
minlength="8"
|
|
|
|
|
|
placeholder="خالی بگذارید اگر تغییر نمیدهید"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
2026-10-20 12:50:32 +03:30
|
|
|
|
<label v-if="!editRoleLocked">
|
|
|
|
|
|
نقش
|
|
|
|
|
|
<select v-model="editRoleId">
|
|
|
|
|
|
<option v-for="role in inviteableRoles" :key="role.id" :value="role.id">
|
|
|
|
|
|
{{ roleTreeLabel(role) }}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<p v-else class="hint">نقش فعلی: {{ editRoleLabel }} — نقش مالک را فقط مالک میتواند تغییر دهد.</p>
|
2026-10-04 12:31:07 +03:30
|
|
|
|
</form>
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<button type="button" class="btn ghost" @click="editOpen = false">انصراف</button>
|
|
|
|
|
|
<button type="button" class="btn primary" :disabled="editSaving" @click="submitEditUser">
|
|
|
|
|
|
{{ editSaving ? 'در حال ذخیره…' : 'ذخیره' }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</UiModal>
|
|
|
|
|
|
|
2026-10-03 13:16:13 +03:30
|
|
|
|
<UiModal
|
|
|
|
|
|
:open="roleFormOpen"
|
|
|
|
|
|
:title="roleFormId ? 'ویرایش نقش' : roleFormParentId ? 'زیرنقش جدید' : 'نقش جدید'"
|
|
|
|
|
|
@close="roleFormOpen = false"
|
|
|
|
|
|
>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<form class="form" @submit.prevent="submitRoleForm">
|
|
|
|
|
|
<label>نام نقش<input v-model="roleFormName" required minlength="2" /></label>
|
|
|
|
|
|
<label>توضیح<textarea v-model="roleFormDesc" rows="3" /></label>
|
2026-10-03 13:16:13 +03:30
|
|
|
|
<label v-if="!(roleFormId && roles.find((r) => r.id === roleFormId)?.systemKey)">
|
|
|
|
|
|
نقش والد
|
|
|
|
|
|
<select v-model="roleFormParentId">
|
|
|
|
|
|
<option value="">بدون والد (نقش اصلی)</option>
|
|
|
|
|
|
<option v-for="role in parentRoleOptions" :key="role.id" :value="role.id">
|
|
|
|
|
|
{{ roleTreeLabel(role) }}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<label>
|
|
|
|
|
|
رنگ
|
|
|
|
|
|
<select v-model="roleFormColor">
|
|
|
|
|
|
<option v-for="color in ROLE_COLOR_OPTIONS" :key="color.value" :value="color.value">
|
|
|
|
|
|
{{ color.label }}
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<fieldset class="perm-set">
|
|
|
|
|
|
<legend>مجوزهای دسترسی</legend>
|
2026-10-19 12:20:52 +03:30
|
|
|
|
<label v-for="perm in visibleRolePermissions" :key="perm.key" class="perm-row">
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<input type="checkbox" :checked="permOn(perm.key)" @change="toggleFormPerm(perm.key)" />
|
|
|
|
|
|
{{ perm.label }}
|
2026-09-11 01:09:59 +03:30
|
|
|
|
</label>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
</fieldset>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<button type="button" class="btn ghost" @click="roleFormOpen = false">انصراف</button>
|
|
|
|
|
|
<button type="button" class="btn primary" :disabled="roleFormSaving" @click="submitRoleForm">
|
|
|
|
|
|
{{ roleFormSaving ? 'در حال ذخیره…' : 'ذخیره نقش' }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</UiModal>
|
|
|
|
|
|
|
|
|
|
|
|
<UiModal :open="deleteOpen" title="حذف نقش" @close="deleteOpen = false">
|
|
|
|
|
|
<p>آیا از حذف این نقش مطمئن هستید؟ کاربران آن بدون نقش سفارشی میمانند.</p>
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<button type="button" class="btn ghost" @click="deleteOpen = false">انصراف</button>
|
|
|
|
|
|
<button type="button" class="btn danger" :disabled="deleteSaving" @click="confirmDeleteRole">
|
|
|
|
|
|
{{ deleteSaving ? 'در حال حذف…' : 'حذف نقش' }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</UiModal>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</section>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.page {
|
|
|
|
|
|
display: grid;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
gap: 1rem;
|
|
|
|
|
|
min-width: 0;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 02:29:41 +03:30
|
|
|
|
.page-head {
|
|
|
|
|
|
display: flex;
|
2026-09-15 17:33:05 +03:30
|
|
|
|
justify-content: flex-start;
|
|
|
|
|
|
width: 100%;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.head-copy {
|
2026-09-15 17:33:05 +03:30
|
|
|
|
text-align: right;
|
|
|
|
|
|
width: 100%;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
h1 {
|
2026-09-15 17:33:05 +03:30
|
|
|
|
margin: 0 0 0.35rem;
|
|
|
|
|
|
font-size: clamp(1.35rem, 2.2vw, 1.7rem);
|
2026-09-05 02:29:41 +03:30
|
|
|
|
font-weight: 800;
|
2026-09-15 17:33:05 +03:30
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.55rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.title-ico {
|
|
|
|
|
|
width: 2.15rem;
|
|
|
|
|
|
height: 2.15rem;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
place-items: center;
|
|
|
|
|
|
background: #e6fbf7;
|
|
|
|
|
|
color: #0d9488;
|
|
|
|
|
|
flex: 0 0 auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.title-ico.sm {
|
|
|
|
|
|
width: 1.7rem;
|
|
|
|
|
|
height: 1.7rem;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.lead,
|
2026-09-15 17:33:05 +03:30
|
|
|
|
.muted,
|
2026-09-15 21:37:58 +03:30
|
|
|
|
.hint,
|
2026-09-15 17:33:05 +03:30
|
|
|
|
.empty {
|
2026-09-05 02:29:41 +03:30
|
|
|
|
margin: 0;
|
2026-09-15 17:33:05 +03:30
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
font-size: 0.92rem;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.error {
|
2026-09-05 02:29:41 +03:30
|
|
|
|
margin: 0;
|
|
|
|
|
|
color: #b91c1c;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-03 16:45:59 +03:30
|
|
|
|
.ok {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
color: #0f766e;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
font-weight: 600;
|
2026-09-03 16:45:59 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
.tabs {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: flex-start;
|
|
|
|
|
|
gap: 1.75rem;
|
|
|
|
|
|
border-bottom: 1px solid #e2e8f0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.tabs button {
|
|
|
|
|
|
appearance: none;
|
|
|
|
|
|
background: none;
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
border-bottom: 2px solid transparent;
|
|
|
|
|
|
margin-bottom: -1px;
|
|
|
|
|
|
padding: 0.55rem 0.15rem;
|
|
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.tabs button.on {
|
|
|
|
|
|
color: #0d9488;
|
|
|
|
|
|
border-bottom-color: #14b8a6;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stage {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: minmax(0, 1fr) minmax(280px, 340px);
|
|
|
|
|
|
grid-template-areas: 'list panel';
|
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
|
align-items: start;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.main-col {
|
|
|
|
|
|
grid-area: list;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.role-panel {
|
|
|
|
|
|
grid-area: panel;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.main-col,
|
|
|
|
|
|
.role-panel,
|
|
|
|
|
|
.card {
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.toolbar {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: row;
|
|
|
|
|
|
direction: rtl;
|
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
margin-bottom: 0.85rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.search {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
order: 1;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.45rem;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
background: #fff;
|
|
|
|
|
|
border: 1px solid #e2e8f0;
|
2026-09-15 17:33:05 +03:30
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
min-height: 42px;
|
|
|
|
|
|
padding: 0 0.85rem;
|
|
|
|
|
|
color: #94a3b8;
|
2026-10-10 11:58:45 +03:30
|
|
|
|
min-width: 0;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
.search input {
|
|
|
|
|
|
flex: 1;
|
2026-10-10 11:58:45 +03:30
|
|
|
|
width: 100%;
|
2026-09-15 17:33:05 +03:30
|
|
|
|
border: 0;
|
|
|
|
|
|
outline: none;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stats {
|
2026-08-29 21:06:30 +03:30
|
|
|
|
display: grid;
|
2026-09-15 17:33:05 +03:30
|
|
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
2026-08-29 21:06:30 +03:30
|
|
|
|
gap: 0.75rem;
|
2026-09-15 17:33:05 +03:30
|
|
|
|
margin-bottom: 0.85rem;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
.stats article {
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
border: 1px solid #eef2f6;
|
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
|
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04);
|
|
|
|
|
|
padding: 0.9rem 1rem;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stats p {
|
|
|
|
|
|
margin: 0 0 0.2rem;
|
|
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
font-size: 0.82rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stats strong {
|
|
|
|
|
|
font-size: 1.35rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stat-ico {
|
|
|
|
|
|
width: 2rem;
|
|
|
|
|
|
height: 2rem;
|
|
|
|
|
|
border-radius: 999px;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
display: grid;
|
2026-09-15 17:33:05 +03:30
|
|
|
|
place-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stat-ico.users {
|
|
|
|
|
|
background: #eef2ff;
|
|
|
|
|
|
color: #6366f1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stat-ico.on {
|
|
|
|
|
|
background: #dcfce7;
|
|
|
|
|
|
color: #16a34a;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stat-ico.off {
|
|
|
|
|
|
background: #f1f5f9;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
color: #64748b;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
.table-card,
|
|
|
|
|
|
.role-panel,
|
|
|
|
|
|
.card,
|
|
|
|
|
|
.user-card,
|
|
|
|
|
|
.role-card {
|
2026-08-29 21:06:30 +03:30
|
|
|
|
background: #fff;
|
2026-09-15 17:33:05 +03:30
|
|
|
|
border: 1px solid #eef2f6;
|
|
|
|
|
|
border-radius: 18px;
|
|
|
|
|
|
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.04);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.table-card {
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
table {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
border-collapse: collapse;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
th,
|
|
|
|
|
|
td {
|
|
|
|
|
|
padding: 0.85rem 1rem;
|
|
|
|
|
|
text-align: right;
|
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
thead th {
|
|
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
border-bottom: 1px solid #f1f5f9;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tbody tr {
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tbody tr + tr td {
|
|
|
|
|
|
border-top: 1px solid #f8fafc;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ops {
|
|
|
|
|
|
width: 44px;
|
|
|
|
|
|
padding-inline: 0.4rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.sort {
|
|
|
|
|
|
appearance: none;
|
|
|
|
|
|
background: none;
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
color: inherit;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.25rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.sort .flip {
|
|
|
|
|
|
display: inline-block;
|
|
|
|
|
|
transform: rotate(180deg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.who {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.7rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.who img,
|
|
|
|
|
|
.members img {
|
|
|
|
|
|
width: 40px;
|
|
|
|
|
|
height: 40px;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
object-fit: cover;
|
|
|
|
|
|
background: #e2e8f0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.who strong {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pill {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
padding: 0.18rem 0.7rem;
|
|
|
|
|
|
font-size: 0.78rem;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
background: #ccfbf1;
|
|
|
|
|
|
color: #0f766e;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pill[data-color='violet'] {
|
|
|
|
|
|
background: #f3e8ff;
|
|
|
|
|
|
color: #7c3aed;
|
|
|
|
|
|
}
|
|
|
|
|
|
.pill[data-color='rose'],
|
|
|
|
|
|
.pill[data-color='pink'] {
|
|
|
|
|
|
background: #fce7f3;
|
|
|
|
|
|
color: #db2777;
|
|
|
|
|
|
}
|
|
|
|
|
|
.pill[data-color='sky'] {
|
|
|
|
|
|
background: #e0f2fe;
|
|
|
|
|
|
color: #0284c7;
|
|
|
|
|
|
}
|
|
|
|
|
|
.pill[data-color='amber'] {
|
|
|
|
|
|
background: #fef3c7;
|
|
|
|
|
|
color: #b45309;
|
|
|
|
|
|
}
|
|
|
|
|
|
.pill[data-color='slate'] {
|
|
|
|
|
|
background: #f1f5f9;
|
|
|
|
|
|
color: #475569;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.status {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
|
color: #16a34a;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.status i {
|
|
|
|
|
|
width: 8px;
|
|
|
|
|
|
height: 8px;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
background: currentColor;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.status.off {
|
|
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ghost-ico {
|
|
|
|
|
|
width: 32px;
|
|
|
|
|
|
height: 32px;
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
place-items: center;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.role-panel {
|
|
|
|
|
|
position: sticky;
|
|
|
|
|
|
top: 0.75rem;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
min-height: 34rem;
|
|
|
|
|
|
padding: 1rem 1.05rem 1.1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.panel-head,
|
|
|
|
|
|
.panel-title,
|
|
|
|
|
|
.role-hero {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.panel-head {
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.panel-head h2,
|
|
|
|
|
|
.role-hero h3 {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-size: 0.95rem;
|
|
|
|
|
|
color: #0d9488;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.role-hero {
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
margin: 1rem 0 1.15rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.role-hero h3 {
|
|
|
|
|
|
font-size: 1.05rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.role-hero p,
|
|
|
|
|
|
.panel-block p,
|
|
|
|
|
|
.role-card p {
|
|
|
|
|
|
margin: 0.25rem 0 0;
|
|
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
font-size: 0.82rem;
|
|
|
|
|
|
line-height: 1.7;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.panel-block + .panel-block {
|
|
|
|
|
|
margin-top: 1.15rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.panel-block h4 {
|
|
|
|
|
|
margin: 0 0 0.7rem;
|
|
|
|
|
|
font-size: 0.92rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.perms,
|
|
|
|
|
|
.members {
|
|
|
|
|
|
list-style: none;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 0.55rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.perms li,
|
|
|
|
|
|
.members li {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.55rem;
|
|
|
|
|
|
font-size: 0.88rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.perm-check {
|
|
|
|
|
|
width: 1.15rem;
|
|
|
|
|
|
height: 1.15rem;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
place-items: center;
|
|
|
|
|
|
color: #cbd5e1;
|
|
|
|
|
|
background: #f8fafc;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.perm-check.on {
|
|
|
|
|
|
color: #16a34a;
|
|
|
|
|
|
background: #dcfce7;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.linkish {
|
|
|
|
|
|
appearance: none;
|
|
|
|
|
|
background: none;
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
color: #0d9488;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
padding: 0.7rem 0 0;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.panel-actions {
|
|
|
|
|
|
margin-top: auto;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 0.55rem;
|
|
|
|
|
|
padding-top: 1.1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.panel-body {
|
|
|
|
|
|
flex: 1;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
2026-09-15 17:33:05 +03:30
|
|
|
|
gap: 0.4rem;
|
|
|
|
|
|
min-height: 42px;
|
|
|
|
|
|
padding: 0 1rem;
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
font-weight: 800;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
border: 1px solid transparent;
|
|
|
|
|
|
cursor: pointer;
|
2026-09-15 17:33:05 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn.primary,
|
|
|
|
|
|
.btn.add {
|
|
|
|
|
|
background: #14b8a6;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.22);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn.add {
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
order: 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn.outline {
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
color: #0d9488;
|
|
|
|
|
|
border-color: #99f6e4;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn.danger-outline {
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
color: #e11d48;
|
|
|
|
|
|
border-color: #fecdd3;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn.danger {
|
|
|
|
|
|
background: #e11d48;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn.ghost {
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
border-color: #e2e8f0;
|
2026-09-21 17:55:05 +03:30
|
|
|
|
color: #334155;
|
2026-09-15 17:33:05 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pager {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
|
padding: 0.85rem 0.2rem 0;
|
|
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pages {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pages button {
|
|
|
|
|
|
width: 32px;
|
|
|
|
|
|
height: 32px;
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
color: #64748b;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pages button.on {
|
|
|
|
|
|
background: #ccfbf1;
|
|
|
|
|
|
color: #0f766e;
|
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-list {
|
|
|
|
|
|
display: none;
|
|
|
|
|
|
gap: 0.65rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.user-card {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: center;
|
2026-10-03 13:16:13 +03:30
|
|
|
|
gap: 0.75rem;
|
2026-09-15 17:33:05 +03:30
|
|
|
|
padding: 0.85rem 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.user-card .pill {
|
|
|
|
|
|
margin-top: 0.35rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-04 12:31:07 +03:30
|
|
|
|
.mobile-line {
|
|
|
|
|
|
margin: 0.2rem 0 0;
|
|
|
|
|
|
font-size: 0.78rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 13:16:13 +03:30
|
|
|
|
.card-meta {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
justify-items: end;
|
|
|
|
|
|
gap: 0.45rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.comm-field {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 0.15rem;
|
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
|
color: #64748b;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.comm-field input {
|
|
|
|
|
|
width: 4.8rem;
|
|
|
|
|
|
min-height: 2rem;
|
|
|
|
|
|
border: 1px solid #e2e8f0;
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
padding: 0 0.45rem;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.comm-field input:disabled {
|
|
|
|
|
|
background: #f8fafc;
|
|
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-21 14:20:15 +03:30
|
|
|
|
.logout-cell {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.3rem;
|
2026-10-19 13:34:55 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-21 14:20:15 +03:30
|
|
|
|
.logout-btn,
|
|
|
|
|
|
.block-btn {
|
|
|
|
|
|
display: inline-grid;
|
|
|
|
|
|
place-items: center;
|
|
|
|
|
|
height: 1.9rem;
|
|
|
|
|
|
border: 1px solid #e2e8f0;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
color: #64748b;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.logout-btn {
|
|
|
|
|
|
min-width: 1.9rem;
|
|
|
|
|
|
padding: 0 0.4rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.logout-btn.set {
|
|
|
|
|
|
color: #0f172a;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
font-variant-numeric: tabular-nums;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.logout-btn:hover,
|
|
|
|
|
|
.block-btn:hover {
|
|
|
|
|
|
border-color: #0d9488;
|
|
|
|
|
|
color: #0f766e;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.block-btn {
|
|
|
|
|
|
width: 1.9rem;
|
2026-10-19 13:34:55 +03:30
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-21 14:20:15 +03:30
|
|
|
|
.block-btn.on {
|
|
|
|
|
|
border-color: #99f6e4;
|
|
|
|
|
|
background: #f0fdfa;
|
|
|
|
|
|
color: #0f766e;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 13:16:13 +03:30
|
|
|
|
.roles-grid,
|
|
|
|
|
|
.roles-tree {
|
2026-09-15 17:33:05 +03:30
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 13:16:13 +03:30
|
|
|
|
.roles-tree {
|
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
.role-card {
|
|
|
|
|
|
text-align: right;
|
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
|
cursor: pointer;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
font: inherit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 13:16:13 +03:30
|
|
|
|
.roles-tree .role-card {
|
|
|
|
|
|
margin-inline-start: calc(var(--role-depth, 0) * 1.25rem);
|
|
|
|
|
|
border-inline-start: 3px solid transparent;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.roles-tree .role-card.child {
|
|
|
|
|
|
border-inline-start-color: #99f6e4;
|
|
|
|
|
|
background: #f8fffd;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.role-card-top {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.child-badge {
|
|
|
|
|
|
font-size: 0.72rem;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
color: #0f766e;
|
|
|
|
|
|
background: #ccfbf1;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
padding: 0.15rem 0.55rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
.role-card.on {
|
|
|
|
|
|
border-color: #99f6e4;
|
|
|
|
|
|
box-shadow: 0 0 0 2px #ccfbf1;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 13:16:13 +03:30
|
|
|
|
.parent-line {
|
|
|
|
|
|
margin: 0.35rem 0 0;
|
|
|
|
|
|
font-size: 0.82rem;
|
|
|
|
|
|
color: #64748b;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.block-head {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
margin-bottom: 0.45rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.block-head h4 {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.child-roles {
|
|
|
|
|
|
list-style: none;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 0.4rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.child-role-btn {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
border: 1px solid #e2e8f0;
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
padding: 0.55rem 0.7rem;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
text-align: right;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.child-role-btn small {
|
|
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
.access-wrap,
|
|
|
|
|
|
.org-grid,
|
|
|
|
|
|
.form {
|
2026-08-29 21:06:30 +03:30
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
.card {
|
|
|
|
|
|
padding: 1.1rem;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
.card h2 {
|
|
|
|
|
|
margin: 0 0 0.85rem;
|
|
|
|
|
|
font-size: 1rem;
|
2026-09-11 01:09:59 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
.org-card {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 1.15rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.org-card h2 {
|
2026-09-14 19:19:09 +03:30
|
|
|
|
margin: 0;
|
2026-09-15 17:33:05 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.org-card > .btn {
|
|
|
|
|
|
justify-self: start;
|
|
|
|
|
|
margin-top: 0.35rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.org-grid {
|
|
|
|
|
|
grid-template-columns: 1fr 1fr;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
label,
|
|
|
|
|
|
.perm-row {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
|
font-size: 0.88rem;
|
2026-09-11 01:09:59 +03:30
|
|
|
|
color: #64748b;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
input,
|
|
|
|
|
|
select,
|
|
|
|
|
|
textarea {
|
|
|
|
|
|
border: 1px solid #e2e8f0;
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
min-height: 42px;
|
|
|
|
|
|
padding: 0.55rem 0.85rem;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
background: #fff;
|
2026-09-14 19:19:09 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
.matrix-head {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: center;
|
2026-09-11 01:09:59 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
.matrix-mobile {
|
|
|
|
|
|
display: none;
|
2026-09-14 19:19:09 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
.matrix-scroll {
|
|
|
|
|
|
overflow: auto;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
.matrix th,
|
|
|
|
|
|
.matrix td {
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.matrix th:first-child,
|
|
|
|
|
|
.matrix td:first-child {
|
|
|
|
|
|
text-align: right;
|
|
|
|
|
|
position: sticky;
|
|
|
|
|
|
inset-inline-start: 0;
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.check {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
place-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.perm-set {
|
|
|
|
|
|
border: 1px solid #eef2f6;
|
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
|
padding: 0.85rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.perm-row {
|
|
|
|
|
|
grid-template-columns: auto 1fr;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
color: #0f172a;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.sheet-handle,
|
|
|
|
|
|
.sheet-backdrop {
|
|
|
|
|
|
display: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.back-chevron {
|
|
|
|
|
|
transform: rotate(180deg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 1100px) {
|
|
|
|
|
|
.stage {
|
|
|
|
|
|
grid-template-columns: minmax(0, 1fr) 280px;
|
|
|
|
|
|
grid-template-areas: 'list panel';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 900px) {
|
|
|
|
|
|
.stage {
|
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
|
grid-template-areas: 'list';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.main-col,
|
|
|
|
|
|
.role-panel {
|
|
|
|
|
|
grid-area: auto;
|
|
|
|
|
|
grid-column: auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.desktop-table {
|
|
|
|
|
|
display: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-list {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stats {
|
|
|
|
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stats article {
|
|
|
|
|
|
flex-direction: column-reverse;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
gap: 0.45rem;
|
|
|
|
|
|
padding: 0.75rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.toolbar {
|
|
|
|
|
|
flex-wrap: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.search {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
order: 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn.add {
|
|
|
|
|
|
flex: 0 0 auto;
|
|
|
|
|
|
order: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.org-grid,
|
2026-10-03 13:16:13 +03:30
|
|
|
|
.roles-grid,
|
|
|
|
|
|
.roles-tree {
|
2026-08-29 21:06:30 +03:30
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
|
}
|
2026-09-05 02:29:41 +03:30
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
.matrix-scroll {
|
|
|
|
|
|
display: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.matrix-mobile {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 0.85rem;
|
|
|
|
|
|
margin-top: 0.75rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.matrix-card {
|
|
|
|
|
|
overflow-x: clip;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.role-chips {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: 1fr 1fr;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.role-chip {
|
|
|
|
|
|
display: flex;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
flex-direction: column;
|
2026-09-15 17:33:05 +03:30
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
gap: 0.3rem;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
min-height: 52px;
|
|
|
|
|
|
padding: 0.55rem 0.7rem;
|
|
|
|
|
|
border: 1px solid #eef2f6;
|
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
|
background: #f8fafc;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
text-align: right;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.role-chip .pill {
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.role-chip small {
|
|
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.role-chip.on {
|
|
|
|
|
|
background: #f0fdfa;
|
|
|
|
|
|
border-color: #99f6e4;
|
|
|
|
|
|
box-shadow: 0 0 0 2px #ccfbf1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.access-list {
|
|
|
|
|
|
list-style: none;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.access-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
|
min-height: 46px;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 0.35rem 0.15rem;
|
|
|
|
|
|
color: #0f172a;
|
|
|
|
|
|
font-size: 0.92rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.access-row .check {
|
|
|
|
|
|
flex: 0 0 auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.sheet-backdrop {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
background: rgba(15, 23, 42, 0.28);
|
|
|
|
|
|
z-index: 70;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.role-panel.sheet {
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
inset-inline: 0;
|
|
|
|
|
|
bottom: var(--mobile-tabbar-h, 64px);
|
|
|
|
|
|
top: auto;
|
|
|
|
|
|
z-index: 71;
|
|
|
|
|
|
min-height: 0;
|
|
|
|
|
|
max-height: min(78dvh, 640px);
|
|
|
|
|
|
border-radius: 22px 22px 0 0;
|
2026-09-17 17:36:21 +03:30
|
|
|
|
padding-bottom: 0.85rem;
|
2026-09-15 17:33:05 +03:30
|
|
|
|
animation: sheet-up 0.22s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.sheet-handle {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
width: 42px;
|
|
|
|
|
|
height: 4px;
|
|
|
|
|
|
border-radius: 99px;
|
|
|
|
|
|
background: #e2e8f0;
|
|
|
|
|
|
margin: 0 auto 0.4rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.panel-actions {
|
|
|
|
|
|
position: sticky;
|
|
|
|
|
|
bottom: 0;
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
padding-top: 0.75rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pager {
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@keyframes sheet-up {
|
|
|
|
|
|
from {
|
|
|
|
|
|
transform: translateY(100%);
|
|
|
|
|
|
}
|
|
|
|
|
|
to {
|
|
|
|
|
|
transform: translateY(0);
|
2026-09-05 02:29:41 +03:30
|
|
|
|
}
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
</style>
|