Files
mbmanager-dashboard/src/views/pages/Roles/ListRoles.vue
T
2026-08-19 06:02:20 +03:30

728 lines
22 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import ConfirmPopup from 'primevue/confirmpopup';
import { RoleService } from '@/service/RoleService';
import { PermissionService } from '@/service/PermissionService';
const toast = useToast();
const confirm = useConfirm();
const roles = ref([]);
const allPermissions = ref([]);
const loading = ref(true);
const showDialog = ref(false);
const editMode = ref(false);
const submitting = ref(false);
const selectedRole = ref(null);
const searchPhrase = ref('');
const permissionFilter = ref('');
const form = ref({
englishName: '',
nativeName: '',
permissions: []
});
onMounted(async () => {
await Promise.all([loadRoles(), loadPermissions()]);
});
const loadRoles = async () => {
loading.value = true;
try {
roles.value = await RoleService.listRoles();
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در بارگذاری لیست نقش‌ها', life: 3000 });
} finally {
loading.value = false;
}
};
const loadPermissions = async () => {
try {
allPermissions.value = await PermissionService.listPermissions();
} catch (e) {
console.error('Error loading permissions:', e);
}
};
const filteredRoles = computed(() => {
if (!searchPhrase.value) return roles.value;
const q = searchPhrase.value.toLowerCase().trim();
return roles.value.filter((r) => {
const en = (r.englishName || '').toLowerCase();
const fa = (r.nativeName || '').toLowerCase();
return en.includes(q) || fa.includes(q);
});
});
// Group permissions by category
const categorizedPermissions = computed(() => {
const filter = permissionFilter.value.toLowerCase().trim();
const groups = {};
allPermissions.value.forEach((p) => {
if (filter) {
const matchesFa = (p.nativeName || '').toLowerCase().includes(filter);
const matchesEn = (p.englishName || '').toLowerCase().includes(filter);
const matchesKey = (p.key || '').toLowerCase().includes(filter);
const matchesCat = (p.category || '').toLowerCase().includes(filter);
if (!matchesFa && !matchesEn && !matchesKey && !matchesCat) return;
}
const cat = p.category || 'سایر دسترسی‌ها';
if (!groups[cat]) {
groups[cat] = [];
}
groups[cat].push(p);
});
return groups;
});
const openCreate = () => {
editMode.value = false;
selectedRole.value = null;
permissionFilter.value = '';
form.value = {
englishName: '',
nativeName: '',
permissions: []
};
showDialog.value = true;
};
const openEdit = (r) => {
editMode.value = true;
selectedRole.value = r;
permissionFilter.value = '';
const permIds = (r.permissions || []).map((p) => (p._id || p).toString());
form.value = {
englishName: r.englishName || '',
nativeName: r.nativeName || '',
permissions: permIds
};
showDialog.value = true;
};
const togglePermission = (permId) => {
const id = permId.toString();
const idx = form.value.permissions.indexOf(id);
if (idx === -1) {
form.value.permissions.push(id);
} else {
form.value.permissions.splice(idx, 1);
}
};
const isPermissionSelected = (permId) => {
return form.value.permissions.includes(permId.toString());
};
const toggleCategory = (categoryName) => {
const catPerms = categorizedPermissions.value[categoryName] || [];
const catIds = catPerms.map((p) => p._id.toString());
const allSelected = catIds.every((id) => form.value.permissions.includes(id));
if (allSelected) {
// Deselect all in category
form.value.permissions = form.value.permissions.filter((id) => !catIds.includes(id));
} else {
// Select all in category
catIds.forEach((id) => {
if (!form.value.permissions.includes(id)) {
form.value.permissions.push(id);
}
});
}
};
const isCategoryAllSelected = (categoryName) => {
const catPerms = categorizedPermissions.value[categoryName] || [];
if (catPerms.length === 0) return false;
return catPerms.every((p) => form.value.permissions.includes(p._id.toString()));
};
const selectAllPermissions = () => {
form.value.permissions = allPermissions.value.map((p) => p._id.toString());
};
const deselectAllPermissions = () => {
form.value.permissions = [];
};
const submitForm = async () => {
if (!form.value.nativeName) {
toast.add({ severity: 'warn', summary: 'توجه', detail: 'نام فارسی نقش الزامی است', life: 3000 });
return;
}
if (!form.value.englishName) {
toast.add({ severity: 'warn', summary: 'توجه', detail: 'نام انگلیسی نقش الزامی است', life: 3000 });
return;
}
submitting.value = true;
try {
const payload = {
englishName: form.value.englishName.trim(),
nativeName: form.value.nativeName.trim(),
permissions: form.value.permissions
};
if (editMode.value) {
await RoleService.updateRole(selectedRole.value._id, payload);
toast.add({ severity: 'success', summary: 'موفق', detail: 'نقش با موفقیت ویرایش شد', life: 3000 });
} else {
await RoleService.createRole(payload);
toast.add({ severity: 'success', summary: 'موفق', detail: 'نقش جدید با موفقیت ایجاد شد', life: 3000 });
}
showDialog.value = false;
await loadRoles();
} catch (e) {
toast.add({
severity: 'error',
summary: 'خطا',
detail: e.response?.data?.message || 'عملیات ناموفق بود',
life: 4000
});
} finally {
submitting.value = false;
}
};
const confirmDelete = (r, event) => {
if (r.englishName === 'SuperAdmin') {
toast.add({ severity: 'warn', summary: 'غیرمجاز', detail: 'نقش مدیر اصلی سیستم قابل حذف نیست', life: 3000 });
return;
}
confirm.require({
target: event.currentTarget,
message: `آیا از حذف نقش "${r.nativeName}" اطمینان دارید؟`,
icon: 'pi pi-exclamation-triangle',
rejectProps: { label: 'خیر', severity: 'secondary', outlined: true },
acceptProps: { label: 'بله', severity: 'danger' },
accept: async () => {
try {
await RoleService.deleteRole(r._id);
toast.add({ severity: 'success', summary: 'موفق', detail: 'نقش حذف شد', life: 3000 });
await loadRoles();
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'عملیات ناموفق بود', life: 3000 });
}
}
});
};
</script>
<template>
<ConfirmPopup />
<div class="roles-page">
<!-- Header -->
<div class="page-header">
<div class="header-content">
<div class="header-icon">
<i class="pi pi-shield"></i>
</div>
<div>
<h1 class="page-title">مدیریت نقش‌ها و دسترسی‌ها</h1>
<p class="page-subtitle">تعریف سطوح دسترسی و گروه‌بندی مجوزهای کاربران</p>
</div>
</div>
<Button label="افزودن نقش جدید" icon="pi pi-plus" class="create-btn" @click="openCreate" />
</div>
<!-- Table Card -->
<div class="card main-card">
<DataTable
:value="filteredRoles"
:loading="loading"
dataKey="_id"
stripedRows
responsiveLayout="scroll"
>
<template #header>
<div class="table-header">
<span class="table-title">لیست نقش‌های تعریف شده ({{ filteredRoles.length }})</span>
<IconField>
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText v-model="searchPhrase" placeholder="جستجوی نام نقش..." class="search-input" />
</IconField>
</div>
</template>
<template #loading>
<div class="flex flex-col items-center justify-center p-8 gap-3">
<ProgressSpinner style="width: 42px; height: 42px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500">در حال بارگذاری نقش‌ها و دسترسی‌ها...</span>
</div>
</template>
<template #empty>
<div v-if="!loading" class="empty-state">
<i class="pi pi-shield empty-icon"></i>
<p>هیچ نقشی تعریف نشده است</p>
<Button label="افزودن اولین نقش" icon="pi pi-plus" @click="openCreate" />
</div>
</template>
<!-- Native Name -->
<Column field="nativeName" header="نام فارسی نقش" style="min-width: 180px">
<template #body="{ data }">
<div class="role-name-cell">
<span class="role-native-name">{{ data.nativeName }}</span>
<span v-if="data.englishName === 'SuperAdmin'" class="super-badge">
<i class="pi pi-bolt"></i> مدیر کل سیستم
</span>
</div>
</template>
</Column>
<!-- English Name -->
<Column field="englishName" header="شناسه لاتین" style="min-width: 150px">
<template #body="{ data }">
<span class="code-badge">{{ data.englishName }}</span>
</template>
</Column>
<!-- Permissions Count -->
<Column header="تعداد دسترسی‌ها" style="width: 180px">
<template #body="{ data }">
<span v-if="data.englishName === 'SuperAdmin'" class="perm-count-badge perm-full">
<i class="pi pi-check-circle ml-1"></i> تمام دسترسی‌ها (نامحدود)
</span>
<span v-else class="perm-count-badge">
<i class="pi pi-key ml-1"></i> {{ (data.permissions || []).length }} دسترسی فعال
</span>
</template>
</Column>
<!-- Status Column -->
<Column header="وضعیت" style="width: 100px">
<template #body="{ data }">
<Tag
:severity="data.isActive !== false ? 'success' : 'danger'"
:value="data.isActive !== false ? 'فعال' : 'غیرفعال'"
/>
</template>
</Column>
<!-- Actions -->
<Column header="عملیات" style="width: 110px">
<template #body="{ data }">
<div class="action-buttons">
<Button icon="pi pi-pencil" outlined rounded size="small" v-tooltip.top="'ویرایش دسترسی‌ها'" @click="openEdit(data)" />
<Button
v-if="data.englishName !== 'SuperAdmin'"
icon="pi pi-trash"
outlined
rounded
severity="danger"
size="small"
v-tooltip.top="'حذف نقش'"
@click="confirmDelete(data, $event)"
/>
</div>
</template>
</Column>
</DataTable>
</div>
<!-- Create / Edit Dialog -->
<Dialog
v-model:visible="showDialog"
:header="editMode ? 'ویرایش نقش و دسترسی‌ها' : 'افزودن نقش جدید'"
:style="{ width: '820px' }"
modal
dir="rtl"
>
<div class="dialog-content">
<!-- General Info -->
<div class="form-row">
<div class="form-field">
<label>نام فارسی نقش <span class="required">*</span></label>
<InputText v-model="form.nativeName" placeholder="مثال: مدیر مالی" class="w-full" />
</div>
<div class="form-field">
<label>نام انگلیسی (شناسه) <span class="required">*</span></label>
<InputText v-model="form.englishName" placeholder="مثال: FinanceManager" class="w-full" dir="ltr" />
</div>
</div>
<!-- Permissions Matrix Section -->
<div class="permissions-section">
<div class="section-header">
<div class="section-title">
<i class="pi pi-key"></i>
<span>تخصیص دسترسی‌های این نقش ({{ form.permissions.length }} انتخاب شده)</span>
</div>
<div class="section-actions">
<Button label="انتخاب همه" size="small" outlined severity="secondary" @click="selectAllPermissions" />
<Button label="لغو همه" size="small" text severity="danger" @click="deselectAllPermissions" />
</div>
</div>
<!-- Search within permissions -->
<div class="perm-search-box">
<IconField class="w-full">
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText v-model="permissionFilter" placeholder="فیلتر دسترسی‌ها (مثلا: انبار، کاربر، ایجاد...)" class="w-full search-box-input" />
</IconField>
</div>
<!-- Categories Matrix -->
<div class="categories-container">
<div v-for="(perms, catName) in categorizedPermissions" :key="catName" class="category-card">
<div class="category-header">
<span class="category-name">
<i class="pi pi-folder ml-1"></i>
{{ catName }} ({{ perms.length }})
</span>
<Button
:label="isCategoryAllSelected(catName) ? 'لغو انتخاب دسته' : 'انتخاب همه دسته'"
size="small"
text
:severity="isCategoryAllSelected(catName) ? 'danger' : 'info'"
class="cat-toggle-btn"
@click="toggleCategory(catName)"
/>
</div>
<div class="permissions-grid">
<div
v-for="p in perms"
:key="p._id"
class="permission-item"
:class="{ 'permission-active': isPermissionSelected(p._id) }"
@click="togglePermission(p._id)"
>
<div class="perm-checkbox">
<i v-if="isPermissionSelected(p._id)" class="pi pi-check text-xs"></i>
</div>
<div class="perm-info">
<span class="perm-native">{{ p.nativeName }}</span>
<span class="perm-key">{{ p.key }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<Button label="انصراف" icon="pi pi-times" text @click="showDialog = false" />
<Button
:label="editMode ? 'ذخیره تغییرات' : 'ایجاد نقش'"
icon="pi pi-check"
:loading="submitting"
@click="submitForm"
/>
</template>
</Dialog>
</div>
</template>
<style scoped>
.roles-page {
padding: 1.5rem;
direction: rtl;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.header-content {
display: flex;
align-items: center;
gap: 1rem;
}
.header-icon {
width: 52px;
height: 52px;
border-radius: 14px;
background: linear-gradient(135deg, #8b5cf6, #d946ef);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: #fff;
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.35);
}
.page-title {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
}
.page-subtitle {
margin: 0;
color: var(--text-color-secondary);
font-size: 0.875rem;
}
.create-btn {
background: linear-gradient(135deg, #8b5cf6, #d946ef) !important;
border: none !important;
padding: 0.65rem 1.5rem !important;
font-weight: 600 !important;
}
.main-card {
border-radius: 16px;
}
.table-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0;
}
.table-title {
font-size: 1rem;
font-weight: 600;
}
.search-input {
width: 260px;
}
.empty-state {
text-align: center;
padding: 3rem;
color: var(--text-color-secondary);
}
.empty-icon {
font-size: 3rem;
display: block;
margin-bottom: 1rem;
opacity: 0.35;
}
.role-name-cell {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.role-native-name {
font-weight: 600;
font-size: 0.95rem;
}
.super-badge {
font-size: 0.75rem;
color: #eab308;
display: inline-flex;
align-items: center;
gap: 0.2rem;
}
.code-badge {
font-family: monospace;
background: var(--surface-100);
padding: 3px 8px;
border-radius: 6px;
font-size: 0.82rem;
font-weight: 600;
}
.perm-count-badge {
display: inline-flex;
align-items: center;
font-size: 0.85rem;
background: rgba(99, 102, 241, 0.1);
color: #6366f1;
padding: 3px 10px;
border-radius: 8px;
font-weight: 600;
}
.perm-full {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
.action-buttons {
display: flex;
gap: 0.4rem;
}
.dialog-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.form-field label {
font-weight: 600;
font-size: 0.9rem;
}
.required {
color: #ef4444;
}
.permissions-section {
border-top: 1px solid var(--surface-border);
padding-top: 1.25rem;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 700;
font-size: 0.95rem;
}
.section-actions {
display: flex;
gap: 0.5rem;
}
.perm-search-box {
margin-bottom: 1rem;
}
.search-box-input {
border-radius: 10px;
}
.categories-container {
display: flex;
flex-direction: column;
gap: 1.25rem;
max-height: 420px;
overflow-y: auto;
padding-left: 0.5rem;
}
.category-card {
background: var(--surface-50);
border: 1px solid var(--surface-border);
border-radius: 12px;
padding: 1rem;
}
.category-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px dashed var(--surface-border);
}
.category-name {
font-weight: 700;
font-size: 0.9rem;
color: var(--primary-color);
}
.cat-toggle-btn {
font-size: 0.8rem !important;
padding: 0.2rem 0.5rem !important;
}
.permissions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.6rem;
}
.permission-item {
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 10px;
padding: 0.6rem 0.75rem;
display: flex;
align-items: center;
gap: 0.65rem;
cursor: pointer;
transition: all 0.15s ease;
user-select: none;
}
.permission-item:hover {
border-color: var(--primary-color);
background: var(--surface-100);
}
.permission-active {
background: rgba(59, 130, 246, 0.08);
border-color: #3b82f6;
}
.perm-checkbox {
width: 20px;
height: 20px;
border-radius: 6px;
border: 2px solid var(--surface-400);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.15s;
}
.permission-active .perm-checkbox {
background: #3b82f6;
border-color: #3b82f6;
color: #fff;
}
.perm-info {
display: flex;
flex-direction: column;
overflow: hidden;
}
.perm-native {
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.perm-key {
font-size: 0.72rem;
color: var(--text-color-secondary);
font-family: monospace;
direction: ltr;
text-align: right;
}
</style>