Files
Lucky_Wheel/apps/admin/src/views/roles/RoleMatrixView.vue
T

559 lines
16 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import {
CheckOutlined,
DeleteOutlined,
EditOutlined,
LockOutlined,
PlusOutlined,
SafetyCertificateOutlined,
SaveOutlined,
TeamOutlined,
} from '@ant-design/icons-vue';
import { message, Modal } from 'ant-design-vue';
import { computed, onMounted, reactive, ref } from 'vue';
import { toFarsiDigits } from '@lucky-wheel/shared-types';
import { useAuthStore } from '../../stores/auth.store';
import { apiClient } from '../../services/api/client';
const authStore = useAuthStore();
const loading = ref(false);
const saving = ref(false);
const roles = ref<any[]>([]);
const permissions = ref<any[]>([]);
const selectedRoleId = ref<string | null>(null);
// Form state for creating a new role
const isCreateModalOpen = ref(false);
const isSubmittingNewRole = ref(false);
const newRoleForm = reactive({
code: '',
name: '',
description: '',
permissionIds: [] as string[],
});
// Permission IDs assigned to the currently selected role in edit matrix
const activeRolePermIds = ref<string[]>([]);
const isRoleDirty = ref(false);
const selectedRole = computed(() => {
return roles.value.find((r) => r.id === selectedRoleId.value) || null;
});
// Group permissions by module
const moduleLabels: Record<string, string> = {
campaigns: 'مدیریت کمپین‌ها',
prizes: 'مدیریت جوایز',
users: 'کاربران سامانه',
chances: 'تنظیم دستی شانس‌ها',
spins: 'تاریخچه اسپین‌ها',
winners: 'مدیریت برندگان و کدهای هدیه',
sms: 'لاگ و ارسال پیامک',
audit: 'لاگ بازرسی (Audit)',
reports: 'گزارش‌ها و KPI',
admins: 'مدیریت مدیران و همکاران',
roles: 'مدیریت نقش‌ها و دسترسی‌ها',
workspaces: 'فضاهای کاری سازمانی',
};
const groupedPermissions = computed(() => {
const groups: Record<string, any[]> = {};
permissions.value.forEach((p) => {
const mod = p.module || 'other';
if (!groups[mod]) groups[mod] = [];
groups[mod].push(p);
});
return groups;
});
async function loadData() {
loading.value = true;
try {
const [rolesRes, permsRes] = await Promise.all([
apiClient.get('/admin/roles'),
apiClient.get('/admin/permissions'),
]);
roles.value = Array.isArray(rolesRes.data) ? rolesRes.data : rolesRes.data?.data || [];
permissions.value = Array.isArray(permsRes.data) ? permsRes.data : permsRes.data?.data || [];
if (!selectedRoleId.value && roles.value.length > 0) {
selectRole(roles.value[0].id);
} else if (selectedRoleId.value) {
selectRole(selectedRoleId.value);
}
} finally {
loading.value = false;
}
}
function selectRole(roleId: string) {
selectedRoleId.value = roleId;
const role = roles.value.find((r) => r.id === roleId);
if (role) {
activeRolePermIds.value = role.permissions ? role.permissions.map((rp: any) => rp.permissionId || rp.permission?.id) : [];
} else {
activeRolePermIds.value = [];
}
isRoleDirty.value = false;
}
function togglePermission(permId: string) {
if (selectedRole.value?.isSystem && selectedRole.value?.code === 'SUPER_ADMIN') {
message.warning('مجوزهای نقش مدیر ارشد به صورت ذاتی کامل و غیرقابل تغییر است.');
return;
}
const idx = activeRolePermIds.value.indexOf(permId);
if (idx > -1) {
activeRolePermIds.value.splice(idx, 1);
} else {
activeRolePermIds.value.push(permId);
}
isRoleDirty.value = true;
}
function toggleModuleAll(mod: string) {
if (selectedRole.value?.isSystem && selectedRole.value?.code === 'SUPER_ADMIN') return;
const perms = groupedPermissions.value[mod] || [];
const ids = perms.map((p) => p.id);
const allSelected = ids.every((id) => activeRolePermIds.value.includes(id));
if (allSelected) {
activeRolePermIds.value = activeRolePermIds.value.filter((id) => !ids.includes(id));
} else {
ids.forEach((id) => {
if (!activeRolePermIds.value.includes(id)) {
activeRolePermIds.value.push(id);
}
});
}
isRoleDirty.value = true;
}
async function saveRolePermissions() {
if (!selectedRoleId.value) return;
saving.value = true;
try {
await apiClient.patch(`/admin/roles/${selectedRoleId.value}`, {
permissionIds: activeRolePermIds.value,
});
message.success('مجوزهای نقش با موفقیت به‌روزرسانی شد.');
isRoleDirty.value = false;
loadData();
} finally {
saving.value = false;
}
}
function openCreateModal() {
newRoleForm.code = '';
newRoleForm.name = '';
newRoleForm.description = '';
newRoleForm.permissionIds = [];
isCreateModalOpen.value = true;
}
async function handleCreateRole() {
if (!newRoleForm.name.trim()) {
message.error('نام فارسی نقش الزامی است.');
return;
}
if (!newRoleForm.code.trim()) {
message.error('کد انگلیسی نقش الزامی است.');
return;
}
isSubmittingNewRole.value = true;
try {
const res = await apiClient.post('/admin/roles', {
name: newRoleForm.name.trim(),
code: newRoleForm.code.trim().toUpperCase(),
description: newRoleForm.description?.trim() || undefined,
permissionIds: newRoleForm.permissionIds,
});
message.success('نقش جدید با موفقیت ایجاد شد.');
isCreateModalOpen.value = false;
await loadData();
if (res.data?.id) selectRole(res.data.id);
} finally {
isSubmittingNewRole.value = false;
}
}
function confirmDeleteRole(role: any) {
Modal.confirm({
title: 'حذف نقش سفارشی',
content: `آیا از حذف نقش «${role.name}» اطمینان دارید؟`,
okText: 'حذف نقش',
okType: 'danger',
cancelText: 'انصراف',
onOk: async () => {
try {
await apiClient.delete(`/admin/roles/${role.id}`);
message.success('نقش با موفقیت حذف شد.');
selectedRoleId.value = null;
loadData();
} catch {
// Handled
}
},
});
}
onMounted(() => {
loadData();
});
</script>
<template>
<div class="roles-matrix-page">
<div class="page-header-title">
<div>
<h1 class="title">مدیریت نقش‌ها و ماتریس مجوزها</h1>
<p class="subtitle">تعریف نقش‌های سازمانی و انتساب دانه‌بندی‌شده مجوزهای دسترسی به ماژول‌های سامانه</p>
</div>
<div class="page-actions" v-if="authStore.can('roles:manage')">
<a-button type="primary" size="large" @click="openCreateModal">
<template #icon><PlusOutlined /></template>
تعریف نقش جدید
</a-button>
</div>
</div>
<a-spin :spinning="loading">
<a-row :gutter="20">
<!-- سمت راست: لیست نقش‌ها -->
<a-col :xs="24" :md="8" :lg="7">
<a-card :bordered="false" class="roles-sidebar-card" title="نقش‌های سامانه">
<div class="roles-list">
<div
v-for="r in roles"
:key="r.id"
class="role-item"
:class="{ 'is-active': r.id === selectedRoleId }"
@click="selectRole(r.id)"
>
<div class="role-item-header">
<div class="role-title">
<SafetyCertificateOutlined
:style="{ color: r.isSystem ? '#7c3aed' : '#2563eb', marginLeft: '6px' }"
/>
<strong>{{ r.name }}</strong>
</div>
<a-tag v-if="r.isSystem" color="purple" size="small">سیستمی</a-tag>
</div>
<div class="role-code">{{ r.code }}</div>
<div class="role-desc">{{ r.description || 'بدون توضیح' }}</div>
<div class="role-footer">
<span class="user-count">
<TeamOutlined style="margin-left: 4px" />
{{ toFarsiDigits(r._count?.admins ?? 0) }} مدیر منتسب
</span>
<a-button
v-if="!r.isSystem && authStore.can('roles:manage')"
type="text"
danger
size="small"
@click.stop="confirmDeleteRole(r)"
>
<DeleteOutlined />
</a-button>
</div>
</div>
</div>
</a-card>
</a-col>
<!-- سمت چپ: ماتریس مجوزهای نقش انتخاب‌شده -->
<a-col :xs="24" :md="16" :lg="17">
<a-card :bordered="false" class="matrix-card">
<template #title>
<div class="matrix-header">
<div>
<h3 style="margin: 0; font-size: 16px">
ماتریس دسترسی‌های:
<span style="color: #2563eb">{{ selectedRole?.name || 'انتخاب کنید' }}</span>
</h3>
<span style="font-size: 12px; color: #64748b">
{{ selectedRole?.code }}
{{ selectedRole?.isSystem ? '• نقش سیستمی پایه' : '• نقش سفارشی' }}
</span>
</div>
<a-button
v-if="authStore.can('roles:manage') && !selectedRole?.isSystem"
type="primary"
:loading="saving"
:disabled="!isRoleDirty"
@click="saveRolePermissions"
>
<template #icon><SaveOutlined /></template>
ذخیره تغییرات مجوزها
</a-button>
</div>
</template>
<div v-if="selectedRole?.code === 'SUPER_ADMIN'" class="super-admin-notice">
<SafetyCertificateOutlined style="font-size: 20px; color: #7c3aed" />
<div>
<strong>نقش مدیر ارشد سامانه (Super Admin)</strong>
<p style="margin: 4px 0 0; color: #6b7280; font-size: 13px">
تمامی مجوزهای سامانه به صورت خودکار و دائمی برای مدیر ارشد فعال است و نیازی به تغییر دستی ندارد.
</p>
</div>
</div>
<div class="modules-grid">
<div
v-for="(perms, modKey) in groupedPermissions"
:key="modKey"
class="module-perm-block"
>
<div class="module-header">
<div class="module-title">
<h4>{{ moduleLabels[modKey] || modKey }}</h4>
<span class="count-label">
({{ toFarsiDigits(perms.filter((p) => activeRolePermIds.includes(p.id)).length) }} از {{ toFarsiDigits(perms.length) }})
</span>
</div>
<a-button
type="link"
size="small"
:disabled="selectedRole?.code === 'SUPER_ADMIN' || !authStore.can('roles:manage')"
@click="toggleModuleAll(modKey)"
>
تغییر انتخاب همه
</a-button>
</div>
<div class="perms-list">
<div
v-for="p in perms"
:key="p.id"
class="perm-checkbox-item"
:class="{ 'is-checked': activeRolePermIds.includes(p.id) }"
@click="togglePermission(p.id)"
>
<a-checkbox
:checked="activeRolePermIds.includes(p.id)"
:disabled="selectedRole?.code === 'SUPER_ADMIN' || !authStore.can('roles:manage')"
>
<strong class="perm-name">{{ p.name }}</strong>
</a-checkbox>
<div class="perm-code">{{ p.code }}</div>
<div class="perm-desc" v-if="p.description">{{ p.description }}</div>
</div>
</div>
</div>
</div>
</a-card>
</a-col>
</a-row>
</a-spin>
<!-- Create Role Modal -->
<a-modal
v-model:open="isCreateModalOpen"
title="تعریف نقش سازمانی جدید"
okText="ایجاد نقش"
cancelText="انصراف"
:confirmLoading="isSubmittingNewRole"
@ok="handleCreateRole"
>
<a-form layout="vertical" style="margin-top: 16px">
<a-form-item label="نام فارسی نقش" required>
<a-input
v-model:value="newRoleForm.name"
placeholder="مثال: سرپرست بازاریابی"
size="large"
/>
</a-form-item>
<a-form-item label="کد انگلیسی نقش (فقط حروف بزرگ و خط زیر)" required>
<a-input
v-model:value="newRoleForm.code"
placeholder="MARKETING_SUPERVISOR"
dir="ltr"
size="large"
/>
</a-form-item>
<a-form-item label="توضیحات و شرح اختیارات">
<a-textarea
v-model:value="newRoleForm.description"
placeholder="شرح وظایف و اختیارات این نقش..."
:rows="3"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<style scoped>
.roles-matrix-page {
padding-bottom: 24px;
}
.page-header-title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.title {
font-size: 22px;
font-weight: 700;
color: #1e293b;
margin: 0;
}
.subtitle {
font-size: 13px;
color: #64748b;
margin-top: 4px;
margin-bottom: 0;
}
.roles-sidebar-card,
.matrix-card {
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.roles-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.role-item {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
background: #fff;
}
.role-item:hover {
border-color: #93c5fd;
background: #f8fafc;
}
.role-item.is-active {
border-color: #2563eb;
background: #eff6ff;
}
.role-item-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.role-code {
font-size: 11px;
color: #94a3b8;
direction: ltr;
text-align: right;
margin: 2px 0 6px;
}
.role-desc {
font-size: 12px;
color: #64748b;
line-height: 1.5;
}
.role-footer {
margin-top: 10px;
padding-top: 8px;
border-top: 1px dashed #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: #64748b;
}
.matrix-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.super-admin-notice {
display: flex;
gap: 12px;
align-items: center;
background: #f5f3ff;
border: 1px solid #ddd6fe;
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 20px;
}
.modules-grid {
display: flex;
flex-direction: column;
gap: 16px;
}
.module-perm-block {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}
.module-header {
background: #f8fafc;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.module-title h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
display: inline-block;
color: #1e293b;
}
.count-label {
font-size: 12px;
color: #64748b;
margin-right: 6px;
}
.perms-list {
padding: 12px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 10px;
}
.perm-checkbox-item {
padding: 8px 10px;
border: 1px solid #f1f5f9;
border-radius: 6px;
transition: all 0.15s ease;
cursor: pointer;
background: #fff;
}
.perm-checkbox-item:hover {
border-color: #cbd5e1;
}
.perm-checkbox-item.is-checked {
background: #eff6ff;
border-color: #bfdbfe;
}
.perm-name {
font-size: 13px;
color: #1e293b;
}
.perm-code {
font-size: 11px;
color: #94a3b8;
direction: ltr;
text-align: right;
margin: 2px 24px 0 0;
}
.perm-desc {
font-size: 11px;
color: #64748b;
margin: 2px 24px 0 0;
}
</style>