feat: add user management and role/permission management pages
deploy-chabokan / deploy (push) Canceled after 0s
deploy-chabokan / deploy (push) Canceled after 0s
This commit is contained in:
@@ -30,6 +30,13 @@ const model = ref([
|
|||||||
{ label: 'تراکنشهای انبار', icon: 'pi pi-fw pi-history', to: '/warehouse-items/transactions' },
|
{ label: 'تراکنشهای انبار', icon: 'pi pi-fw pi-history', to: '/warehouse-items/transactions' },
|
||||||
{ label: 'مکانهای انبار', icon: 'pi pi-fw pi-map', to: '/warehouse-items/warehouses' }
|
{ label: 'مکانهای انبار', icon: 'pi pi-fw pi-map', to: '/warehouse-items/warehouses' }
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'مدیریت سیستم',
|
||||||
|
items: [
|
||||||
|
{ label: 'کاربران', icon: 'pi pi-fw pi-users', to: '/users/list-users' },
|
||||||
|
{ label: 'نقشها و دسترسیها', icon: 'pi pi-fw pi-shield', to: '/roles/list-roles' }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -115,6 +115,17 @@ const router = createRouter({
|
|||||||
name: 'list-warehouses',
|
name: 'list-warehouses',
|
||||||
component: () => import('@/views/pages/WarehouseItems/ListWarehouses.vue')
|
component: () => import('@/views/pages/WarehouseItems/ListWarehouses.vue')
|
||||||
},
|
},
|
||||||
|
// ─── Users & Roles ───────────────────────────────────────────
|
||||||
|
{
|
||||||
|
path: '/users/list-users',
|
||||||
|
name: 'list-users',
|
||||||
|
component: () => import('@/views/pages/Users/ListUsers.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/roles/list-roles',
|
||||||
|
name: 'list-roles',
|
||||||
|
component: () => import('@/views/pages/Roles/ListRoles.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/uikit/formlayout',
|
path: '/uikit/formlayout',
|
||||||
name: 'formlayout',
|
name: 'formlayout',
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const getAuthHeader = () => {
|
||||||
|
const bmToken = document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find((row) => row.startsWith('bm-token='))
|
||||||
|
?.split('=')[1] || localStorage.getItem('bm-token');
|
||||||
|
return bmToken ? { Authorization: `Bearer ${bmToken}` } : {};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PermissionService = {
|
||||||
|
listPermissions: async () => {
|
||||||
|
const response = await axios.get('/api/permissions', {
|
||||||
|
withCredentials: true,
|
||||||
|
headers: getAuthHeader()
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const getAuthHeader = () => {
|
||||||
|
const bmToken = document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find((row) => row.startsWith('bm-token='))
|
||||||
|
?.split('=')[1] || localStorage.getItem('bm-token');
|
||||||
|
return bmToken ? { Authorization: `Bearer ${bmToken}` } : {};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RoleService = {
|
||||||
|
listRoles: async (params = {}) => {
|
||||||
|
const response = await axios.get('/api/roles/admin/get-all', {
|
||||||
|
params,
|
||||||
|
withCredentials: true,
|
||||||
|
headers: getAuthHeader()
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getRole: async (roleId) => {
|
||||||
|
const response = await axios.get(`/api/roles/admin/get-one/${roleId}`, {
|
||||||
|
withCredentials: true,
|
||||||
|
headers: getAuthHeader()
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createRole: async (data) => {
|
||||||
|
const response = await axios.post('/api/roles/admin/create', data, {
|
||||||
|
withCredentials: true,
|
||||||
|
headers: getAuthHeader()
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateRole: async (roleId, data) => {
|
||||||
|
const response = await axios.patch(`/api/roles/admin/update/${roleId}`, data, {
|
||||||
|
withCredentials: true,
|
||||||
|
headers: getAuthHeader()
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteRole: async (roleId) => {
|
||||||
|
const response = await axios.delete(`/api/roles/admin/delete/${roleId}`, {
|
||||||
|
withCredentials: true,
|
||||||
|
headers: getAuthHeader()
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const getAuthHeader = () => {
|
||||||
|
const bmToken = document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find((row) => row.startsWith('bm-token='))
|
||||||
|
?.split('=')[1] || localStorage.getItem('bm-token');
|
||||||
|
return bmToken ? { Authorization: `Bearer ${bmToken}` } : {};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UserService = {
|
||||||
|
listUsers: async (params = {}) => {
|
||||||
|
const response = await axios.get('/api/users', {
|
||||||
|
params,
|
||||||
|
withCredentials: true,
|
||||||
|
headers: getAuthHeader()
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getUser: async (userId) => {
|
||||||
|
const response = await axios.get(`/api/users/${userId}`, {
|
||||||
|
withCredentials: true,
|
||||||
|
headers: getAuthHeader()
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createUser: async (data) => {
|
||||||
|
const response = await axios.post('/api/users', data, {
|
||||||
|
withCredentials: true,
|
||||||
|
headers: getAuthHeader()
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateUser: async (userId, data) => {
|
||||||
|
const response = await axios.patch(`/api/users/${userId}`, data, {
|
||||||
|
withCredentials: true,
|
||||||
|
headers: getAuthHeader()
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteUser: async (userId) => {
|
||||||
|
const response = await axios.delete(`/api/users/${userId}`, {
|
||||||
|
withCredentials: true,
|
||||||
|
headers: getAuthHeader()
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,718 @@
|
|||||||
|
<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(false);
|
||||||
|
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 #empty>
|
||||||
|
<div 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>
|
||||||
@@ -0,0 +1,668 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref, computed } from 'vue';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
import { useConfirm } from 'primevue/useconfirm';
|
||||||
|
import ConfirmPopup from 'primevue/confirmpopup';
|
||||||
|
import { UserService } from '@/service/UserService';
|
||||||
|
import { RoleService } from '@/service/RoleService';
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
|
||||||
|
const users = ref([]);
|
||||||
|
const roles = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const showDialog = ref(false);
|
||||||
|
const editMode = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
const selectedUser = ref(null);
|
||||||
|
const searchPhrase = ref('');
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
role: null,
|
||||||
|
isAdmin: false,
|
||||||
|
isActive: true
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadUsers(), loadRoles()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
users.value = await UserService.listUsers();
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در بارگذاری لیست کاربران', life: 3000 });
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRoles = async () => {
|
||||||
|
try {
|
||||||
|
roles.value = await RoleService.listRoles();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading roles:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleOptions = computed(() => {
|
||||||
|
return roles.value.map((r) => ({
|
||||||
|
label: `${r.nativeName} (${r.englishName})`,
|
||||||
|
value: r._id
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredUsers = computed(() => {
|
||||||
|
if (!searchPhrase.value) return users.value;
|
||||||
|
const q = searchPhrase.value.toLowerCase().trim();
|
||||||
|
return users.value.filter((u) => {
|
||||||
|
const name = (u.name || '').toLowerCase();
|
||||||
|
const email = (u.email || '').toLowerCase();
|
||||||
|
const roleNative = (u.role?.nativeName || '').toLowerCase();
|
||||||
|
const roleEn = (u.role?.englishName || '').toLowerCase();
|
||||||
|
return name.includes(q) || email.includes(q) || roleNative.includes(q) || roleEn.includes(q);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = computed(() => {
|
||||||
|
const total = users.value.length;
|
||||||
|
const active = users.value.filter((u) => u.isActive !== false).length;
|
||||||
|
const admins = users.value.filter((u) => u.isAdmin || u.role?.englishName === 'SuperAdmin').length;
|
||||||
|
return { total, active, admins };
|
||||||
|
});
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
editMode.value = false;
|
||||||
|
selectedUser.value = null;
|
||||||
|
form.value = {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
role: roles.value.length > 0 ? roles.value[0]._id : null,
|
||||||
|
isAdmin: false,
|
||||||
|
isActive: true
|
||||||
|
};
|
||||||
|
showDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (u) => {
|
||||||
|
editMode.value = true;
|
||||||
|
selectedUser.value = u;
|
||||||
|
form.value = {
|
||||||
|
name: u.name || '',
|
||||||
|
email: u.email || '',
|
||||||
|
password: '',
|
||||||
|
role: u.role?._id || u.role,
|
||||||
|
isAdmin: u.isAdmin || false,
|
||||||
|
isActive: u.isActive !== false
|
||||||
|
};
|
||||||
|
showDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitForm = async () => {
|
||||||
|
if (!form.value.email) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'توجه', detail: 'آدرس ایمیل الزامی است', life: 3000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!editMode.value && !form.value.password) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'توجه', detail: 'رمز عبور الزامی است', life: 3000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.value.role) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'توجه', detail: 'انتخاب نقش کاربری الزامی است', life: 3000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: form.value.name,
|
||||||
|
email: form.value.email,
|
||||||
|
role: form.value.role,
|
||||||
|
isAdmin: form.value.isAdmin,
|
||||||
|
isActive: form.value.isActive
|
||||||
|
};
|
||||||
|
|
||||||
|
if (form.value.password && form.value.password.trim().length > 0) {
|
||||||
|
payload.password = form.value.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editMode.value) {
|
||||||
|
await UserService.updateUser(selectedUser.value._id, payload);
|
||||||
|
toast.add({ severity: 'success', summary: 'موفق', detail: 'کاربر با موفقیت ویرایش شد', life: 3000 });
|
||||||
|
} else {
|
||||||
|
await UserService.createUser(payload);
|
||||||
|
toast.add({ severity: 'success', summary: 'موفق', detail: 'کاربر جدید با موفقیت ایجاد شد', life: 3000 });
|
||||||
|
}
|
||||||
|
showDialog.value = false;
|
||||||
|
await loadUsers();
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'خطا',
|
||||||
|
detail: e.response?.data?.message || 'عملیات ناموفق بود',
|
||||||
|
life: 4000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (u, event) => {
|
||||||
|
confirm.require({
|
||||||
|
target: event.currentTarget,
|
||||||
|
message: `آیا از غیرفعالسازی کاربر "${u.name || u.email}" اطمینان دارید؟`,
|
||||||
|
icon: 'pi pi-exclamation-triangle',
|
||||||
|
rejectProps: { label: 'خیر', severity: 'secondary', outlined: true },
|
||||||
|
acceptProps: { label: 'بله', severity: 'danger' },
|
||||||
|
accept: async () => {
|
||||||
|
try {
|
||||||
|
await UserService.deleteUser(u._id);
|
||||||
|
toast.add({ severity: 'success', summary: 'موفق', detail: 'کاربر غیرفعال شد', life: 3000 });
|
||||||
|
await loadUsers();
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'خطا', detail: 'عملیات ناموفق بود', life: 3000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
if (!dateStr) return '—';
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleDateString('fa-IR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInitials = (name, email) => {
|
||||||
|
if (name && name.trim().length > 0) {
|
||||||
|
return name.trim().charAt(0).toUpperCase();
|
||||||
|
}
|
||||||
|
if (email) {
|
||||||
|
return email.charAt(0).toUpperCase();
|
||||||
|
}
|
||||||
|
return 'U';
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ConfirmPopup />
|
||||||
|
<div class="users-page">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="header-icon">
|
||||||
|
<i class="pi pi-users"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">مدیریت کاربران</h1>
|
||||||
|
<p class="page-subtitle">تعریف حسابهای کاربری، تعیین نقشها و رمز عبور</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button label="افزودن کاربر جدید" icon="pi pi-user-plus" class="create-btn" @click="openCreate" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Stats -->
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon stat-icon-blue">
|
||||||
|
<i class="pi pi-users"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-data">
|
||||||
|
<span class="stat-value">{{ stats.total }}</span>
|
||||||
|
<span class="stat-label">کل کاربران</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon stat-icon-green">
|
||||||
|
<i class="pi pi-check-circle"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-data">
|
||||||
|
<span class="stat-value">{{ stats.active }}</span>
|
||||||
|
<span class="stat-label">کاربران فعال</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon stat-icon-purple">
|
||||||
|
<i class="pi pi-shield"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-data">
|
||||||
|
<span class="stat-value">{{ stats.admins }}</span>
|
||||||
|
<span class="stat-label">مدیران سیستم</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table Card -->
|
||||||
|
<div class="card main-card">
|
||||||
|
<DataTable
|
||||||
|
:value="filteredUsers"
|
||||||
|
:loading="loading"
|
||||||
|
dataKey="_id"
|
||||||
|
stripedRows
|
||||||
|
responsiveLayout="scroll"
|
||||||
|
paginator
|
||||||
|
:rows="10"
|
||||||
|
:rowsPerPageOptions="[10, 20, 50]"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="table-header">
|
||||||
|
<span class="table-title">لیست کاربران سیستم ({{ filteredUsers.length }})</span>
|
||||||
|
<IconField>
|
||||||
|
<InputIcon><i class="pi pi-search" /></InputIcon>
|
||||||
|
<InputText v-model="searchPhrase" placeholder="جستجوی کاربر، ایمیل، نقش..." class="search-input" />
|
||||||
|
</IconField>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #empty>
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="pi pi-user-minus empty-icon"></i>
|
||||||
|
<p>هیچ کاربری یافت نشد</p>
|
||||||
|
<Button label="افزودن اولین کاربر" icon="pi pi-plus" @click="openCreate" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- User Column -->
|
||||||
|
<Column header="کاربر" style="min-width: 200px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="user-cell">
|
||||||
|
<div class="user-avatar">
|
||||||
|
{{ getInitials(data.name, data.email) }}
|
||||||
|
</div>
|
||||||
|
<div class="user-info">
|
||||||
|
<span class="user-name">{{ data.name || 'بدون نام' }}</span>
|
||||||
|
<span class="user-email">{{ data.email }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<!-- Role Column -->
|
||||||
|
<Column header="نقش کاربری" style="min-width: 150px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span v-if="data.role" class="role-badge" :class="{ 'role-super': data.role?.englishName === 'SuperAdmin' }">
|
||||||
|
<i class="pi pi-shield ml-1"></i>
|
||||||
|
{{ data.role?.nativeName || data.role?.englishName }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-muted">—</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<!-- Admin Column -->
|
||||||
|
<Column header="دسترسی ادمین" style="width: 120px; text-align: center">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag
|
||||||
|
v-if="data.isAdmin || data.role?.englishName === 'SuperAdmin'"
|
||||||
|
severity="warn"
|
||||||
|
value="مدیر"
|
||||||
|
icon="pi pi-star-fill"
|
||||||
|
/>
|
||||||
|
<span v-else class="text-muted">عادی</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>
|
||||||
|
|
||||||
|
<!-- Created Date -->
|
||||||
|
<Column header="تاریخ عضویت" style="width: 140px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="date-text">{{ formatDate(data.createdAt) }}</span>
|
||||||
|
</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
|
||||||
|
icon="pi pi-ban"
|
||||||
|
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: '520px' }"
|
||||||
|
modal
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-field">
|
||||||
|
<label>نام و نام خانوادگی</label>
|
||||||
|
<InputText v-model="form.name" placeholder="مثال: علی رضایی" class="w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label>آدرس ایمیل <span class="required">*</span></label>
|
||||||
|
<InputText v-model="form.email" type="email" placeholder="example@domain.com" class="w-full" dir="ltr" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label>
|
||||||
|
رمز عبور
|
||||||
|
<span v-if="!editMode" class="required">*</span>
|
||||||
|
<span v-else class="text-xs text-muted-color">(در صورت تمایل به تغییر وارد کنید)</span>
|
||||||
|
</label>
|
||||||
|
<Password
|
||||||
|
v-model="form.password"
|
||||||
|
:placeholder="editMode ? 'رمز جدید (اختیاری)' : 'حداقل ۸ کاراکتر'"
|
||||||
|
:toggleMask="true"
|
||||||
|
class="w-full"
|
||||||
|
fluid
|
||||||
|
:feedback="false"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label>نقش کاربری <span class="required">*</span></label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="form.role"
|
||||||
|
:options="roleOptions"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
placeholder="انتخاب نقش"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checkbox-row">
|
||||||
|
<div class="checkbox-field">
|
||||||
|
<Checkbox v-model="form.isAdmin" :binary="true" inputId="isAdminCheck" />
|
||||||
|
<label for="isAdminCheck" class="cursor-pointer">دسترسی مدیر کل (Admin)</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checkbox-field">
|
||||||
|
<Checkbox v-model="form.isActive" :binary="true" inputId="isActiveCheck" />
|
||||||
|
<label for="isActiveCheck" class="cursor-pointer">حساب کاربری فعال است</label>
|
||||||
|
</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>
|
||||||
|
.users-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, #3b82f6, #6366f1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 15px rgba(59, 130, 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, #3b82f6, #6366f1) !important;
|
||||||
|
border: none !important;
|
||||||
|
padding: 0.65rem 1.5rem !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--surface-card);
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon-blue {
|
||||||
|
background: rgba(59, 130, 246, 0.12);
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon-green {
|
||||||
|
background: rgba(34, 197, 94, 0.12);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon-purple {
|
||||||
|
background: rgba(168, 85, 247, 0.12);
|
||||||
|
color: #a855f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-data {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-email {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
direction: ltr;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--surface-100);
|
||||||
|
color: var(--text-color);
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-super {
|
||||||
|
background: rgba(234, 179, 8, 0.15);
|
||||||
|
color: #ca8a04;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-text {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user