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';
|
|
|
|
|
|
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';
|
|
|
|
|
|
import UiModal from '@/components/ui/UiModal.vue';
|
|
|
|
|
|
import {
|
|
|
|
|
|
ROLE_PERMISSION_ITEMS,
|
|
|
|
|
|
type InviteUserRequest,
|
|
|
|
|
|
type OrganizationDto,
|
|
|
|
|
|
type OrgRoleDto,
|
|
|
|
|
|
type OrgUserDto,
|
|
|
|
|
|
type RolePermissionKey,
|
|
|
|
|
|
type UpdateOrganizationRequest,
|
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';
|
|
|
|
|
|
|
|
|
|
|
|
type TabKey = 'users' | 'roles' | 'access';
|
|
|
|
|
|
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-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);
|
|
|
|
|
|
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-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('');
|
|
|
|
|
|
|
|
|
|
|
|
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 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-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-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) {
|
|
|
|
|
|
return `${toFaDigits(role.permissions.length)} از ${toFaDigits(ROLE_PERMISSION_ITEMS.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) {
|
|
|
|
|
|
const needle = q.toLowerCase();
|
|
|
|
|
|
list = list.filter(
|
|
|
|
|
|
(u) =>
|
|
|
|
|
|
u.fullName.toLowerCase().includes(needle) ||
|
|
|
|
|
|
(u.mobile ?? '').includes(needle) ||
|
|
|
|
|
|
(u.orgRoleName ?? '').includes(q),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return [...list].sort((a, b) => {
|
|
|
|
|
|
const at = a.lastLoginAt ? new Date(a.lastLoginAt).getTime() : 0;
|
|
|
|
|
|
const bt = b.lastLoginAt ? new Date(b.lastLoginAt).getTime() : 0;
|
|
|
|
|
|
return sortLoginDesc.value ? bt - at : at - bt;
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const pageCount = computed(() => Math.max(1, Math.ceil(filteredUsers.value.length / PAGE_SIZE)));
|
|
|
|
|
|
|
|
|
|
|
|
const pagedUsers = computed(() => {
|
|
|
|
|
|
const start = (page.value - 1) * PAGE_SIZE;
|
|
|
|
|
|
return filteredUsers.value.slice(start, start + PAGE_SIZE);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const pageLabel = computed(() => {
|
|
|
|
|
|
const total = filteredUsers.value.length;
|
|
|
|
|
|
if (!total) return '۰ کاربر';
|
|
|
|
|
|
const from = (page.value - 1) * PAGE_SIZE + 1;
|
|
|
|
|
|
const to = Math.min(page.value * PAGE_SIZE, total);
|
|
|
|
|
|
return `${toFaDigits(from)} تا ${toFaDigits(to)} از ${toFaDigits(total)} کاربر`;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const visibleMembers = computed(() => {
|
|
|
|
|
|
const role = selectedRole.value;
|
|
|
|
|
|
if (!role) return [];
|
|
|
|
|
|
return showAllMembers.value ? role.members : role.members.slice(0, 3);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const inviteableRoles = computed(() =>
|
|
|
|
|
|
roles.value.filter((r) => r.systemKey !== 'owner' || auth.user?.role === 'owner'),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
function tabFromHash(hash: string): TabKey {
|
|
|
|
|
|
if (hash.includes('settings-org') || hash.includes('access')) return 'access';
|
|
|
|
|
|
if (hash.includes('roles')) return 'roles';
|
|
|
|
|
|
return 'users';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setTab(next: TabKey) {
|
|
|
|
|
|
tab.value = next;
|
|
|
|
|
|
const hash = next === 'access' ? 'settings-org' : next === 'roles' ? 'roles' : 'settings-team';
|
|
|
|
|
|
if (route.hash.replace('#', '') !== hash) {
|
|
|
|
|
|
void router.replace({ hash: `#${hash}` });
|
|
|
|
|
|
}
|
|
|
|
|
|
if (next !== 'users' && next !== 'roles') closeSheet();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeSheet() {
|
|
|
|
|
|
if (isNarrow.value) selectedRoleId.value = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ensureDesktopRole() {
|
|
|
|
|
|
if (isNarrow.value) return;
|
|
|
|
|
|
if (selectedRoleId.value && roles.value.some((r) => r.id === selectedRoleId.value)) return;
|
|
|
|
|
|
const sales = roles.value.find((r) => r.systemKey === 'sales') ?? roles.value[0];
|
|
|
|
|
|
selectedRoleId.value = sales?.id ?? null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function pickRole(id: string) {
|
|
|
|
|
|
selectedRoleId.value = id;
|
|
|
|
|
|
showAllMembers.value = false;
|
|
|
|
|
|
menuUserId.value = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onUserActivate(user: OrgUserDto) {
|
|
|
|
|
|
if (user.orgRoleId) pickRole(user.orgRoleId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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-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-09-15 17:33:05 +03:30
|
|
|
|
function openCreateRole() {
|
|
|
|
|
|
roleFormId.value = null;
|
|
|
|
|
|
roleFormName.value = '';
|
|
|
|
|
|
roleFormDesc.value = '';
|
|
|
|
|
|
roleFormColor.value = 'teal';
|
|
|
|
|
|
roleFormPerms.value = ROLE_PERMISSION_ITEMS.map((p) => p.key);
|
|
|
|
|
|
roleFormOpen.value = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
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-09-15 17:33:05 +03:30
|
|
|
|
const body = {
|
|
|
|
|
|
name: roleFormName.value.trim(),
|
|
|
|
|
|
description: roleFormDesc.value.trim(),
|
|
|
|
|
|
color: roleFormColor.value,
|
|
|
|
|
|
permissions: roleFormPerms.value,
|
|
|
|
|
|
};
|
|
|
|
|
|
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>
|
|
|
|
|
|
<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-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"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="pill" :data-color="role.color">{{ role.name }}</span>
|
|
|
|
|
|
<small>{{ permCountLabel(role) }}</small>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ul v-if="accessFocusRole" class="access-list">
|
|
|
|
|
|
<li v-for="perm in ROLE_PERMISSION_ITEMS" :key="perm.key">
|
|
|
|
|
|
<label class="access-row">
|
|
|
|
|
|
<span>{{ perm.label }}</span>
|
|
|
|
|
|
<span class="check">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
:checked="accessFocusRole.permissions.includes(perm.key)"
|
|
|
|
|
|
:disabled="!canEditRoles"
|
|
|
|
|
|
@change="toggleAccess(accessFocusRole, perm.key)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="matrix-scroll">
|
|
|
|
|
|
<table class="matrix">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>مجوز</th>
|
|
|
|
|
|
<th v-for="role in roles" :key="role.id">{{ role.name }}</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr v-for="perm in ROLE_PERMISSION_ITEMS" :key="perm.key">
|
|
|
|
|
|
<th>{{ perm.label }}</th>
|
|
|
|
|
|
<td v-for="role in roles" :key="role.id + perm.key">
|
|
|
|
|
|
<label class="check">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
:checked="role.permissions.includes(perm.key)"
|
|
|
|
|
|
:disabled="!canEditRoles"
|
|
|
|
|
|
@change="toggleAccess(role, perm.key)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span />
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-else class="stage" :class="{ 'panel-open': Boolean(selectedRole) }">
|
|
|
|
|
|
<aside v-if="selectedRole" class="role-panel" :class="{ sheet: isNarrow }">
|
|
|
|
|
|
<div v-if="isNarrow" class="sheet-handle" />
|
|
|
|
|
|
<header class="panel-head">
|
|
|
|
|
|
<div class="panel-title">
|
|
|
|
|
|
<span class="title-ico sm"><UiIcon name="user" :size="16" /></span>
|
|
|
|
|
|
<h2>جزئیات نقش</h2>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="ghost-ico"
|
|
|
|
|
|
:aria-label="isNarrow ? 'بستن' : 'بازگشت'"
|
|
|
|
|
|
@click="isNarrow ? (selectedRoleId = null) : undefined"
|
|
|
|
|
|
>
|
|
|
|
|
|
<UiIcon :name="isNarrow ? 'x' : 'chevron'" :size="18" :class="{ 'back-chevron': !isNarrow }" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="panel-body">
|
|
|
|
|
|
<div class="role-hero">
|
|
|
|
|
|
<span class="title-ico"><UiIcon name="user" :size="18" /></span>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h3>{{ selectedRole.name }}</h3>
|
|
|
|
|
|
<p>{{ selectedRole.description || '—' }}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<section class="panel-block">
|
|
|
|
|
|
<h4>مجوزهای دسترسی</h4>
|
|
|
|
|
|
<ul class="perms">
|
|
|
|
|
|
<li v-for="perm in ROLE_PERMISSION_ITEMS" :key="perm.key">
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="perm-check"
|
|
|
|
|
|
:class="{ on: selectedRole.permissions.includes(perm.key) }"
|
|
|
|
|
|
>
|
|
|
|
|
|
<UiIcon name="check-plain" :size="14" />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{{ perm.label }}
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section v-if="!isNarrow" class="panel-block">
|
|
|
|
|
|
<h4>کاربران دارای این نقش</h4>
|
|
|
|
|
|
<ul class="members">
|
|
|
|
|
|
<li v-for="member in visibleMembers" :key="member.id">
|
|
|
|
|
|
<img :src="avatarOf(member.id)" :alt="member.fullName" />
|
|
|
|
|
|
<span>{{ member.fullName }}</span>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
<li v-if="!selectedRole.members.length" class="empty">هنوز کاربری با این نقش نیست.</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-if="selectedRole.members.length > 3"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="linkish"
|
|
|
|
|
|
@click="showAllMembers = !showAllMembers"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ showAllMembers ? 'نمایش کمتر' : `مشاهده همه (${toFaDigits(selectedRole.userCount)})` }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<footer v-if="canEditRoles" class="panel-actions">
|
|
|
|
|
|
<button type="button" class="btn outline" @click="openEditRole">
|
|
|
|
|
|
<UiIcon name="pencil" :size="16" />
|
|
|
|
|
|
ویرایش نقش
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-if="!isNarrow && !selectedRole.systemKey"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="btn danger-outline"
|
|
|
|
|
|
@click="deleteOpen = true"
|
|
|
|
|
|
>
|
|
|
|
|
|
<UiIcon name="trash" :size="16" />
|
|
|
|
|
|
حذف نقش
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</footer>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
<div v-if="isNarrow && selectedRoleId" class="sheet-backdrop" @click="selectedRoleId = null" />
|
|
|
|
|
|
|
|
|
|
|
|
<div class="main-col">
|
|
|
|
|
|
<div class="toolbar">
|
|
|
|
|
|
<label v-if="tab === 'users'" class="search">
|
|
|
|
|
|
<UiIcon name="search" :size="18" />
|
|
|
|
|
|
<input v-model="query" type="search" placeholder="جستجو در کاربران..." />
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<button v-if="canInvite && tab === 'users'" type="button" class="btn primary add" @click="openInvite">
|
|
|
|
|
|
<span>+</span>
|
|
|
|
|
|
کاربر جدید
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button v-else-if="canEditRoles && tab === 'roles'" type="button" class="btn primary add" @click="openCreateRole">
|
|
|
|
|
|
<span>+</span>
|
|
|
|
|
|
نقش جدید
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="tab === 'users'" class="stats">
|
|
|
|
|
|
<article>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p>کل کاربران</p>
|
|
|
|
|
|
<strong>{{ toFaDigits(stats.total) }}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span class="stat-ico users"><UiIcon name="users" :size="18" /></span>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
<article>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p>فعال</p>
|
|
|
|
|
|
<strong>{{ toFaDigits(stats.active) }}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span class="stat-ico on"><UiIcon name="check-plain" :size="16" /></span>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
<article>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p>غیرفعال</p>
|
|
|
|
|
|
<strong>{{ toFaDigits(stats.inactive) }}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span class="stat-ico off"><UiIcon name="x-circle" :size="16" /></span>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="tab === 'users'" class="table-card desktop-table">
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>نام و نام خانوادگی</th>
|
|
|
|
|
|
<th>نقش</th>
|
|
|
|
|
|
<th>وضعیت</th>
|
|
|
|
|
|
<th>
|
|
|
|
|
|
<button type="button" class="sort" @click="sortLoginDesc = !sortLoginDesc">
|
|
|
|
|
|
آخرین ورود
|
|
|
|
|
|
<span :class="{ flip: !sortLoginDesc }">▴</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</th>
|
|
|
|
|
|
<th class="ops" />
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr v-for="user in pagedUsers" :key="user.id" @click="onUserActivate(user)">
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<div class="who">
|
|
|
|
|
|
<img :src="avatarOf(user.id)" :alt="user.fullName" />
|
|
|
|
|
|
<strong>{{ user.fullName }}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<span class="pill" :data-color="user.orgRoleColor || 'teal'">
|
|
|
|
|
|
{{ user.orgRoleName || '—' }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<span class="status" :class="{ off: user.isActive === false }">
|
|
|
|
|
|
<i />
|
|
|
|
|
|
{{ user.isActive === false ? 'غیرفعال' : 'فعال' }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td class="muted">{{ formatLastLogin(user.lastLoginAt) }}</td>
|
|
|
|
|
|
<td class="ops" @click.stop>
|
|
|
|
|
|
<div class="kebab">
|
|
|
|
|
|
<button type="button" class="ghost-ico" aria-label="عملیات" @click.stop="menuUserId = menuUserId === user.id ? null : user.id">
|
|
|
|
|
|
<UiIcon name="dots-v" :size="16" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<div v-if="menuUserId === user.id" class="menu" @click.stop>
|
|
|
|
|
|
<button type="button" @click="toggleStatus(user)">
|
|
|
|
|
|
{{ user.isActive === false ? 'فعال کردن' : 'غیرفعال کردن' }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
<tr v-if="!loading && !pagedUsers.length">
|
|
|
|
|
|
<td colspan="5" class="empty">کاربری یافت نشد.</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="tab === 'users'" class="mobile-list">
|
|
|
|
|
|
<article v-for="user in pagedUsers" :key="user.id" class="user-card" @click="onUserActivate(user)">
|
|
|
|
|
|
<div class="who">
|
|
|
|
|
|
<img :src="avatarOf(user.id)" :alt="user.fullName" />
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong>{{ user.fullName }}</strong>
|
|
|
|
|
|
<span class="pill" :data-color="user.orgRoleColor || 'teal'">
|
|
|
|
|
|
{{ user.orgRoleName || '—' }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span class="status" :class="{ off: user.isActive === false }">
|
|
|
|
|
|
<i />
|
|
|
|
|
|
{{ user.isActive === false ? 'غیرفعال' : 'فعال' }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
<p v-if="!loading && !pagedUsers.length" class="empty">کاربری یافت نشد.</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="tab === 'roles'" class="roles-grid">
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-for="role in roles"
|
|
|
|
|
|
:key="role.id"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="role-card"
|
|
|
|
|
|
:class="{ on: selectedRole?.id === role.id }"
|
|
|
|
|
|
@click="pickRole(role.id)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="pill" :data-color="role.color">{{ role.name }}</span>
|
|
|
|
|
|
<p>{{ role.description || 'بدون توضیح' }}</p>
|
|
|
|
|
|
<small>{{ toFaDigits(role.userCount) }} کاربر</small>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<footer v-if="tab === 'users' && filteredUsers.length" class="pager">
|
|
|
|
|
|
<span>{{ pageLabel }}</span>
|
|
|
|
|
|
<div class="pages">
|
|
|
|
|
|
<button type="button" :disabled="page <= 1" @click="page -= 1">‹</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-for="n in pageCount"
|
|
|
|
|
|
:key="n"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
:class="{ on: n === page }"
|
|
|
|
|
|
@click="page = n"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ toFaDigits(n) }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" :disabled="page >= pageCount" @click="page += 1">›</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</footer>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<UiModal :open="inviteOpen" title="کاربر جدید" @close="inviteOpen = false">
|
|
|
|
|
|
<form class="form" @submit.prevent="submitInvite">
|
|
|
|
|
|
<label>نام و نام خانوادگی<input v-model="inviteName" required minlength="2" /></label>
|
|
|
|
|
|
<label>شماره موبایل<input v-model="inviteMobile" dir="ltr" inputmode="tel" placeholder="0912…" /></label>
|
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">
|
|
|
|
|
|
<option v-for="role in inviteableRoles" :key="role.id" :value="role.id">{{ role.name }}</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<button type="button" class="btn ghost" @click="inviteOpen = false">انصراف</button>
|
|
|
|
|
|
<button type="button" class="btn primary" :disabled="inviteSaving" @click="submitInvite">
|
|
|
|
|
|
{{ inviteSaving ? 'در حال ذخیره…' : 'افزودن کاربر' }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</UiModal>
|
|
|
|
|
|
|
|
|
|
|
|
<UiModal :open="roleFormOpen" :title="roleFormId ? 'ویرایش نقش' : 'نقش جدید'" @close="roleFormOpen = false">
|
|
|
|
|
|
<form class="form" @submit.prevent="submitRoleForm">
|
|
|
|
|
|
<label>نام نقش<input v-model="roleFormName" required minlength="2" /></label>
|
|
|
|
|
|
<label>توضیح<textarea v-model="roleFormDesc" rows="3" /></label>
|
|
|
|
|
|
<label>
|
|
|
|
|
|
رنگ
|
|
|
|
|
|
<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>
|
|
|
|
|
|
<label v-for="perm in ROLE_PERMISSION_ITEMS" :key="perm.key" class="perm-row">
|
|
|
|
|
|
<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-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
.search input {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.kebab {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.menu {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
inset-inline-start: 0;
|
|
|
|
|
|
top: 100%;
|
|
|
|
|
|
z-index: 5;
|
|
|
|
|
|
min-width: 9rem;
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
border: 1px solid #e2e8f0;
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.12);
|
|
|
|
|
|
padding: 0.35rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.menu button {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
appearance: none;
|
|
|
|
|
|
background: none;
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
text-align: right;
|
|
|
|
|
|
padding: 0.45rem 0.55rem;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.menu button:hover {
|
|
|
|
|
|
background: #f8fafc;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.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;
|
|
|
|
|
|
padding: 0.85rem 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.user-card .pill {
|
|
|
|
|
|
margin-top: 0.35rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.roles-grid {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.role-card {
|
|
|
|
|
|
text-align: right;
|
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
|
cursor: pointer;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
font: inherit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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-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,
|
|
|
|
|
|
.roles-grid {
|
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>
|