1783 lines
43 KiB
Vue
1783 lines
43 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||
import { useRoute, useRouter } from 'vue-router';
|
||
import axios from 'axios';
|
||
import { api } from '@/lib/api';
|
||
import { useAuthStore } from '@/stores/auth';
|
||
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,
|
||
} from '@mento/shared';
|
||
import { canManageSettings, canManageUsers } from '@/lib/roles';
|
||
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
|
||
import {
|
||
calendarDaysBetween,
|
||
formatClockFa,
|
||
formatJalaliNumeric,
|
||
toFaDigits,
|
||
} from '@/lib/jalali';
|
||
|
||
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: 'سرخابی' },
|
||
];
|
||
|
||
const auth = useAuthStore();
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
|
||
const loading = ref(false);
|
||
const error = ref('');
|
||
const ok = ref('');
|
||
const users = ref<OrgUserDto[]>([]);
|
||
const roles = ref<OrgRoleDto[]>([]);
|
||
const org = ref<OrganizationDto | null>(null);
|
||
const orgName = ref('');
|
||
const businessField = ref('');
|
||
const orgSaving = ref(false);
|
||
const accessSaving = ref(false);
|
||
|
||
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);
|
||
|
||
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>();
|
||
|
||
const canInvite = computed(() => canManageUsers(auth.user?.role));
|
||
const canEditOrg = computed(() => canManageSettings(auth.user?.role));
|
||
const canEditRoles = computed(() => canManageUsers(auth.user?.role));
|
||
|
||
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;
|
||
}
|
||
|
||
async function load() {
|
||
loading.value = true;
|
||
error.value = '';
|
||
try {
|
||
const [usersRes, rolesRes, orgRes] = await Promise.all([
|
||
api.get<OrgUserDto[]>('/users'),
|
||
api.get<OrgRoleDto[]>('/org-roles'),
|
||
api.get<OrganizationDto>('/organizations/current'),
|
||
]);
|
||
users.value = usersRes.data;
|
||
roles.value = rolesRes.data;
|
||
org.value = orgRes.data;
|
||
orgName.value = orgRes.data.name;
|
||
businessField.value = orgRes.data.businessField ?? '';
|
||
if (!isNarrow.value) ensureDesktopRole();
|
||
await loadAvatars([
|
||
...users.value.map((u) => ({ id: u.id, hasAvatar: u.hasAvatar })),
|
||
...roles.value.flatMap((r) => r.members),
|
||
]);
|
||
} catch (e) {
|
||
error.value = axios.isAxiosError(e) && !e.response ? 'اتصال به API برقرار نشد.' : 'بارگذاری کاربران ناموفق بود.';
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function saveOrg() {
|
||
if (!canEditOrg.value) return;
|
||
if (!businessField.value) {
|
||
error.value = 'زمینه فعالیت را انتخاب کنید.';
|
||
return;
|
||
}
|
||
orgSaving.value = true;
|
||
error.value = '';
|
||
ok.value = '';
|
||
try {
|
||
const payload: UpdateOrganizationRequest = {
|
||
name: orgName.value.trim(),
|
||
businessField: businessField.value as UpdateOrganizationRequest['businessField'],
|
||
};
|
||
const { data } = await api.patch<OrganizationDto>('/organizations/current', payload);
|
||
org.value = data;
|
||
orgName.value = data.name;
|
||
businessField.value = data.businessField ?? '';
|
||
ok.value = 'مشخصات سازمان ذخیره شد.';
|
||
} catch (e) {
|
||
error.value = apiError(e, 'ذخیره سازمان ناموفق بود');
|
||
} finally {
|
||
orgSaving.value = false;
|
||
}
|
||
}
|
||
|
||
function openInvite() {
|
||
inviteName.value = '';
|
||
inviteMobile.value = '';
|
||
invitePassword.value = '';
|
||
inviteRoleId.value =
|
||
inviteableRoles.value.find((r) => r.systemKey === 'sales')?.id ??
|
||
inviteableRoles.value[0]?.id ??
|
||
'';
|
||
inviteOpen.value = true;
|
||
error.value = '';
|
||
}
|
||
|
||
async function submitInvite() {
|
||
if (!inviteName.value.trim() || !inviteMobile.value.trim()) {
|
||
error.value = 'نام و شماره موبایل لازم است.';
|
||
return;
|
||
}
|
||
if (invitePassword.value && invitePassword.value.length < 8) {
|
||
error.value = 'رمز عبور در صورت ورود باید حداقل ۸ کاراکتر باشد.';
|
||
return;
|
||
}
|
||
inviteSaving.value = true;
|
||
error.value = '';
|
||
try {
|
||
const payload: InviteUserRequest = {
|
||
mobile: inviteMobile.value.trim(),
|
||
fullName: inviteName.value.trim(),
|
||
orgRoleId: inviteRoleId.value || undefined,
|
||
...(invitePassword.value ? { password: invitePassword.value } : {}),
|
||
};
|
||
await api.post('/users', payload);
|
||
inviteOpen.value = false;
|
||
ok.value = 'کاربر به این محیط اضافه شد.';
|
||
await load();
|
||
} catch (e) {
|
||
error.value = apiError(e, 'افزودن کاربر ناموفق بود');
|
||
} finally {
|
||
inviteSaving.value = false;
|
||
}
|
||
}
|
||
|
||
async function toggleStatus(user: OrgUserDto) {
|
||
menuUserId.value = null;
|
||
error.value = '';
|
||
try {
|
||
await api.patch(`/users/${user.id}/status`, { isActive: user.isActive === false });
|
||
await load();
|
||
} catch (e) {
|
||
error.value = apiError(e, 'تغییر وضعیت ناموفق بود');
|
||
}
|
||
}
|
||
|
||
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;
|
||
error.value = '';
|
||
try {
|
||
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 ? 'نقش بهروز شد.' : 'نقش جدید ساخته شد.';
|
||
await load();
|
||
} catch (e) {
|
||
error.value = apiError(e, 'ذخیره نقش ناموفق بود');
|
||
} finally {
|
||
roleFormSaving.value = false;
|
||
}
|
||
}
|
||
|
||
async function confirmDeleteRole() {
|
||
const role = selectedRole.value;
|
||
if (!role || role.systemKey) return;
|
||
deleteSaving.value = true;
|
||
error.value = '';
|
||
try {
|
||
await api.delete(`/org-roles/${role.id}`);
|
||
deleteOpen.value = false;
|
||
selectedRoleId.value = null;
|
||
ok.value = 'نقش حذف شد.';
|
||
await load();
|
||
} catch (e) {
|
||
error.value = apiError(e, 'حذف نقش ناموفق بود');
|
||
} finally {
|
||
deleteSaving.value = false;
|
||
}
|
||
}
|
||
|
||
async function toggleAccess(role: OrgRoleDto, key: RolePermissionKey) {
|
||
const next = role.permissions.includes(key)
|
||
? role.permissions.filter((p) => p !== key)
|
||
: [...role.permissions, key];
|
||
role.permissions = next;
|
||
accessSaving.value = true;
|
||
error.value = '';
|
||
try {
|
||
await api.patch(`/org-roles/${role.id}`, { permissions: next });
|
||
} catch (e) {
|
||
error.value = apiError(e, 'ذخیره دسترسی ناموفق بود');
|
||
await load();
|
||
} finally {
|
||
accessSaving.value = false;
|
||
}
|
||
}
|
||
|
||
function 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);
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<section class="page">
|
||
<header class="page-head">
|
||
<div class="head-copy">
|
||
<h1>
|
||
<span class="title-ico"><UiIcon name="users" :size="22" /></span>
|
||
مدیریت کاربران
|
||
</h1>
|
||
<p class="lead">کاربران سامانه را مدیریت کنید و نقشهای مختلف را برای آنها تعریف کنید.</p>
|
||
</div>
|
||
</header>
|
||
|
||
<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>
|
||
|
||
<p v-if="error" class="error">{{ error }}</p>
|
||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||
|
||
<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>
|
||
<label>
|
||
رمز عبور
|
||
<input v-model="invitePassword" type="password" minlength="8" placeholder="فقط برای کاربر جدید" />
|
||
</label>
|
||
<p class="hint">اگر این شماره از قبل حساب دارد، بدون رمز به همین محیط اضافه میشود.</p>
|
||
<label>
|
||
نقش
|
||
<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 }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<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 }}
|
||
</label>
|
||
</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>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.page {
|
||
display: grid;
|
||
gap: 1rem;
|
||
min-width: 0;
|
||
}
|
||
|
||
.page-head {
|
||
display: flex;
|
||
justify-content: flex-start;
|
||
width: 100%;
|
||
}
|
||
|
||
.head-copy {
|
||
text-align: right;
|
||
width: 100%;
|
||
}
|
||
|
||
h1 {
|
||
margin: 0 0 0.35rem;
|
||
font-size: clamp(1.35rem, 2.2vw, 1.7rem);
|
||
font-weight: 800;
|
||
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;
|
||
}
|
||
|
||
.lead,
|
||
.muted,
|
||
.hint,
|
||
.empty {
|
||
margin: 0;
|
||
color: #94a3b8;
|
||
font-size: 0.92rem;
|
||
}
|
||
|
||
.error {
|
||
margin: 0;
|
||
color: #b91c1c;
|
||
}
|
||
|
||
.ok {
|
||
margin: 0;
|
||
color: #0f766e;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.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;
|
||
background: #fff;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 12px;
|
||
min-height: 42px;
|
||
padding: 0 0.85rem;
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.search input {
|
||
flex: 1;
|
||
border: 0;
|
||
outline: none;
|
||
font: inherit;
|
||
background: transparent;
|
||
min-width: 0;
|
||
}
|
||
|
||
.stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 0.75rem;
|
||
margin-bottom: 0.85rem;
|
||
}
|
||
|
||
.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;
|
||
display: grid;
|
||
place-items: center;
|
||
}
|
||
|
||
.stat-ico.users {
|
||
background: #eef2ff;
|
||
color: #6366f1;
|
||
}
|
||
|
||
.stat-ico.on {
|
||
background: #dcfce7;
|
||
color: #16a34a;
|
||
}
|
||
|
||
.stat-ico.off {
|
||
background: #f1f5f9;
|
||
color: #64748b;
|
||
}
|
||
|
||
.table-card,
|
||
.role-panel,
|
||
.card,
|
||
.user-card,
|
||
.role-card {
|
||
background: #fff;
|
||
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;
|
||
}
|
||
|
||
.btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 0.4rem;
|
||
min-height: 42px;
|
||
padding: 0 1rem;
|
||
border-radius: 12px;
|
||
font: inherit;
|
||
font-weight: 800;
|
||
border: 1px solid transparent;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.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;
|
||
color: #334155;
|
||
}
|
||
|
||
.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;
|
||
font: inherit;
|
||
}
|
||
|
||
.role-card.on {
|
||
border-color: #99f6e4;
|
||
box-shadow: 0 0 0 2px #ccfbf1;
|
||
}
|
||
|
||
.access-wrap,
|
||
.org-grid,
|
||
.form {
|
||
display: grid;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.card {
|
||
padding: 1.1rem;
|
||
}
|
||
|
||
.card h2 {
|
||
margin: 0 0 0.85rem;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.org-card {
|
||
display: grid;
|
||
gap: 1.15rem;
|
||
}
|
||
|
||
.org-card h2 {
|
||
margin: 0;
|
||
}
|
||
|
||
.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;
|
||
color: #64748b;
|
||
}
|
||
|
||
input,
|
||
select,
|
||
textarea {
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 12px;
|
||
min-height: 42px;
|
||
padding: 0.55rem 0.85rem;
|
||
font: inherit;
|
||
background: #fff;
|
||
}
|
||
|
||
.matrix-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.matrix-mobile {
|
||
display: none;
|
||
}
|
||
|
||
.matrix-scroll {
|
||
overflow: auto;
|
||
}
|
||
|
||
.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 {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.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;
|
||
flex-direction: column;
|
||
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;
|
||
padding-bottom: 0.85rem;
|
||
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);
|
||
}
|
||
}
|
||
</style>
|