feat(rbac): implement full admin management, dynamic roles, permissions matrix, and workspace access control
- Add Workspace, Role, Permission, RolePermission, AdminRoleAssignment, and AdminWorkspaceAssignment models to Prisma schema\n- Create database migration and RBAC seed script\n- Implement PermissionsGuard, @RequirePermission decorator, and real-time permission resolution in JwtAdminStrategy\n- Implement AdminManagementModule with endpoints for managers CRUD, roles matrix, permissions catalog, and workspaces\n- Secure all sensitive admin controllers with granular permission requirements\n- Add useAuthStore helpers (can, canAny, fetchMe) and v-permission directive\n- Implement Desktop views: AdminListView, AdminFormDrawer (RTL right-side), RoleMatrixView, WorkspaceListView, and ForbiddenView\n- Implement Mobile views: MAdminListView, MAdminFormView (stepped mobile form), MRoleMatrixView, MWorkspaceListView, and MForbiddenView\n- Fix sticky header behavior across desktop and mobile shells\n- Update route guards and device detection logic for mobile prefixing
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
AuditOutlined,
|
||||
DashboardOutlined,
|
||||
LineChartOutlined,
|
||||
@@ -8,6 +9,8 @@ import {
|
||||
MoreOutlined,
|
||||
PieChartOutlined,
|
||||
ReloadOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
TeamOutlined,
|
||||
TrophyOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
@@ -21,32 +24,38 @@ const authStore = useAuthStore();
|
||||
|
||||
const moreOpen = ref(false);
|
||||
|
||||
const tabs = [
|
||||
const allTabs = [
|
||||
{ key: 'm-dashboard', label: 'داشبورد', icon: DashboardOutlined, path: '/m/dashboard' },
|
||||
{ key: 'm-campaigns', label: 'کمپینها', icon: PieChartOutlined, path: '/m/campaigns' },
|
||||
{ key: 'm-users', label: 'کاربران', icon: UserOutlined, path: '/m/users' },
|
||||
{ key: 'm-winners', label: 'برندگان', icon: TrophyOutlined, path: '/m/winners' },
|
||||
{ key: 'm-campaigns', label: 'کمپینها', icon: PieChartOutlined, path: '/m/campaigns', permission: 'campaigns:read' },
|
||||
{ key: 'm-users', label: 'کاربران', icon: UserOutlined, path: '/m/users', permission: 'users:read' },
|
||||
{ key: 'm-winners', label: 'برندگان', icon: TrophyOutlined, path: '/m/winners', permission: 'winners:read' },
|
||||
];
|
||||
|
||||
const moreItems = [
|
||||
{ key: 'm-spins', label: 'تاریخچه اسپین', icon: ReloadOutlined, path: '/m/spins' },
|
||||
{ key: 'm-sms', label: 'لاگ پیامک', icon: MessageOutlined, path: '/m/sms' },
|
||||
{ key: 'm-reports', label: 'گزارشها', icon: LineChartOutlined, path: '/m/reports' },
|
||||
{ key: 'm-audit', label: 'لاگ بازرسی', icon: AuditOutlined, path: '/m/audit' },
|
||||
const allMoreItems = [
|
||||
{ key: 'm-spins', label: 'تاریخچه اسپین', icon: ReloadOutlined, path: '/m/spins', permission: 'spins:read' },
|
||||
{ key: 'm-sms', label: 'لاگ پیامک', icon: MessageOutlined, path: '/m/sms', permission: 'sms:read' },
|
||||
{ key: 'm-reports', label: 'گزارشها', icon: LineChartOutlined, path: '/m/reports', permission: 'reports:read' },
|
||||
{ key: 'm-audit', label: 'لاگ بازرسی', icon: AuditOutlined, path: '/m/audit', permission: 'audit:read' },
|
||||
{ key: 'm-managers', label: 'مدیریت مدیران', icon: TeamOutlined, path: '/m/managers', permission: 'admins:read' },
|
||||
{ key: 'm-roles', label: 'نقشها و دسترسیها', icon: SafetyCertificateOutlined, path: '/m/roles', permission: 'roles:manage' },
|
||||
{ key: 'm-workspaces', label: 'فضاهای کاری', icon: ApartmentOutlined, path: '/m/workspaces', permission: 'workspaces:manage' },
|
||||
];
|
||||
|
||||
const tabs = computed(() => allTabs.filter(t => !t.permission || authStore.can(t.permission)));
|
||||
const moreItems = computed(() => allMoreItems.filter(i => !i.permission || authStore.can(i.permission)));
|
||||
|
||||
const activeKey = computed(() => {
|
||||
const name = String(route.name || '');
|
||||
if (tabs.some((t) => t.key === name || name.startsWith(t.key))) {
|
||||
if (tabs.value.some((t) => t.key === name || name.startsWith(t.key))) {
|
||||
if (name.startsWith('m-campaign')) return 'm-campaigns';
|
||||
if (name.startsWith('m-user')) return 'm-users';
|
||||
return name;
|
||||
}
|
||||
if (moreItems.some((t) => t.key === name)) return 'more';
|
||||
if (moreItems.value.some((t) => t.key === name)) return 'more';
|
||||
return name;
|
||||
});
|
||||
|
||||
const moreActive = computed(() => moreItems.some((i) => i.key === String(route.name)));
|
||||
const moreActive = computed(() => moreItems.value.some((i) => i.key === String(route.name)));
|
||||
|
||||
function go(path: string) {
|
||||
moreOpen.value = false;
|
||||
|
||||
@@ -64,7 +64,9 @@ function handleBack() {
|
||||
.m-topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
z-index: 1000;
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #6366f1 55%, #7c3aed 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 20px rgba(79, 70, 229, 0.28);
|
||||
|
||||
@@ -34,6 +34,10 @@ const desktopToMobileRoute: Record<string, string> = {
|
||||
sms: 'm-sms',
|
||||
reports: 'm-reports',
|
||||
audit: 'm-audit',
|
||||
managers: 'm-managers',
|
||||
roles: 'm-roles',
|
||||
workspaces: 'm-workspaces',
|
||||
forbidden: 'm-forbidden',
|
||||
};
|
||||
|
||||
const mobileToDesktopRoute: Record<string, string> = Object.fromEntries(
|
||||
@@ -49,7 +53,7 @@ function syncRouteToDevice() {
|
||||
isMobile.value = mobile;
|
||||
|
||||
const route = boundRouter.currentRoute.value;
|
||||
const isMobileRoute = route.path.startsWith('/m') || !!route.meta.mobile;
|
||||
const isMobileRoute = route.path === '/m' || route.path.startsWith('/m/') || !!route.meta.mobile;
|
||||
const name = String(route.name || '');
|
||||
|
||||
if (mobile && !isMobileRoute) {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { computed } from 'vue';
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
|
||||
export function usePermission() {
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const isSuperAdmin = computed(() => authStore.isSuperAdmin);
|
||||
const permissions = computed(() => authStore.userPermissions);
|
||||
|
||||
function can(permissionCode: string): boolean {
|
||||
return authStore.can(permissionCode);
|
||||
}
|
||||
|
||||
function canAny(permissionCodes: string[]): boolean {
|
||||
return authStore.canAny(permissionCodes);
|
||||
}
|
||||
|
||||
function canAll(permissionCodes: string[]): boolean {
|
||||
if (authStore.isSuperAdmin) return true;
|
||||
return permissionCodes.every((code) => authStore.can(code));
|
||||
}
|
||||
|
||||
return {
|
||||
isSuperAdmin,
|
||||
permissions,
|
||||
can,
|
||||
canAny,
|
||||
canAll,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { App, DirectiveBinding } from 'vue';
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
|
||||
export const permissionDirective = {
|
||||
mounted(el: HTMLElement, binding: DirectiveBinding<string | string[]>) {
|
||||
const authStore = useAuthStore();
|
||||
const value = binding.value;
|
||||
|
||||
if (!value) return;
|
||||
|
||||
let hasPermission = false;
|
||||
if (Array.isArray(value)) {
|
||||
hasPermission = authStore.canAny(value);
|
||||
} else {
|
||||
hasPermission = authStore.can(value);
|
||||
}
|
||||
|
||||
if (!hasPermission) {
|
||||
if (binding.modifiers.disable) {
|
||||
el.setAttribute('disabled', 'true');
|
||||
el.classList.add('ant-btn-disabled', 'is-permission-disabled');
|
||||
el.style.pointerEvents = 'none';
|
||||
el.style.opacity = '0.5';
|
||||
} else {
|
||||
el.parentNode?.removeChild(el);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export function setupPermissionDirective(app: App) {
|
||||
app.directive('permission', permissionDirective);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
AuditOutlined,
|
||||
CloseCircleOutlined,
|
||||
DashboardOutlined,
|
||||
@@ -14,12 +15,14 @@ import {
|
||||
MessageOutlined,
|
||||
PieChartOutlined,
|
||||
ReloadOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
SearchOutlined,
|
||||
TeamOutlined,
|
||||
TrophyOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import { computed, createVNode, reactive, ref, watch } from 'vue';
|
||||
import { computed, createVNode, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
|
||||
@@ -36,32 +39,52 @@ interface NavItem {
|
||||
label: string;
|
||||
icon: any;
|
||||
path: string;
|
||||
permission?: string;
|
||||
}
|
||||
|
||||
const mainItems: NavItem[] = [
|
||||
{ key: 'dashboard', label: 'داشبورد آماری', icon: DashboardOutlined, path: '/dashboard' },
|
||||
{ key: 'campaigns', label: 'مدیریت کمپینها', icon: PieChartOutlined, path: '/campaigns' },
|
||||
{ key: 'users', label: 'مدیریت کاربران', icon: UserOutlined, path: '/users' },
|
||||
{ key: 'winners', label: 'مدیریت برندگان', icon: TrophyOutlined, path: '/winners' },
|
||||
{ key: 'spins', label: 'تاریخچه اسپینها', icon: ReloadOutlined, path: '/spins' },
|
||||
{ key: 'campaigns', label: 'مدیریت کمپینها', icon: PieChartOutlined, path: '/campaigns', permission: 'campaigns:read' },
|
||||
{ key: 'users', label: 'مدیریت کاربران', icon: UserOutlined, path: '/users', permission: 'users:read' },
|
||||
{ key: 'winners', label: 'مدیریت برندگان', icon: TrophyOutlined, path: '/winners', permission: 'winners:read' },
|
||||
{ key: 'spins', label: 'تاریخچه اسپینها', icon: ReloadOutlined, path: '/spins', permission: 'spins:read' },
|
||||
];
|
||||
|
||||
const utilityItems: NavItem[] = [
|
||||
{ key: 'sms', label: 'لاگهای پیامک', icon: MessageOutlined, path: '/sms' },
|
||||
{ key: 'reports', label: 'گزارشها و KPI', icon: LineChartOutlined, path: '/reports' },
|
||||
{ key: 'audit', label: 'لاگ بازرسی (Audit)', icon: AuditOutlined, path: '/audit' },
|
||||
{ key: 'sms', label: 'لاگهای پیامک', icon: MessageOutlined, path: '/sms', permission: 'sms:read' },
|
||||
{ key: 'reports', label: 'گزارشها و KPI', icon: LineChartOutlined, path: '/reports', permission: 'reports:read' },
|
||||
{ key: 'audit', label: 'لاگ بازرسی (Audit)', icon: AuditOutlined, path: '/audit', permission: 'audit:read' },
|
||||
];
|
||||
|
||||
const accessControlItems: NavItem[] = [
|
||||
{ key: 'managers', label: 'مدیریت مدیران', icon: TeamOutlined, path: '/managers', permission: 'admins:read' },
|
||||
{ key: 'roles', label: 'نقشها و دسترسیها', icon: SafetyCertificateOutlined, path: '/roles', permission: 'roles:manage' },
|
||||
{ key: 'workspaces', label: 'فضاهای کاری', icon: ApartmentOutlined, path: '/workspaces', permission: 'workspaces:manage' },
|
||||
];
|
||||
|
||||
const filteredMainItems = computed(() => {
|
||||
if (!searchQuery.value.trim()) return mainItems;
|
||||
const allowed = mainItems.filter(item => !item.permission || authStore.can(item.permission));
|
||||
if (!searchQuery.value.trim()) return allowed;
|
||||
const q = searchQuery.value.toLowerCase().trim();
|
||||
return mainItems.filter(item => item.label.toLowerCase().includes(q));
|
||||
return allowed.filter(item => item.label.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
const filteredUtilityItems = computed(() => {
|
||||
if (!searchQuery.value.trim()) return utilityItems;
|
||||
const allowed = utilityItems.filter(item => !item.permission || authStore.can(item.permission));
|
||||
if (!searchQuery.value.trim()) return allowed;
|
||||
const q = searchQuery.value.toLowerCase().trim();
|
||||
return utilityItems.filter(item => item.label.toLowerCase().includes(q));
|
||||
return allowed.filter(item => item.label.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
const filteredAccessControlItems = computed(() => {
|
||||
const allowed = accessControlItems.filter(item => !item.permission || authStore.can(item.permission));
|
||||
if (!searchQuery.value.trim()) return allowed;
|
||||
const q = searchQuery.value.toLowerCase().trim();
|
||||
return allowed.filter(item => item.label.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
authStore.fetchMe();
|
||||
});
|
||||
|
||||
function navigateTo(item: NavItem) {
|
||||
@@ -267,9 +290,31 @@ function confirmLogout() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access Control Section -->
|
||||
<div v-if="filteredAccessControlItems.length > 0" class="nav-group nav-group-secondary">
|
||||
<div v-if="!collapsed" class="nav-group-header">مدیریت دسترسی و امنیت</div>
|
||||
<div class="nav-group-items">
|
||||
<a-tooltip
|
||||
v-for="item in filteredAccessControlItems"
|
||||
:key="item.key"
|
||||
:title="collapsed ? item.label : ''"
|
||||
placement="left"
|
||||
>
|
||||
<div
|
||||
class="nav-link-item"
|
||||
:class="{ 'is-active': selectedKeys.includes(item.key) }"
|
||||
@click="navigateTo(item)"
|
||||
>
|
||||
<component :is="item.icon" class="nav-link-icon" />
|
||||
<span v-if="!collapsed" class="nav-link-text">{{ item.label }}</span>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty search state -->
|
||||
<div
|
||||
v-if="!collapsed && filteredMainItems.length === 0 && filteredUtilityItems.length === 0"
|
||||
v-if="!collapsed && filteredMainItems.length === 0 && filteredUtilityItems.length === 0 && filteredAccessControlItems.length === 0"
|
||||
class="empty-search-text"
|
||||
>
|
||||
موردی یافت نشد
|
||||
@@ -532,8 +577,24 @@ function confirmLogout() {
|
||||
/* Navigation Area */
|
||||
.menu-scroll-wrap {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 6px 10px 16px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e1 transparent;
|
||||
}
|
||||
|
||||
.menu-scroll-wrap::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.menu-scroll-wrap::-webkit-scrollbar-thumb {
|
||||
background-color: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.menu-scroll-wrap::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #94a3b8;
|
||||
}
|
||||
|
||||
.sider-nav {
|
||||
@@ -641,6 +702,9 @@ function confirmLogout() {
|
||||
}
|
||||
|
||||
.app-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 99;
|
||||
background: #ffffff;
|
||||
padding: 0 28px;
|
||||
height: 64px;
|
||||
|
||||
@@ -19,14 +19,28 @@ const titles: Record<string, string> = {
|
||||
'm-sms': 'لاگ پیامک',
|
||||
'm-reports': 'گزارشها',
|
||||
'm-audit': 'لاگ بازرسی',
|
||||
'm-managers': 'مدیریت مدیران',
|
||||
'm-managers-create': 'افزودن مدیر جدید',
|
||||
'm-managers-edit': 'ویرایش اطلاعات مدیر',
|
||||
'm-roles': 'نقشها و دسترسیها',
|
||||
'm-workspaces': 'فضاهای کاری',
|
||||
'm-forbidden': 'عدم دسترسی',
|
||||
};
|
||||
|
||||
const hideBottomNavRoutes = new Set(['m-campaign-create', 'm-campaign-edit', 'm-user-detail']);
|
||||
const hideBottomNavRoutes = new Set([
|
||||
'm-campaign-create',
|
||||
'm-campaign-edit',
|
||||
'm-user-detail',
|
||||
'm-managers-create',
|
||||
'm-managers-edit',
|
||||
]);
|
||||
const showBackRoutes = new Set([
|
||||
'm-campaign-create',
|
||||
'm-campaign-edit',
|
||||
'm-campaign-prizes',
|
||||
'm-user-detail',
|
||||
'm-managers-create',
|
||||
'm-managers-edit',
|
||||
]);
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
@@ -79,8 +93,9 @@ watch(
|
||||
|
||||
<style scoped>
|
||||
.mobile-shell {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
background: #f4f6fa;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createPinia } from 'pinia';
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import { bindDeviceRouter } from './composables/useDevice';
|
||||
import { setupPermissionDirective } from './directives/permission.directive';
|
||||
import { router } from './router';
|
||||
|
||||
const app = createApp(App);
|
||||
@@ -13,6 +14,7 @@ const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.use(Antd);
|
||||
setupPermissionDirective(app);
|
||||
|
||||
bindDeviceRouter(router);
|
||||
|
||||
|
||||
@@ -86,6 +86,29 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'audit',
|
||||
component: () => import('../views/audit/AuditLogView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'managers',
|
||||
name: 'managers',
|
||||
component: () => import('../views/managers/AdminListView.vue'),
|
||||
meta: { permission: 'admins:read' },
|
||||
},
|
||||
{
|
||||
path: 'roles',
|
||||
name: 'roles',
|
||||
component: () => import('../views/roles/RoleMatrixView.vue'),
|
||||
meta: { permission: 'roles:manage' },
|
||||
},
|
||||
{
|
||||
path: 'workspaces',
|
||||
name: 'workspaces',
|
||||
component: () => import('../views/workspaces/WorkspaceListView.vue'),
|
||||
meta: { permission: 'workspaces:manage' },
|
||||
},
|
||||
{
|
||||
path: '403',
|
||||
name: 'forbidden',
|
||||
component: () => import('../views/errors/ForbiddenView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -154,6 +177,41 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'm-audit',
|
||||
component: () => import('../views/mobile/MAuditLogView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'managers',
|
||||
name: 'm-managers',
|
||||
component: () => import('../views/mobile/MAdminListView.vue'),
|
||||
meta: { permission: 'admins:read' },
|
||||
},
|
||||
{
|
||||
path: 'managers/create',
|
||||
name: 'm-managers-create',
|
||||
component: () => import('../views/mobile/MAdminFormView.vue'),
|
||||
meta: { permission: 'admins:manage' },
|
||||
},
|
||||
{
|
||||
path: 'managers/:id/edit',
|
||||
name: 'm-managers-edit',
|
||||
component: () => import('../views/mobile/MAdminFormView.vue'),
|
||||
meta: { permission: 'admins:manage' },
|
||||
},
|
||||
{
|
||||
path: 'roles',
|
||||
name: 'm-roles',
|
||||
component: () => import('../views/mobile/MRoleMatrixView.vue'),
|
||||
meta: { permission: 'roles:manage' },
|
||||
},
|
||||
{
|
||||
path: 'workspaces',
|
||||
name: 'm-workspaces',
|
||||
component: () => import('../views/mobile/MWorkspaceListView.vue'),
|
||||
meta: { permission: 'workspaces:manage' },
|
||||
},
|
||||
{
|
||||
path: '403',
|
||||
name: 'm-forbidden',
|
||||
component: () => import('../views/mobile/MForbiddenView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -184,7 +242,7 @@ function toDesktopPath(to: { name?: string | symbol | null; params: Record<strin
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
const token = localStorage.getItem('admin_access_token');
|
||||
const mobile = checkIsMobile();
|
||||
const isMobileRoute = to.path.startsWith('/m') || !!to.meta.mobile;
|
||||
const isMobileRoute = to.path === '/m' || to.path.startsWith('/m/') || !!to.meta.mobile;
|
||||
|
||||
if (to.meta.requiresAuth && !token) {
|
||||
next(mobile ? '/m/login' : '/login');
|
||||
@@ -227,5 +285,24 @@ router.beforeEach(async (to, _from, next) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Permission authorization check
|
||||
if (token && to.meta.permission) {
|
||||
const adminInfoStr = localStorage.getItem('admin_info');
|
||||
if (adminInfoStr) {
|
||||
try {
|
||||
const adminInfo = JSON.parse(adminInfoStr);
|
||||
const isSuper = !!adminInfo.isSuperAdmin || adminInfo.role === 'SUPER_ADMIN';
|
||||
const permissions: string[] = adminInfo.permissions || [];
|
||||
const requiredPerm = to.meta.permission as string;
|
||||
if (!isSuper && !permissions.includes(requiredPerm)) {
|
||||
next(mobile ? '/m/403' : '/403');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
@@ -9,6 +9,19 @@ export interface AdminUser {
|
||||
email: string;
|
||||
mobile: string;
|
||||
role: string;
|
||||
isSuperAdmin?: boolean;
|
||||
permissions?: string[];
|
||||
roles?: Array<{
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
}>;
|
||||
workspaces?: Array<{
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
scopeType: string;
|
||||
specificCampaignIds?: string[] | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('adminAuth', () => {
|
||||
@@ -21,6 +34,33 @@ export const useAuthStore = defineStore('adminAuth', () => {
|
||||
const loading = ref(false);
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value);
|
||||
const isSuperAdmin = computed(() => !!admin.value?.isSuperAdmin || admin.value?.role === 'SUPER_ADMIN');
|
||||
const userPermissions = computed(() => admin.value?.permissions || []);
|
||||
|
||||
function can(permissionCode: string): boolean {
|
||||
if (!isAuthenticated.value) return false;
|
||||
if (isSuperAdmin.value) return true;
|
||||
return userPermissions.value.includes(permissionCode);
|
||||
}
|
||||
|
||||
function canAny(permissionCodes: string[]): boolean {
|
||||
if (!isAuthenticated.value) return false;
|
||||
if (isSuperAdmin.value) return true;
|
||||
return permissionCodes.some((code) => userPermissions.value.includes(code));
|
||||
}
|
||||
|
||||
async function fetchMe() {
|
||||
if (!token.value) return null;
|
||||
try {
|
||||
const response = await apiClient.get('/admin/auth/me');
|
||||
const data = response.data;
|
||||
admin.value = data;
|
||||
localStorage.setItem('admin_info', JSON.stringify(data));
|
||||
return data;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function login(credentials: { email: string; password: string }) {
|
||||
loading.value = true;
|
||||
@@ -59,8 +99,11 @@ export const useAuthStore = defineStore('adminAuth', () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await apiClient.patch('/admin/auth/profile', payload);
|
||||
admin.value = response.data;
|
||||
localStorage.setItem('admin_info', JSON.stringify(response.data));
|
||||
admin.value = {
|
||||
...admin.value,
|
||||
...response.data,
|
||||
};
|
||||
localStorage.setItem('admin_info', JSON.stringify(admin.value));
|
||||
return response.data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -72,6 +115,11 @@ export const useAuthStore = defineStore('adminAuth', () => {
|
||||
admin,
|
||||
loading,
|
||||
isAuthenticated,
|
||||
isSuperAdmin,
|
||||
userPermissions,
|
||||
can,
|
||||
canAny,
|
||||
fetchMe,
|
||||
login,
|
||||
logout,
|
||||
updateProfile,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function goHome() {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="forbidden-page">
|
||||
<a-card :bordered="false" class="forbidden-card">
|
||||
<a-result
|
||||
status="403"
|
||||
title="۴۰۳ — دسترسی غیرمجاز"
|
||||
sub-title="شما مجوز لازم برای مشاهده این بخش یا انجام این عملیات را در سامانه ندارید."
|
||||
>
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="goHome">
|
||||
بازگشت به داشبورد
|
||||
</a-button>
|
||||
</template>
|
||||
</a-result>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.forbidden-page {
|
||||
padding: 40px 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 70vh;
|
||||
}
|
||||
.forbidden-card {
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,389 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
KeyOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
SaveOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { apiClient } from '../../services/api/client';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
adminId?: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', val: boolean): void;
|
||||
(e: 'saved'): void;
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const rolesList = ref<any[]>([]);
|
||||
const workspacesList = ref<any[]>([]);
|
||||
const campaignsList = ref<any[]>([]);
|
||||
|
||||
const formState = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
mobile: '',
|
||||
password: '',
|
||||
status: 'ACTIVE',
|
||||
isSuperAdmin: false,
|
||||
roleIds: [] as string[],
|
||||
workspaceId: '',
|
||||
scopeType: 'ALL',
|
||||
specificCampaignIds: [] as string[],
|
||||
});
|
||||
|
||||
const isEdit = computed(() => !!props.adminId);
|
||||
|
||||
async function loadMetadata() {
|
||||
try {
|
||||
const [rolesRes, wsRes, campRes] = await Promise.all([
|
||||
apiClient.get('/admin/roles'),
|
||||
apiClient.get('/admin/workspaces'),
|
||||
apiClient.get('/admin/campaigns', { params: { pageSize: 100 } }),
|
||||
]);
|
||||
rolesList.value = Array.isArray(rolesRes.data) ? rolesRes.data : rolesRes.data?.data || [];
|
||||
workspacesList.value = Array.isArray(wsRes.data) ? wsRes.data : wsRes.data?.data || [];
|
||||
campaignsList.value = Array.isArray(campRes.data) ? campRes.data : campRes.data?.data || [];
|
||||
} catch (err) {
|
||||
// Handled
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAdminData(id: string) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get(`/admin/managers/${id}`);
|
||||
const a = res.data;
|
||||
formState.name = a.name || '';
|
||||
formState.email = a.email || '';
|
||||
formState.mobile = a.mobile || '';
|
||||
formState.password = '';
|
||||
formState.status = a.status || 'ACTIVE';
|
||||
formState.isSuperAdmin = !!a.isSuperAdmin;
|
||||
formState.roleIds = a.roles ? a.roles.map((r: any) => r.role?.id || r.roleId) : [];
|
||||
|
||||
if (a.workspaceAssignments && a.workspaceAssignments.length > 0) {
|
||||
const wa = a.workspaceAssignments[0];
|
||||
formState.workspaceId = wa.workspaceId || wa.workspace?.id || '';
|
||||
formState.scopeType = wa.scopeType || 'ALL';
|
||||
formState.specificCampaignIds = Array.isArray(wa.specificCampaignIds) ? wa.specificCampaignIds : [];
|
||||
} else {
|
||||
formState.workspaceId = workspacesList.value[0]?.id || '';
|
||||
formState.scopeType = 'ALL';
|
||||
formState.specificCampaignIds = [];
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formState.name = '';
|
||||
formState.email = '';
|
||||
formState.mobile = '';
|
||||
formState.password = '';
|
||||
formState.status = 'ACTIVE';
|
||||
formState.isSuperAdmin = false;
|
||||
formState.roleIds = [];
|
||||
formState.workspaceId = workspacesList.value[0]?.id || '';
|
||||
formState.scopeType = 'ALL';
|
||||
formState.specificCampaignIds = [];
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(val) => {
|
||||
if (val) {
|
||||
loadMetadata();
|
||||
if (props.adminId) {
|
||||
loadAdminData(props.adminId);
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function onSubmit() {
|
||||
if (!formState.name.trim()) {
|
||||
message.error('لطفاً نام و نام خانوادگی مدیر را وارد کنید.');
|
||||
return;
|
||||
}
|
||||
if (!formState.email.trim() || !formState.email.includes('@')) {
|
||||
message.error('لطفاً یک آدرس ایمیل معتبر وارد کنید.');
|
||||
return;
|
||||
}
|
||||
if (!formState.mobile.trim() || !/^09\d{9}$/.test(formState.mobile.trim())) {
|
||||
message.error('شماره موبایل باید ۱۱ رقمی بوده و با ۰۹ شروع شود.');
|
||||
return;
|
||||
}
|
||||
if (!isEdit.value && (!formState.password || formState.password.length < 6)) {
|
||||
message.error('کلمه عبور باید حداقل ۶ کاراکتر باشد.');
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
const payload: any = {
|
||||
name: formState.name.trim(),
|
||||
email: formState.email.trim().toLowerCase(),
|
||||
mobile: formState.mobile.trim(),
|
||||
status: formState.status,
|
||||
isSuperAdmin: formState.isSuperAdmin,
|
||||
roleIds: formState.roleIds,
|
||||
};
|
||||
|
||||
if (formState.password) {
|
||||
payload.password = formState.password;
|
||||
}
|
||||
|
||||
if (formState.workspaceId) {
|
||||
payload.workspaceAssignments = [
|
||||
{
|
||||
workspaceId: formState.workspaceId,
|
||||
scopeType: formState.scopeType,
|
||||
specificCampaignIds:
|
||||
formState.scopeType === 'CUSTOM_CAMPAIGNS' ? formState.specificCampaignIds : undefined,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (isEdit.value && props.adminId) {
|
||||
await apiClient.patch(`/admin/managers/${props.adminId}`, payload);
|
||||
message.success('مشخصات مدیر با موفقیت بهروزرسانی شد.');
|
||||
} else {
|
||||
await apiClient.post('/admin/managers', payload);
|
||||
message.success('مدیر جدید با موفقیت ایجاد شد.');
|
||||
}
|
||||
|
||||
emit('saved');
|
||||
emit('update:open', false);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-drawer
|
||||
:open="open"
|
||||
:title="isEdit ? 'ویرایش اطلاعات مدیر' : 'افزودن مدیر جدید به سامانه'"
|
||||
:width="540"
|
||||
placement="right"
|
||||
:maskClosable="false"
|
||||
rootClassName="admin-form-drawer-rtl"
|
||||
@close="emit('update:open', false)"
|
||||
>
|
||||
<a-spin :spinning="loading">
|
||||
<a-form layout="vertical">
|
||||
<a-tabs defaultActiveKey="1" class="admin-form-tabs">
|
||||
<!-- TAB 1: اطلاعات فردی و ورود -->
|
||||
<a-tab-pane key="1">
|
||||
<template #tab>
|
||||
<span><UserOutlined /> اطلاعات هویتی</span>
|
||||
</template>
|
||||
|
||||
<a-form-item label="نام و نام خانوادگی" required>
|
||||
<a-input
|
||||
v-model:value="formState.name"
|
||||
placeholder="مثال: علی رضایی"
|
||||
size="large"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="شماره موبایل سازمانی" required>
|
||||
<a-input
|
||||
v-model:value="formState.mobile"
|
||||
placeholder="09xxxxxxxxx"
|
||||
dir="ltr"
|
||||
size="large"
|
||||
maxLength="11"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="ایمیل سازمانی (نام کاربری ورود)" required>
|
||||
<a-input
|
||||
v-model:value="formState.email"
|
||||
placeholder="admin@example.com"
|
||||
dir="ltr"
|
||||
size="large"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
:label="isEdit ? 'کلمه عبور جدید (در صورت نیاز به تغییر)' : 'کلمه عبور ورود'"
|
||||
:required="!isEdit"
|
||||
>
|
||||
<a-input-password
|
||||
v-model:value="formState.password"
|
||||
placeholder="حداقل ۶ کاراکتر"
|
||||
size="large"
|
||||
dir="ltr"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="وضعیت حساب کاربری">
|
||||
<a-radio-group v-model:value="formState.status" buttonStyle="solid">
|
||||
<a-radio-button value="ACTIVE">فعال</a-radio-button>
|
||||
<a-radio-button value="DISABLED">غیرفعال / مسدود</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- TAB 2: نقشها و اختیارات -->
|
||||
<a-tab-pane key="2">
|
||||
<template #tab>
|
||||
<span><SafetyCertificateOutlined /> نقشها و اختیارات</span>
|
||||
</template>
|
||||
|
||||
<div class="super-admin-banner">
|
||||
<a-checkbox v-model:checked="formState.isSuperAdmin">
|
||||
<strong style="color: #7c3aed; font-size: 15px">مدیر ارشد سامانه (Super Admin)</strong>
|
||||
</a-checkbox>
|
||||
<p class="banner-sub">
|
||||
مدیر ارشد دسترسی کامل و بیقیدوشرط به تمامی بخشها، ایجاد مدیران و تنظیمات کلی سامانه دارد.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a-divider style="margin: 16px 0" />
|
||||
|
||||
<a-form-item label="نقشهای سازمانی (میتوانید چند نقش همزمان انتخاب کنید)">
|
||||
<div v-if="rolesList.length === 0" style="color: #94a3b8">
|
||||
در حال بارگذاری نقشها...
|
||||
</div>
|
||||
<a-checkbox-group
|
||||
v-else
|
||||
v-model:value="formState.roleIds"
|
||||
style="width: 100%"
|
||||
:disabled="formState.isSuperAdmin"
|
||||
>
|
||||
<a-row :gutter="[12, 12]">
|
||||
<a-col :span="24" v-for="r in rolesList" :key="r.id">
|
||||
<div class="role-item-card" :class="{ 'is-selected': formState.roleIds.includes(r.id) }">
|
||||
<a-checkbox :value="r.id">
|
||||
<span class="role-name">{{ r.name }}</span>
|
||||
<a-tag v-if="r.isSystem" color="blue" size="small" style="margin-right: 6px">سیستمی</a-tag>
|
||||
</a-checkbox>
|
||||
<div class="role-desc">{{ r.description || 'بدون توضیح' }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-checkbox-group>
|
||||
</a-form-item>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- TAB 3: فضاهای کاری و اسکوپ دسترسی -->
|
||||
<a-tab-pane key="3">
|
||||
<template #tab>
|
||||
<span><ApartmentOutlined /> قلمرو و فضای کاری</span>
|
||||
</template>
|
||||
|
||||
<a-form-item label="فضای کاری مجاز">
|
||||
<a-select
|
||||
v-model:value="formState.workspaceId"
|
||||
placeholder="انتخاب فضای کاری"
|
||||
size="large"
|
||||
style="width: 100%"
|
||||
>
|
||||
<a-select-option v-for="ws in workspacesList" :key="ws.id" :value="ws.id">
|
||||
{{ ws.name }} ({{ ws.slug }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="قلمرو دسترسی کمپینها (Scope)">
|
||||
<a-radio-group v-model:value="formState.scopeType" style="margin-bottom: 12px">
|
||||
<a-radio value="ALL">دسترسی به تمامی کمپینهای این فضای کاری</a-radio>
|
||||
<a-radio value="CUSTOM_CAMPAIGNS">محدود به کمپینهای خاص</a-radio>
|
||||
</a-radio-group>
|
||||
|
||||
<div v-if="formState.scopeType === 'CUSTOM_CAMPAIGNS'" style="margin-top: 8px">
|
||||
<a-select
|
||||
v-model:value="formState.specificCampaignIds"
|
||||
mode="multiple"
|
||||
placeholder="کمپینهای مجاز را انتخاب کنید"
|
||||
size="large"
|
||||
style="width: 100%"
|
||||
>
|
||||
<a-select-option v-for="c in campaignsList" :key="c.id" :value="c.id">
|
||||
{{ c.title }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-form>
|
||||
</a-spin>
|
||||
|
||||
<template #footer>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 8px">
|
||||
<a-button @click="emit('update:open', false)">انصراف</a-button>
|
||||
<a-button type="primary" :loading="submitting" @click="onSubmit">
|
||||
<template #icon><SaveOutlined /></template>
|
||||
{{ isEdit ? 'ذخیره تغییرات' : 'ثبت مدیر' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.super-admin-banner {
|
||||
background: #f5f3ff;
|
||||
border: 1px solid #ddd6fe;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.banner-sub {
|
||||
margin: 6px 0 0 24px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.role-item-card {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
background: #fff;
|
||||
}
|
||||
.role-item-card.is-selected {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
}
|
||||
.role-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
}
|
||||
.role-desc {
|
||||
margin-right: 24px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
:deep(.ant-drawer-content) {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
:deep(.ant-drawer-header) {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
:deep(.ant-drawer-title) {
|
||||
text-align: right;
|
||||
}
|
||||
:deep(.ant-drawer-close) {
|
||||
margin-right: 0;
|
||||
margin-left: var(--ant-margin-xs, 8px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,518 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
CrownOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
KeyOutlined,
|
||||
MoreOutlined,
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
StopOutlined,
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { toFarsiDigits } from '@lucky-wheel/shared-types';
|
||||
import { useAuthStore } from '../../stores/auth.store';
|
||||
import { apiClient } from '../../services/api/client';
|
||||
import AdminFormDrawer from './AdminFormDrawer.vue';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const admins = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
const rolesList = ref<any[]>([]);
|
||||
|
||||
const filters = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
search: '',
|
||||
roleCode: undefined as string | undefined,
|
||||
status: undefined as string | undefined,
|
||||
});
|
||||
|
||||
// Drawer state
|
||||
const isDrawerOpen = ref(false);
|
||||
const editingAdminId = ref<string | null>(null);
|
||||
|
||||
// Reset password modal state
|
||||
const isPasswordModalOpen = ref(false);
|
||||
const resettingAdmin = ref<any>(null);
|
||||
const newPassword = ref('');
|
||||
const isSubmittingPassword = ref(false);
|
||||
|
||||
async function loadRoles() {
|
||||
try {
|
||||
const res = await apiClient.get('/admin/roles');
|
||||
rolesList.value = Array.isArray(res.data) ? res.data : res.data?.data || [];
|
||||
} catch {
|
||||
// Handled
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAdmins() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get('/admin/managers', {
|
||||
params: {
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
search: filters.search || undefined,
|
||||
roleCode: filters.roleCode || undefined,
|
||||
status: filters.status || undefined,
|
||||
},
|
||||
});
|
||||
const items = Array.isArray(res.data) ? res.data : res.data?.data || [];
|
||||
admins.value = items;
|
||||
total.value = res.meta?.total || res.data?.meta?.total || items.length;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDrawer() {
|
||||
editingAdminId.value = null;
|
||||
isDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
function openEditDrawer(record: any) {
|
||||
editingAdminId.value = record.id;
|
||||
isDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
function openResetPasswordModal(record: any) {
|
||||
resettingAdmin.value = record;
|
||||
newPassword.value = '';
|
||||
isPasswordModalOpen.value = true;
|
||||
}
|
||||
|
||||
async function handleResetPassword() {
|
||||
if (!newPassword.value || newPassword.value.length < 6) {
|
||||
message.error('کلمه عبور جدید باید حداقل ۶ کاراکتر باشد.');
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmittingPassword.value = true;
|
||||
try {
|
||||
await apiClient.patch(`/admin/managers/${resettingAdmin.value.id}/password`, {
|
||||
password: newPassword.value,
|
||||
});
|
||||
message.success(`کلمه عبور مدیر ${resettingAdmin.value.name} با موفقیت بازنشانی شد.`);
|
||||
isPasswordModalOpen.value = false;
|
||||
} finally {
|
||||
isSubmittingPassword.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmToggleStatus(record: any) {
|
||||
const isCurrentlyActive = record.status === 'ACTIVE';
|
||||
const newStatus = isCurrentlyActive ? 'DISABLED' : 'ACTIVE';
|
||||
|
||||
Modal.confirm({
|
||||
title: isCurrentlyActive ? 'غیرفعالسازی حساب مدیر' : 'فعالسازی مجدد حساب مدیر',
|
||||
content: isCurrentlyActive
|
||||
? `آیا از غیرفعالسازی دسترسی مدیر ${record.name} اطمینان دارید؟ دسترسی ایشان به پنل بلافاصله مسدود میشود.`
|
||||
: `آیا از فعالسازی مجدد حساب کاربری ${record.name} اطمینان دارید؟`,
|
||||
okText: isCurrentlyActive ? 'غیرفعال کن' : 'فعال کن',
|
||||
okType: isCurrentlyActive ? 'danger' : 'primary',
|
||||
cancelText: 'انصراف',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await apiClient.patch(`/admin/managers/${record.id}/status`, { status: newStatus });
|
||||
message.success(isCurrentlyActive ? 'حساب کاربری غیرفعال شد.' : 'حساب کاربری فعال شد.');
|
||||
loadAdmins();
|
||||
} catch {
|
||||
// Handled by client interceptor
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function confirmDeleteAdmin(record: any) {
|
||||
Modal.confirm({
|
||||
title: 'حذف مدیر از سامانه',
|
||||
content: `آیا از حذف دائم حساب مدیر ${record.name} اطمینان دارید؟ این عملیات غیرقابل بازگشت است.`,
|
||||
okText: 'حذف مدیر',
|
||||
okType: 'danger',
|
||||
cancelText: 'انصراف',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await apiClient.delete(`/admin/managers/${record.id}`);
|
||||
message.success('مدیر با موفقیت حذف شد.');
|
||||
loadAdmins();
|
||||
} catch {
|
||||
// Handled by client interceptor
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: 'مشخصات مدیر', key: 'adminProfile', width: 220 },
|
||||
{ title: 'شماره موبایل', dataIndex: 'mobile', key: 'mobile', width: 140 },
|
||||
{ title: 'نقشها و سطح اختیارات', key: 'roles', minWidth: 200 },
|
||||
{ title: 'فضای کاری و اسکوپ', key: 'workspaces', minWidth: 160 },
|
||||
{ title: 'وضعیت', dataIndex: 'status', key: 'status', align: 'center' as const, width: 100 },
|
||||
{ title: 'آخرین ورود', dataIndex: 'lastLoginAt', key: 'lastLoginAt', width: 130 },
|
||||
{ title: 'عملیات', key: 'actions', width: 70, align: 'center' as const },
|
||||
];
|
||||
|
||||
onMounted(() => {
|
||||
loadRoles();
|
||||
loadAdmins();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-list-page">
|
||||
<div class="page-header-title">
|
||||
<div>
|
||||
<h1 class="title">مدیریت مدیران و دسترسیها</h1>
|
||||
<p class="subtitle">مشاهده لیست مدیران، تخصیص نقشها، فضاهای کاری و اسکوپ دسترسی</p>
|
||||
</div>
|
||||
|
||||
<div class="page-actions" v-if="authStore.can('admins:manage')">
|
||||
<a-button type="primary" size="large" @click="openCreateDrawer">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
افزودن مدیر جدید
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-card :bordered="false" class="main-card">
|
||||
<div class="table-header">
|
||||
<div class="filters">
|
||||
<a-input-search
|
||||
v-model:value="filters.search"
|
||||
placeholder="جستجوی نام، ایمیل یا موبایل..."
|
||||
style="width: 280px"
|
||||
allowClear
|
||||
@search="() => { filters.page = 1; loadAdmins(); }"
|
||||
>
|
||||
<template #prefix><SearchOutlined style="color: #94a3b8; margin-left: 4px" /></template>
|
||||
</a-input-search>
|
||||
|
||||
<a-select
|
||||
v-model:value="filters.roleCode"
|
||||
placeholder="فیلتر بر اساس نقش"
|
||||
allowClear
|
||||
style="width: 180px"
|
||||
@change="() => { filters.page = 1; loadAdmins(); }"
|
||||
>
|
||||
<a-select-option v-for="r in rolesList" :key="r.id" :value="r.code">
|
||||
{{ r.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
|
||||
<a-select
|
||||
v-model:value="filters.status"
|
||||
placeholder="وضعیت حساب"
|
||||
allowClear
|
||||
style="width: 140px"
|
||||
@change="() => { filters.page = 1; loadAdmins(); }"
|
||||
>
|
||||
<a-select-option value="ACTIVE">فعال</a-select-option>
|
||||
<a-select-option value="DISABLED">غیرفعال</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:dataSource="admins"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
rowKey="id"
|
||||
size="middle"
|
||||
:pagination="{
|
||||
current: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
total: total,
|
||||
showTotal: (total: number) => `مجموع: ${toFarsiDigits(total)} مدیر`,
|
||||
onChange: (page: number) => {
|
||||
filters.page = page;
|
||||
loadAdmins();
|
||||
},
|
||||
}"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<!-- Profile Column -->
|
||||
<template v-if="column.key === 'adminProfile'">
|
||||
<div class="admin-profile-cell">
|
||||
<a-avatar
|
||||
:style="{
|
||||
backgroundColor: record.isSuperAdmin ? '#7c3aed' : '#2563eb',
|
||||
verticalAlign: 'middle',
|
||||
}"
|
||||
>
|
||||
{{ record.name?.charAt(0) || 'U' }}
|
||||
</a-avatar>
|
||||
<div class="admin-details">
|
||||
<span class="admin-name">
|
||||
{{ record.name }}
|
||||
<a-tooltip v-if="record.isSuperAdmin" title="مدیر ارشد سامانه">
|
||||
<CrownOutlined style="color: #f59e0b; margin-right: 4px" />
|
||||
</a-tooltip>
|
||||
</span>
|
||||
<span class="admin-email">{{ record.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Mobile -->
|
||||
<template v-else-if="column.key === 'mobile'">
|
||||
<span class="mobile-cell">{{ toFarsiDigits(record.mobile) }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Roles -->
|
||||
<template v-else-if="column.key === 'roles'">
|
||||
<div class="roles-tag-group">
|
||||
<a-tag v-if="record.isSuperAdmin" color="purple" class="role-pill">
|
||||
مدیر ارشد (Super Admin)
|
||||
</a-tag>
|
||||
<template v-if="record.roles && record.roles.length > 0">
|
||||
<a-tag
|
||||
v-for="ra in record.roles"
|
||||
:key="ra.role?.id"
|
||||
color="blue"
|
||||
class="role-pill"
|
||||
>
|
||||
{{ ra.role?.name || ra.role?.code }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<span v-else-if="!record.isSuperAdmin" style="color: #94a3b8; font-size: 13px">
|
||||
بدون نقش
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Workspaces & Scopes -->
|
||||
<template v-else-if="column.key === 'workspaces'">
|
||||
<div class="workspaces-tag-group">
|
||||
<template v-if="record.workspaceAssignments && record.workspaceAssignments.length > 0">
|
||||
<a-tag
|
||||
v-for="(wa, idx) in record.workspaceAssignments"
|
||||
:key="idx"
|
||||
color="cyan"
|
||||
class="scope-pill"
|
||||
>
|
||||
{{ wa.workspace?.name || 'فضای اصلی' }}
|
||||
<span v-if="wa.scopeType === 'CUSTOM_CAMPAIGNS'" style="font-size: 11px; opacity: 0.8">
|
||||
(کمپینهای خاص)
|
||||
</span>
|
||||
</a-tag>
|
||||
</template>
|
||||
<span v-else style="color: #94a3b8; font-size: 13px">
|
||||
سراسری
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Status -->
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 'DISABLED' ? 'red' : 'green'" class="status-tag">
|
||||
<span
|
||||
class="status-dot"
|
||||
:style="{ backgroundColor: record.status === 'DISABLED' ? '#ef4444' : '#10b981' }"
|
||||
/>
|
||||
{{ record.status === 'DISABLED' ? 'غیرفعال' : 'فعال' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- Last Login -->
|
||||
<template v-else-if="column.key === 'lastLoginAt'">
|
||||
<span class="date-cell">
|
||||
{{ record.lastLoginAt ? toFarsiDigits(new Date(record.lastLoginAt).toLocaleDateString('fa-IR')) : '—' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Actions -->
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-dropdown :trigger="['click']" placement="bottomLeft">
|
||||
<a-button type="text" shape="circle" class="action-more-btn">
|
||||
<MoreOutlined style="font-size: 18px" />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu class="action-dropdown-menu">
|
||||
<a-menu-item
|
||||
v-if="authStore.can('admins:manage')"
|
||||
key="edit"
|
||||
@click="openEditDrawer(record)"
|
||||
>
|
||||
<EditOutlined style="margin-left: 8px" />
|
||||
ویرایش مشخصات و دسترسیها
|
||||
</a-menu-item>
|
||||
|
||||
<a-menu-item
|
||||
v-if="authStore.can('admins:manage')"
|
||||
key="resetPassword"
|
||||
@click="openResetPasswordModal(record)"
|
||||
>
|
||||
<KeyOutlined style="margin-left: 8px" />
|
||||
بازنشانی کلمه عبور
|
||||
</a-menu-item>
|
||||
|
||||
<a-menu-divider v-if="authStore.can('admins:manage') && record.id !== authStore.admin?.id" />
|
||||
|
||||
<a-menu-item
|
||||
v-if="authStore.can('admins:manage') && record.id !== authStore.admin?.id"
|
||||
key="toggleStatus"
|
||||
@click="confirmToggleStatus(record)"
|
||||
>
|
||||
<template v-if="record.status === 'ACTIVE'">
|
||||
<StopOutlined style="margin-left: 8px; color: #ef4444" />
|
||||
<span style="color: #ef4444">غیرفعالسازی دسترسی</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<CheckCircleOutlined style="margin-left: 8px; color: #10b981" />
|
||||
<span style="color: #10b981">فعالسازی مجدد</span>
|
||||
</template>
|
||||
</a-menu-item>
|
||||
|
||||
<a-menu-item
|
||||
v-if="authStore.can('admins:manage') && record.id !== authStore.admin?.id"
|
||||
key="delete"
|
||||
danger
|
||||
@click="confirmDeleteAdmin(record)"
|
||||
>
|
||||
<DeleteOutlined style="margin-left: 8px" />
|
||||
حذف مدیر
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- Create / Edit Drawer -->
|
||||
<AdminFormDrawer
|
||||
v-model:open="isDrawerOpen"
|
||||
:adminId="editingAdminId"
|
||||
@saved="loadAdmins"
|
||||
/>
|
||||
|
||||
<!-- Reset Password Modal -->
|
||||
<a-modal
|
||||
v-model:open="isPasswordModalOpen"
|
||||
title="بازنشانی کلمه عبور مدیر"
|
||||
okText="تغییر کلمه عبور"
|
||||
cancelText="انصراف"
|
||||
:confirmLoading="isSubmittingPassword"
|
||||
@ok="handleResetPassword"
|
||||
>
|
||||
<div style="margin-bottom: 16px">
|
||||
کلمه عبور جدید را برای <strong>{{ resettingAdmin?.name }}</strong> وارد کنید:
|
||||
</div>
|
||||
<a-input-password
|
||||
v-model:value="newPassword"
|
||||
placeholder="حداقل ۶ کاراکتر"
|
||||
size="large"
|
||||
dir="ltr"
|
||||
/>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-list-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;
|
||||
}
|
||||
.main-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.table-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.admin-profile-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.admin-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.admin-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
}
|
||||
.admin-email {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
}
|
||||
.mobile-cell {
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
}
|
||||
.roles-tag-group,
|
||||
.workspaces-tag-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.role-pill,
|
||||
.scope-pill {
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.status-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.date-cell {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
.action-more-btn {
|
||||
color: #64748b;
|
||||
}
|
||||
.action-more-btn:hover {
|
||||
background-color: #f1f5f9;
|
||||
color: #1e293b;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,495 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
CrownOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { apiClient } from '../../services/api/client';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const isEdit = computed(() => !!route.params.id);
|
||||
const adminId = computed(() => route.params.id as string | undefined);
|
||||
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const step = ref(0);
|
||||
|
||||
const rolesList = ref<any[]>([]);
|
||||
const workspacesList = ref<any[]>([]);
|
||||
const campaignsList = ref<any[]>([]);
|
||||
|
||||
const formState = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
mobile: '',
|
||||
password: '',
|
||||
status: 'ACTIVE',
|
||||
isSuperAdmin: false,
|
||||
roleIds: [] as string[],
|
||||
workspaceId: '',
|
||||
scopeType: 'ALL',
|
||||
specificCampaignIds: [] as string[],
|
||||
});
|
||||
|
||||
async function loadMetadata() {
|
||||
try {
|
||||
const [rolesRes, wsRes, campRes] = await Promise.all([
|
||||
apiClient.get('/admin/roles'),
|
||||
apiClient.get('/admin/workspaces'),
|
||||
apiClient.get('/admin/campaigns', { params: { pageSize: 100 } }),
|
||||
]);
|
||||
rolesList.value = Array.isArray(rolesRes.data) ? rolesRes.data : rolesRes.data?.data || [];
|
||||
workspacesList.value = Array.isArray(wsRes.data) ? wsRes.data : wsRes.data?.data || [];
|
||||
campaignsList.value = Array.isArray(campRes.data) ? campRes.data : campRes.data?.data || [];
|
||||
|
||||
if (!formState.workspaceId && workspacesList.value.length > 0) {
|
||||
formState.workspaceId = workspacesList.value[0].id;
|
||||
}
|
||||
} catch (err) {
|
||||
// Handled
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAdminData(id: string) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get(`/admin/managers/${id}`);
|
||||
const a = res.data;
|
||||
formState.name = a.name || '';
|
||||
formState.email = a.email || '';
|
||||
formState.mobile = a.mobile || '';
|
||||
formState.password = '';
|
||||
formState.status = a.status || 'ACTIVE';
|
||||
formState.isSuperAdmin = !!a.isSuperAdmin;
|
||||
formState.roleIds = a.roles ? a.roles.map((r: any) => r.role?.id || r.roleId) : [];
|
||||
|
||||
if (a.workspaceAssignments && a.workspaceAssignments.length > 0) {
|
||||
const wa = a.workspaceAssignments[0];
|
||||
formState.workspaceId = wa.workspaceId || wa.workspace?.id || '';
|
||||
formState.scopeType = wa.scopeType || 'ALL';
|
||||
formState.specificCampaignIds = Array.isArray(wa.specificCampaignIds) ? wa.specificCampaignIds : [];
|
||||
} else if (workspacesList.value.length > 0) {
|
||||
formState.workspaceId = workspacesList.value[0].id;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
if (step.value === 0) {
|
||||
if (!formState.name.trim()) {
|
||||
message.error('لطفاً نام و نام خانوادگی مدیر را وارد کنید.');
|
||||
return;
|
||||
}
|
||||
if (!formState.mobile.trim() || !/^09\d{9}$/.test(formState.mobile.trim())) {
|
||||
message.error('شماره موبایل باید ۱۱ رقمی باشد و با ۰۹ شروع شود.');
|
||||
return;
|
||||
}
|
||||
if (!formState.email.trim() || !/^\S+@\S+\.\S+$/.test(formState.email.trim())) {
|
||||
message.error('لطفاً یک آدرس ایمیل معتبر وارد کنید.');
|
||||
return;
|
||||
}
|
||||
if (!isEdit.value && (!formState.password || formState.password.length < 6)) {
|
||||
message.error('کلمه عبور ورود باید حداقل ۶ کاراکتر باشد.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (step.value === 1) {
|
||||
if (!formState.isSuperAdmin && formState.roleIds.length === 0) {
|
||||
message.error('لطفاً حداقل یک نقش سازمانی انتخاب کنید یا وضعیت مدیر ارشد را فعال نمایید.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
step.value += 1;
|
||||
}
|
||||
|
||||
function toggleRole(roleId: string) {
|
||||
const idx = formState.roleIds.indexOf(roleId);
|
||||
if (idx > -1) {
|
||||
formState.roleIds.splice(idx, 1);
|
||||
} else {
|
||||
formState.roleIds.push(roleId);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!formState.name.trim()) {
|
||||
message.error('نام الزامی است');
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
const payload: any = {
|
||||
name: formState.name.trim(),
|
||||
email: formState.email.trim().toLowerCase(),
|
||||
mobile: formState.mobile.trim(),
|
||||
status: formState.status,
|
||||
isSuperAdmin: formState.isSuperAdmin,
|
||||
roleIds: formState.isSuperAdmin ? [] : formState.roleIds,
|
||||
};
|
||||
|
||||
if (formState.password) {
|
||||
payload.password = formState.password;
|
||||
}
|
||||
|
||||
if (formState.workspaceId) {
|
||||
payload.workspaceAssignments = [
|
||||
{
|
||||
workspaceId: formState.workspaceId,
|
||||
scopeType: formState.scopeType,
|
||||
specificCampaignIds:
|
||||
formState.scopeType === 'CUSTOM_CAMPAIGNS' ? formState.specificCampaignIds : undefined,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (isEdit.value && adminId.value) {
|
||||
await apiClient.patch(`/admin/managers/${adminId.value}`, payload);
|
||||
message.success('مشخصات مدیر با موفقیت بهروزرسانی شد.');
|
||||
} else {
|
||||
await apiClient.post('/admin/managers', payload);
|
||||
message.success('مدیر جدید با موفقیت ایجاد شد.');
|
||||
}
|
||||
|
||||
router.push('/m/managers');
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.message || 'خطا در ثبت اطلاعات مدیر';
|
||||
message.error(Array.isArray(errorMsg) ? errorMsg.join(' | ') : errorMsg);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMetadata();
|
||||
if (adminId.value) {
|
||||
await loadAdminData(adminId.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="m-form-page">
|
||||
<a-spin :spinning="loading">
|
||||
<!-- Steps Indicator -->
|
||||
<div class="m-steps">
|
||||
<div class="m-step" :class="{ active: step === 0, done: step > 0 }">
|
||||
<UserOutlined /> ۱. هویتی
|
||||
</div>
|
||||
<div class="m-step" :class="{ active: step === 1, done: step > 1 }">
|
||||
<SafetyCertificateOutlined /> ۲. نقشها
|
||||
</div>
|
||||
<div class="m-step" :class="{ active: step === 2 }">
|
||||
<ApartmentOutlined /> ۳. دسترسی
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 1: اطلاعات هویتی و ورود -->
|
||||
<div v-show="step === 0" class="m-card">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="نام و نام خانوادگی" required>
|
||||
<a-input
|
||||
v-model:value="formState.name"
|
||||
size="large"
|
||||
placeholder="مثال: علی رضایی"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="شماره موبایل سازمانی" required>
|
||||
<a-input
|
||||
v-model:value="formState.mobile"
|
||||
size="large"
|
||||
placeholder="09xxxxxxxxx"
|
||||
dir="ltr"
|
||||
maxLength="11"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="ایمیل سازمانی (نام کاربری)" required>
|
||||
<a-input
|
||||
v-model:value="formState.email"
|
||||
size="large"
|
||||
placeholder="admin@example.com"
|
||||
dir="ltr"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
:label="isEdit ? 'کلمه عبور جدید (اختیاری)' : 'کلمه عبور ورود'"
|
||||
:required="!isEdit"
|
||||
>
|
||||
<a-input-password
|
||||
v-model:value="formState.password"
|
||||
size="large"
|
||||
placeholder="حداقل ۶ کاراکتر"
|
||||
dir="ltr"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="وضعیت حساب کاربری">
|
||||
<a-radio-group v-model:value="formState.status" buttonStyle="solid" class="m-radio-full">
|
||||
<a-radio-button value="ACTIVE">فعال</a-radio-button>
|
||||
<a-radio-button value="DISABLED">غیرفعال / مسدود</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<!-- STEP 2: نقشها و اختیارات -->
|
||||
<div v-show="step === 1" class="m-card">
|
||||
<div class="m-super-admin-card" :class="{ 'is-selected': formState.isSuperAdmin }">
|
||||
<a-checkbox v-model:checked="formState.isSuperAdmin">
|
||||
<span class="m-super-title">
|
||||
<CrownOutlined style="color: #f59e0b; margin-left: 6px" />
|
||||
مدیر ارشد سامانه (Super Admin)
|
||||
</span>
|
||||
</a-checkbox>
|
||||
<p class="m-super-desc">
|
||||
مدیر ارشد به تمامی ماژولها، نقشها، گزارشها و فضاهای کاری دسترسی کامل و بیقیدوشرط دارد.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!formState.isSuperAdmin" class="m-roles-container">
|
||||
<div class="m-section-label">انتخاب نقشهای سازمانی مدیر:</div>
|
||||
<div class="m-roles-list">
|
||||
<div
|
||||
v-for="role in rolesList.filter(r => r.code !== 'SUPER_ADMIN')"
|
||||
:key="role.id"
|
||||
class="m-role-pill"
|
||||
:class="{ 'is-active': formState.roleIds.includes(role.id) }"
|
||||
@click="toggleRole(role.id)"
|
||||
>
|
||||
<div class="m-role-pill-top">
|
||||
<a-checkbox :checked="formState.roleIds.includes(role.id)" />
|
||||
<span class="m-role-name">{{ role.name }}</span>
|
||||
</div>
|
||||
<p class="m-role-desc">{{ role.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 3: فضای کاری و اسکوپ دسترسی -->
|
||||
<div v-show="step === 2" class="m-card">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="انتخاب فضای کاری سازمانی" required>
|
||||
<a-select v-model:value="formState.workspaceId" size="large" style="width: 100%">
|
||||
<a-select-option v-for="ws in workspacesList" :key="ws.id" :value="ws.id">
|
||||
{{ ws.name }} ({{ ws.slug }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="اسکوپ دسترسی کمپینها">
|
||||
<a-radio-group v-model:value="formState.scopeType" buttonStyle="solid" class="m-radio-full">
|
||||
<a-radio-button value="ALL">همه کمپینها</a-radio-button>
|
||||
<a-radio-button value="CUSTOM_CAMPAIGNS">کمپینهای مشخص</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
v-if="formState.scopeType === 'CUSTOM_CAMPAIGNS'"
|
||||
label="کمپینهای مجاز برای این مدیر"
|
||||
required
|
||||
>
|
||||
<a-select
|
||||
v-model:value="formState.specificCampaignIds"
|
||||
mode="multiple"
|
||||
size="large"
|
||||
placeholder="کمپینها را انتخاب کنید"
|
||||
style="width: 100%"
|
||||
>
|
||||
<a-select-option v-for="c in campaignsList" :key="c.id" :value="c.id">
|
||||
{{ c.title }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="m-form-actions">
|
||||
<a-button v-if="step > 0" size="large" block @click="step -= 1">
|
||||
مرحله قبلی
|
||||
</a-button>
|
||||
|
||||
<a-button
|
||||
v-if="step < 2"
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
@click="nextStep"
|
||||
>
|
||||
مرحله بعدی
|
||||
</a-button>
|
||||
|
||||
<a-button
|
||||
v-else
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
:loading="submitting"
|
||||
@click="onSubmit"
|
||||
>
|
||||
{{ isEdit ? 'ذخیره تغییرات مدیر' : 'ایجاد مدیر جدید' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.m-form-page {
|
||||
padding: 14px 14px 28px;
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.m-steps {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.m-step {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 10px 4px;
|
||||
border-radius: 10px;
|
||||
background: #e2e8f0;
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.m-step.active {
|
||||
background: #4f46e5;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.m-step.done {
|
||||
background: #c7d2fe;
|
||||
color: #3730a3;
|
||||
}
|
||||
|
||||
.m-card {
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
padding: 18px 16px;
|
||||
border: 1px solid #f1f5f9;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.m-radio-full {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.m-radio-full :deep(.ant-radio-button-wrapper) {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.m-super-admin-card {
|
||||
background: #fdf4ff;
|
||||
border: 1px solid #f0abfc;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
margin-bottom: 16px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.m-super-admin-card.is-selected {
|
||||
background: #fae8ff;
|
||||
border-color: #d946ef;
|
||||
box-shadow: 0 0 0 2px rgba(217, 70, 239, 0.15);
|
||||
}
|
||||
|
||||
.m-super-title {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: #86198f;
|
||||
}
|
||||
|
||||
.m-super-desc {
|
||||
font-size: 11.5px;
|
||||
color: #701a75;
|
||||
margin: 6px 24px 0 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.m-section-label {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.m-roles-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.m-role-pill {
|
||||
padding: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.m-role-pill.is-active {
|
||||
border-color: #4f46e5;
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.m-role-pill-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.m-role-name {
|
||||
font-weight: 700;
|
||||
font-size: 13.5px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.m-role-desc {
|
||||
margin: 4px 28px 0 0;
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.m-form-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.m-form-actions :deep(.ant-btn) {
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,387 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CrownOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
KeyOutlined,
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
StopOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { toFarsiDigits } from '@lucky-wheel/shared-types';
|
||||
import { useAuthStore } from '../../stores/auth.store';
|
||||
import { apiClient } from '../../services/api/client';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const admins = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
const rolesList = ref<any[]>([]);
|
||||
|
||||
const filters = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
search: '',
|
||||
roleCode: undefined as string | undefined,
|
||||
status: undefined as string | undefined,
|
||||
});
|
||||
|
||||
const isDrawerOpen = ref(false);
|
||||
const editingAdminId = ref<string | null>(null);
|
||||
|
||||
const isPasswordModalOpen = ref(false);
|
||||
const resettingAdmin = ref<any>(null);
|
||||
const newPassword = ref('');
|
||||
const isSubmittingPassword = ref(false);
|
||||
|
||||
async function loadRoles() {
|
||||
try {
|
||||
const res = await apiClient.get('/admin/roles');
|
||||
rolesList.value = Array.isArray(res.data) ? res.data : res.data?.data || [];
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadAdmins() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get('/admin/managers', {
|
||||
params: {
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
search: filters.search || undefined,
|
||||
roleCode: filters.roleCode || undefined,
|
||||
status: filters.status || undefined,
|
||||
},
|
||||
});
|
||||
const items = Array.isArray(res.data) ? res.data : res.data?.data || [];
|
||||
admins.value = items;
|
||||
total.value = res.meta?.total || res.data?.meta?.total || items.length;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
router.push('/m/managers/create');
|
||||
}
|
||||
|
||||
function openEdit(admin: any) {
|
||||
router.push(`/m/managers/${admin.id}/edit`);
|
||||
}
|
||||
|
||||
function openResetPassword(admin: any) {
|
||||
resettingAdmin.value = admin;
|
||||
newPassword.value = '';
|
||||
isPasswordModalOpen.value = true;
|
||||
}
|
||||
|
||||
async function handleResetPassword() {
|
||||
if (!newPassword.value || newPassword.value.length < 6) {
|
||||
message.error('کلمه عبور باید حداقل ۶ کاراکتر باشد.');
|
||||
return;
|
||||
}
|
||||
isSubmittingPassword.value = true;
|
||||
try {
|
||||
await apiClient.patch(`/admin/managers/${resettingAdmin.value.id}/password`, {
|
||||
password: newPassword.value,
|
||||
});
|
||||
message.success('کلمه عبور با موفقیت تغییر یافت.');
|
||||
isPasswordModalOpen.value = false;
|
||||
} finally {
|
||||
isSubmittingPassword.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmToggleStatus(record: any) {
|
||||
const isCurrentlyActive = record.status === 'ACTIVE';
|
||||
const newStatus = isCurrentlyActive ? 'DISABLED' : 'ACTIVE';
|
||||
|
||||
Modal.confirm({
|
||||
title: isCurrentlyActive ? 'غیرفعالسازی مدیر' : 'فعالسازی مجدد مدیر',
|
||||
content: `آیا از تغییر وضعیت حساب ${record.name} اطمینان دارید؟`,
|
||||
okText: isCurrentlyActive ? 'غیرفعال کن' : 'فعال کن',
|
||||
okType: isCurrentlyActive ? 'danger' : 'primary',
|
||||
cancelText: 'انصراف',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await apiClient.patch(`/admin/managers/${record.id}/status`, { status: newStatus });
|
||||
message.success('وضعیت بهروزرسانی شد.');
|
||||
loadAdmins();
|
||||
} catch {}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function confirmDelete(record: any) {
|
||||
Modal.confirm({
|
||||
title: 'حذف مدیر',
|
||||
content: `آیا از حذف دائم ${record.name} اطمینان دارید؟`,
|
||||
okText: 'حذف',
|
||||
okType: 'danger',
|
||||
cancelText: 'انصراف',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await apiClient.delete(`/admin/managers/${record.id}`);
|
||||
message.success('مدیر حذف شد.');
|
||||
loadAdmins();
|
||||
} catch {}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadRoles();
|
||||
loadAdmins();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="m-admin-page">
|
||||
<!-- Top Bar with Search & Action -->
|
||||
<div class="m-search-box">
|
||||
<a-input-search
|
||||
v-model:value="filters.search"
|
||||
placeholder="جستجوی مدیر..."
|
||||
allowClear
|
||||
@search="() => { filters.page = 1; loadAdmins(); }"
|
||||
>
|
||||
<template #prefix><SearchOutlined style="color: #94a3b8" /></template>
|
||||
</a-input-search>
|
||||
</div>
|
||||
|
||||
<div class="m-add-bar" v-if="authStore.can('admins:manage')">
|
||||
<a-button type="primary" block size="large" @click="openCreate">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
افزودن مدیر جدید
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- Cards List -->
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="admins.length === 0 && !loading" class="m-empty">
|
||||
مدیری یافت نشد.
|
||||
</div>
|
||||
|
||||
<div class="m-cards-list">
|
||||
<div v-for="admin in admins" :key="admin.id" class="m-admin-card">
|
||||
<div class="m-card-header">
|
||||
<div class="m-user-info">
|
||||
<a-avatar :style="{ backgroundColor: admin.isSuperAdmin ? '#7c3aed' : '#2563eb' }">
|
||||
{{ admin.name?.charAt(0) || 'U' }}
|
||||
</a-avatar>
|
||||
<div class="m-name-box">
|
||||
<span class="m-name">
|
||||
{{ admin.name }}
|
||||
<CrownOutlined v-if="admin.isSuperAdmin" style="color: #f59e0b; margin-right: 4px" />
|
||||
</span>
|
||||
<span class="m-email">{{ admin.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-tag :color="admin.status === 'DISABLED' ? 'red' : 'green'" class="m-status-tag">
|
||||
{{ admin.status === 'DISABLED' ? 'غیرفعال' : 'فعال' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
|
||||
<div class="m-card-body">
|
||||
<div class="m-info-row">
|
||||
<span class="m-info-label">شماره موبایل:</span>
|
||||
<span class="m-info-val">{{ toFarsiDigits(admin.mobile) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="m-info-row">
|
||||
<span class="m-info-label">نقشها:</span>
|
||||
<div class="m-tags-wrap">
|
||||
<a-tag v-if="admin.isSuperAdmin" color="purple" size="small">مدیر ارشد</a-tag>
|
||||
<template v-if="admin.roles && admin.roles.length > 0">
|
||||
<a-tag v-for="ra in admin.roles" :key="ra.role?.id" color="blue" size="small">
|
||||
{{ ra.role?.name || ra.role?.code }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<span v-else-if="!admin.isSuperAdmin" style="color: #94a3b8; font-size: 11px">بدون نقش</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m-info-row">
|
||||
<span class="m-info-label">فضای کاری:</span>
|
||||
<div class="m-tags-wrap">
|
||||
<template v-if="admin.workspaceAssignments && admin.workspaceAssignments.length > 0">
|
||||
<a-tag v-for="(wa, i) in admin.workspaceAssignments" :key="i" color="cyan" size="small">
|
||||
{{ wa.workspace?.name || 'اصلی' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<span v-else style="color: #94a3b8; font-size: 11px">سراسری</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="m-card-actions" v-if="authStore.can('admins:manage')">
|
||||
<a-button type="default" size="small" @click="openEdit(admin)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
ویرایش
|
||||
</a-button>
|
||||
|
||||
<a-button type="default" size="small" @click="openResetPassword(admin)">
|
||||
<template #icon><KeyOutlined /></template>
|
||||
رمز
|
||||
</a-button>
|
||||
|
||||
<template v-if="admin.id !== authStore.admin?.id">
|
||||
<a-button
|
||||
:danger="admin.status === 'ACTIVE'"
|
||||
size="small"
|
||||
@click="confirmToggleStatus(admin)"
|
||||
>
|
||||
<template #icon>
|
||||
<StopOutlined v-if="admin.status === 'ACTIVE'" />
|
||||
<CheckCircleOutlined v-else />
|
||||
</template>
|
||||
{{ admin.status === 'ACTIVE' ? 'غیرفعال' : 'فعال' }}
|
||||
</a-button>
|
||||
|
||||
<a-button type="text" danger size="small" @click="confirmDelete(admin)">
|
||||
<DeleteOutlined />
|
||||
</a-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="m-pagination" v-if="total > filters.pageSize">
|
||||
<a-pagination
|
||||
v-model:current="filters.page"
|
||||
:pageSize="filters.pageSize"
|
||||
:total="total"
|
||||
size="small"
|
||||
simple
|
||||
@change="loadAdmins"
|
||||
/>
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
|
||||
|
||||
<a-modal
|
||||
v-model:open="isPasswordModalOpen"
|
||||
title="تغییر کلمه عبور مدیر"
|
||||
okText="تغییر"
|
||||
cancelText="انصراف"
|
||||
:confirmLoading="isSubmittingPassword"
|
||||
@ok="handleResetPassword"
|
||||
>
|
||||
<a-input-password
|
||||
v-model:value="newPassword"
|
||||
placeholder="کلمه عبور جدید (حداقل ۶ کاراکتر)"
|
||||
size="large"
|
||||
dir="ltr"
|
||||
style="margin-top: 12px"
|
||||
/>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.m-admin-page {
|
||||
padding: 12px;
|
||||
}
|
||||
.m-search-box {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.m-add-bar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.m-cards-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.m-admin-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 12px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.m-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.m-user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.m-name-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.m-name {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
}
|
||||
.m-email {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
direction: ltr;
|
||||
}
|
||||
.m-status-tag {
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.m-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.m-info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.m-info-label {
|
||||
color: #64748b;
|
||||
}
|
||||
.m-info-val {
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
direction: ltr;
|
||||
}
|
||||
.m-tags-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
}
|
||||
.m-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
}
|
||||
.m-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.m-empty {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function goHome() {
|
||||
router.push('/m/dashboard');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="m-forbidden-page">
|
||||
<a-result
|
||||
status="403"
|
||||
title="۴۰۳ — دسترسی غیرمجاز"
|
||||
sub-title="شما مجوز دسترسی به این بخش را ندارید."
|
||||
>
|
||||
<template #extra>
|
||||
<a-button type="primary" block @click="goHome">
|
||||
بازگشت به داشبورد
|
||||
</a-button>
|
||||
</template>
|
||||
</a-result>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.m-forbidden-page {
|
||||
padding: 32px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,304 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
PlusOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
SaveOutlined,
|
||||
TeamOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { message } 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);
|
||||
|
||||
const activeRolePermIds = ref<string[]>([]);
|
||||
const isRoleDirty = ref(false);
|
||||
|
||||
const isCreateModalOpen = ref(false);
|
||||
const isSubmittingNewRole = ref(false);
|
||||
const newRoleForm = reactive({
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
permissionIds: [] as string[],
|
||||
});
|
||||
|
||||
const selectedRole = computed(() => {
|
||||
return roles.value.find((r) => r.id === selectedRoleId.value) || null;
|
||||
});
|
||||
|
||||
const moduleLabels: Record<string, string> = {
|
||||
campaigns: 'کمپینها',
|
||||
prizes: 'جوایز',
|
||||
users: 'کاربران',
|
||||
chances: 'شانسها',
|
||||
spins: 'اسپینها',
|
||||
winners: 'برندگان',
|
||||
sms: 'پیامک',
|
||||
audit: 'بازرسی',
|
||||
reports: 'گزارشها',
|
||||
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?.code === 'SUPER_ADMIN') return;
|
||||
|
||||
const idx = activeRolePermIds.value.indexOf(permId);
|
||||
if (idx > -1) {
|
||||
activeRolePermIds.value.splice(idx, 1);
|
||||
} else {
|
||||
activeRolePermIds.value.push(permId);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateRole() {
|
||||
if (!newRoleForm.name.trim() || !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;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="m-roles-page">
|
||||
<!-- Select role dropdown -->
|
||||
<div class="m-role-selector-bar">
|
||||
<div style="flex: 1">
|
||||
<a-select
|
||||
v-model:value="selectedRoleId"
|
||||
style="width: 100%"
|
||||
size="large"
|
||||
@change="selectRole(selectedRoleId!)"
|
||||
>
|
||||
<a-select-option v-for="r in roles" :key="r.id" :value="r.id">
|
||||
{{ r.name }} ({{ toFarsiDigits(r._count?.admins ?? 0) }} نفر)
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
|
||||
<a-button
|
||||
v-if="authStore.can('roles:manage')"
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="isCreateModalOpen = true"
|
||||
>
|
||||
<template #icon><PlusOutlined /></template>
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- Save button if dirty -->
|
||||
<div class="m-save-bar" v-if="isRoleDirty && !selectedRole?.isSystem">
|
||||
<a-button type="primary" block size="large" :loading="saving" @click="saveRolePermissions">
|
||||
<template #icon><SaveOutlined /></template>
|
||||
ذخیره تغییرات مجوزها
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- Super admin note -->
|
||||
<div v-if="selectedRole?.code === 'SUPER_ADMIN'" class="m-notice">
|
||||
<SafetyCertificateOutlined style="color: #7c3aed; font-size: 18px" />
|
||||
<span>دسترسیهای مدیر ارشد دائمی و کامل است.</span>
|
||||
</div>
|
||||
|
||||
<!-- Permissions accordion/list -->
|
||||
<a-spin :spinning="loading">
|
||||
<div class="m-modules-list">
|
||||
<div v-for="(perms, mod) in groupedPermissions" :key="mod" class="m-mod-card">
|
||||
<div class="m-mod-title">
|
||||
<strong>{{ moduleLabels[mod] || mod }}</strong>
|
||||
<span class="m-mod-count">
|
||||
{{ toFarsiDigits(perms.filter((p) => activeRolePermIds.includes(p.id)).length) }}/{{ toFarsiDigits(perms.length) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="m-mod-perms">
|
||||
<div
|
||||
v-for="p in perms"
|
||||
:key="p.id"
|
||||
class="m-perm-row"
|
||||
:class="{ 'is-selected': activeRolePermIds.includes(p.id) }"
|
||||
@click="togglePermission(p.id)"
|
||||
>
|
||||
<a-checkbox
|
||||
:checked="activeRolePermIds.includes(p.id)"
|
||||
:disabled="selectedRole?.code === 'SUPER_ADMIN' || !authStore.can('roles:manage')"
|
||||
/>
|
||||
<span class="m-p-name">{{ p.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<a-modal
|
||||
v-model:open="isCreateModalOpen"
|
||||
title="تعریف نقش جدید"
|
||||
okText="ایجاد"
|
||||
cancelText="انصراف"
|
||||
:confirmLoading="isSubmittingNewRole"
|
||||
@ok="handleCreateRole"
|
||||
>
|
||||
<a-form layout="vertical" style="margin-top: 12px">
|
||||
<a-form-item label="نام نقش" required>
|
||||
<a-input v-model:value="newRoleForm.name" placeholder="مثال: سرپرست بازاریابی" />
|
||||
</a-form-item>
|
||||
<a-form-item label="کد انگلیسی" required>
|
||||
<a-input v-model:value="newRoleForm.code" placeholder="MARKETING_LEAD" dir="ltr" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.m-roles-page {
|
||||
padding: 12px;
|
||||
}
|
||||
.m-role-selector-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.m-save-bar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.m-notice {
|
||||
background: #f5f3ff;
|
||||
border: 1px solid #ddd6fe;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.m-modules-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.m-mod-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.m-mod-title {
|
||||
background: #f8fafc;
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.m-mod-count {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
.m-mod-perms {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
.m-perm-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f8fafc;
|
||||
}
|
||||
.m-perm-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.m-p-name {
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,205 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
ProjectOutlined,
|
||||
TeamOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { 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 workspaces = ref<any[]>([]);
|
||||
|
||||
const isModalOpen = ref(false);
|
||||
const isEditing = ref(false);
|
||||
const editingWorkspaceId = ref<string | null>(null);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const formState = reactive({
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
async function loadWorkspaces() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get('/admin/workspaces');
|
||||
workspaces.value = Array.isArray(res.data) ? res.data : res.data?.data || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
isEditing.value = false;
|
||||
editingWorkspaceId.value = null;
|
||||
formState.name = '';
|
||||
formState.slug = '';
|
||||
formState.description = '';
|
||||
isModalOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(ws: any) {
|
||||
isEditing.value = true;
|
||||
editingWorkspaceId.value = ws.id;
|
||||
formState.name = ws.name;
|
||||
formState.slug = ws.slug;
|
||||
formState.description = ws.description || '';
|
||||
isModalOpen.value = true;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!formState.name.trim()) {
|
||||
message.error('نام فضای کاری الزامی است.');
|
||||
return;
|
||||
}
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
if (isEditing.value && editingWorkspaceId.value) {
|
||||
await apiClient.patch(`/admin/workspaces/${editingWorkspaceId.value}`, {
|
||||
name: formState.name.trim(),
|
||||
description: formState.description?.trim() || undefined,
|
||||
});
|
||||
message.success('بهروزرسانی شد.');
|
||||
} else {
|
||||
await apiClient.post('/admin/workspaces', {
|
||||
name: formState.name.trim(),
|
||||
slug: formState.slug.trim().toLowerCase(),
|
||||
description: formState.description?.trim() || undefined,
|
||||
});
|
||||
message.success('فضای کاری ایجاد شد.');
|
||||
}
|
||||
isModalOpen.value = false;
|
||||
loadWorkspaces();
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadWorkspaces();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="m-workspaces-page">
|
||||
<div class="m-add-bar" v-if="authStore.can('workspaces:manage')">
|
||||
<a-button type="primary" block size="large" @click="openCreate">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
تعریف فضای کاری جدید
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div class="m-ws-list">
|
||||
<div v-for="ws in workspaces" :key="ws.id" class="m-ws-card">
|
||||
<div class="m-ws-header">
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<ApartmentOutlined style="color: #2563eb; font-size: 16px" />
|
||||
<strong>{{ ws.name }}</strong>
|
||||
</div>
|
||||
|
||||
<a-button
|
||||
v-if="authStore.can('workspaces:manage')"
|
||||
type="text"
|
||||
size="small"
|
||||
@click="openEdit(ws)"
|
||||
>
|
||||
<EditOutlined style="color: #2563eb" />
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div class="m-ws-slug">
|
||||
{{ ws.slug }}
|
||||
</div>
|
||||
|
||||
<div class="m-ws-desc" v-if="ws.description">
|
||||
{{ ws.description }}
|
||||
</div>
|
||||
|
||||
<div class="m-ws-footer">
|
||||
<a-tag color="purple" size="small">
|
||||
<ProjectOutlined style="margin-left: 4px" />
|
||||
{{ toFarsiDigits(ws._count?.campaigns ?? 0) }} کمپین
|
||||
</a-tag>
|
||||
|
||||
<a-tag color="cyan" size="small">
|
||||
<TeamOutlined style="margin-left: 4px" />
|
||||
{{ toFarsiDigits(ws._count?.adminAssignments ?? 0) }} مدیر
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<a-modal
|
||||
v-model:open="isModalOpen"
|
||||
:title="isEditing ? 'ویرایش فضای کاری' : 'فضای کاری جدید'"
|
||||
okText="ذخیره"
|
||||
cancelText="انصراف"
|
||||
:confirmLoading="isSubmitting"
|
||||
@ok="onSubmit"
|
||||
>
|
||||
<a-form layout="vertical" style="margin-top: 12px">
|
||||
<a-form-item label="نام فضای کاری" required>
|
||||
<a-input v-model:value="formState.name" placeholder="مثال: شعبه مشهد" />
|
||||
</a-form-item>
|
||||
<a-form-item label="شناسه انگلیسی (Slug)" :required="!isEditing">
|
||||
<a-input v-model:value="formState.slug" placeholder="mashhad-branch" dir="ltr" :disabled="isEditing" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.m-workspaces-page {
|
||||
padding: 12px;
|
||||
}
|
||||
.m-add-bar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.m-ws-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.m-ws-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
.m-ws-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.m-ws-slug {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.m-ws-desc {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.m-ws-footer {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px dashed #f1f5f9;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,558 @@
|
||||
<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>
|
||||
@@ -0,0 +1,260 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
ProjectOutlined,
|
||||
TeamOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { 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 workspaces = ref<any[]>([]);
|
||||
|
||||
// Modal state
|
||||
const isModalOpen = ref(false);
|
||||
const isEditing = ref(false);
|
||||
const editingWorkspaceId = ref<string | null>(null);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const formState = reactive({
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
async function loadWorkspaces() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get('/admin/workspaces');
|
||||
workspaces.value = Array.isArray(res.data) ? res.data : res.data?.data || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
isEditing.value = false;
|
||||
editingWorkspaceId.value = null;
|
||||
formState.name = '';
|
||||
formState.slug = '';
|
||||
formState.description = '';
|
||||
isModalOpen.value = true;
|
||||
}
|
||||
|
||||
function openEditModal(record: any) {
|
||||
isEditing.value = true;
|
||||
editingWorkspaceId.value = record.id;
|
||||
formState.name = record.name;
|
||||
formState.slug = record.slug;
|
||||
formState.description = record.description || '';
|
||||
isModalOpen.value = true;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!formState.name.trim()) {
|
||||
message.error('نام فضای کاری الزامی است.');
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
if (isEditing.value && editingWorkspaceId.value) {
|
||||
await apiClient.patch(`/admin/workspaces/${editingWorkspaceId.value}`, {
|
||||
name: formState.name.trim(),
|
||||
description: formState.description?.trim() || undefined,
|
||||
});
|
||||
message.success('مشخصات فضای کاری بهروزرسانی شد.');
|
||||
} else {
|
||||
if (!formState.slug.trim()) {
|
||||
message.error('شناسه انگلیسی (اسلاگ) الزامی است.');
|
||||
return;
|
||||
}
|
||||
await apiClient.post('/admin/workspaces', {
|
||||
name: formState.name.trim(),
|
||||
slug: formState.slug.trim().toLowerCase(),
|
||||
description: formState.description?.trim() || undefined,
|
||||
});
|
||||
message.success('فضای کاری جدید با موفقیت ایجاد شد.');
|
||||
}
|
||||
isModalOpen.value = false;
|
||||
loadWorkspaces();
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: 'نام فضای کاری', dataIndex: 'name', key: 'name' },
|
||||
{ title: 'شناسه یکتا (Slug)', dataIndex: 'slug', key: 'slug' },
|
||||
{ title: 'توضیحات', dataIndex: 'description', key: 'description' },
|
||||
{ title: 'تعداد کمپینها', key: 'campaignsCount', align: 'center' as const },
|
||||
{ title: 'مدیران منتسب', key: 'adminsCount', align: 'center' as const },
|
||||
{ title: 'تاریخ ایجاد', dataIndex: 'createdAt', key: 'createdAt' },
|
||||
{ title: 'عملیات', key: 'actions', width: 80, align: 'center' as const },
|
||||
];
|
||||
|
||||
onMounted(() => {
|
||||
loadWorkspaces();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="workspaces-page">
|
||||
<div class="page-header-title">
|
||||
<div>
|
||||
<h1 class="title">مدیریت فضاهای کاری سازمانی</h1>
|
||||
<p class="subtitle">تعریف سازمانها، شعب یا برندها جهت تفکیک کمپینها و اسکوپ دسترسی مدیران</p>
|
||||
</div>
|
||||
|
||||
<div class="page-actions" v-if="authStore.can('workspaces:manage')">
|
||||
<a-button type="primary" size="large" @click="openCreateModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
تعریف فضای کاری جدید
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-card :bordered="false" class="main-card">
|
||||
<a-table
|
||||
:dataSource="workspaces"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
rowKey="id"
|
||||
size="middle"
|
||||
:pagination="false"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<ApartmentOutlined style="color: #2563eb; font-size: 16px" />
|
||||
<strong>{{ record.name }}</strong>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'slug'">
|
||||
<a-tag color="blue" dir="ltr" style="font-family: monospace">
|
||||
{{ record.slug }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'description'">
|
||||
<span style="color: #64748b; font-size: 13px">
|
||||
{{ record.description || '—' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'campaignsCount'">
|
||||
<a-tag color="purple" style="border-radius: 6px">
|
||||
<ProjectOutlined style="margin-left: 4px" />
|
||||
{{ toFarsiDigits(record._count?.campaigns ?? 0) }} کمپین
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'adminsCount'">
|
||||
<a-tag color="cyan" style="border-radius: 6px">
|
||||
<TeamOutlined style="margin-left: 4px" />
|
||||
{{ toFarsiDigits(record._count?.adminAssignments ?? 0) }} مدیر
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
<span class="date-cell">
|
||||
{{ toFarsiDigits(new Date(record.createdAt).toLocaleDateString('fa-IR')) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-button
|
||||
v-if="authStore.can('workspaces:manage')"
|
||||
type="text"
|
||||
size="small"
|
||||
@click="openEditModal(record)"
|
||||
>
|
||||
<EditOutlined style="color: #2563eb" />
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- Create / Edit Modal -->
|
||||
<a-modal
|
||||
v-model:open="isModalOpen"
|
||||
:title="isEditing ? 'ویرایش فضای کاری' : 'تعریف فضای کاری جدید'"
|
||||
okText="ذخیره"
|
||||
cancelText="انصراف"
|
||||
:confirmLoading="isSubmitting"
|
||||
@ok="onSubmit"
|
||||
>
|
||||
<a-form layout="vertical" style="margin-top: 16px">
|
||||
<a-form-item label="نام فضای کاری (فارسی)" required>
|
||||
<a-input
|
||||
v-model:value="formState.name"
|
||||
placeholder="مثال: شعبه دیجیتال دیجیکالا"
|
||||
size="large"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="شناسه انگلیسی یکتا (Slug)" :required="!isEditing">
|
||||
<a-input
|
||||
v-model:value="formState.slug"
|
||||
placeholder="digikala-branch"
|
||||
dir="ltr"
|
||||
size="large"
|
||||
:disabled="isEditing"
|
||||
/>
|
||||
<span v-if="isEditing" style="font-size: 11px; color: #94a3b8">
|
||||
شناسه یکتای فضای کاری پس از ایجاد قابل ویرایش نیست.
|
||||
</span>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="توضیحات اختیاری">
|
||||
<a-textarea
|
||||
v-model:value="formState.description"
|
||||
placeholder="توضیحات کوتاه در مورد این فضای کاری..."
|
||||
:rows="3"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workspaces-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;
|
||||
}
|
||||
.main-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.date-cell {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AccessScopeType" AS ENUM ('ALL', 'CUSTOM_CAMPAIGNS');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "admins" ADD COLUMN "is_super_admin" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "last_login_at" TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "campaigns" ADD COLUMN "workspace_id" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "workspaces" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "workspaces_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "roles" (
|
||||
"id" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"is_system" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "permissions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"module" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
|
||||
CONSTRAINT "permissions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "role_permissions" (
|
||||
"role_id" TEXT NOT NULL,
|
||||
"permission_id" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("role_id","permission_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "admin_role_assignments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"admin_id" TEXT NOT NULL,
|
||||
"role_id" TEXT NOT NULL,
|
||||
"assigned_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "admin_role_assignments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "admin_workspace_assignments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"admin_id" TEXT NOT NULL,
|
||||
"workspace_id" TEXT NOT NULL,
|
||||
"scope_type" "AccessScopeType" NOT NULL DEFAULT 'ALL',
|
||||
"specific_campaign_ids" JSONB,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "admin_workspace_assignments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "workspaces_slug_key" ON "workspaces"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "roles_code_key" ON "roles"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "permissions_code_key" ON "permissions"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "permissions_module_idx" ON "permissions"("module");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "admin_role_assignments_admin_id_role_id_key" ON "admin_role_assignments"("admin_id", "role_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "admin_workspace_assignments_admin_id_workspace_id_key" ON "admin_workspace_assignments"("admin_id", "workspace_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "campaigns_workspace_id_idx" ON "campaigns"("workspace_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "campaigns" ADD CONSTRAINT "campaigns_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_permission_id_fkey" FOREIGN KEY ("permission_id") REFERENCES "permissions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "admin_role_assignments" ADD CONSTRAINT "admin_role_assignments_admin_id_fkey" FOREIGN KEY ("admin_id") REFERENCES "admins"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "admin_role_assignments" ADD CONSTRAINT "admin_role_assignments_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "admin_workspace_assignments" ADD CONSTRAINT "admin_workspace_assignments_admin_id_fkey" FOREIGN KEY ("admin_id") REFERENCES "admins"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "admin_workspace_assignments" ADD CONSTRAINT "admin_workspace_assignments_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -21,6 +21,11 @@ enum AdminRole {
|
||||
SUPPORT
|
||||
}
|
||||
|
||||
enum AccessScopeType {
|
||||
ALL
|
||||
CUSTOM_CAMPAIGNS
|
||||
}
|
||||
|
||||
enum UserStatus {
|
||||
ACTIVE
|
||||
BLOCKED
|
||||
@@ -111,11 +116,15 @@ model Admin {
|
||||
passwordHash String @map("password_hash")
|
||||
role AdminRole @default(OPERATOR)
|
||||
status AdminStatus @default(ACTIVE)
|
||||
isSuperAdmin Boolean @default(false) @map("is_super_admin")
|
||||
lastLoginAt DateTime? @map("last_login_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
auditLogs AdminLog[]
|
||||
chanceTxns ChanceTransaction[] @relation("AdminCreatedTxn")
|
||||
auditLogs AdminLog[]
|
||||
chanceTxns ChanceTransaction[] @relation("AdminCreatedTxn")
|
||||
roles AdminRoleAssignment[]
|
||||
workspaceAssignments AdminWorkspaceAssignment[]
|
||||
|
||||
@@map("admins")
|
||||
}
|
||||
@@ -171,6 +180,8 @@ model Campaign {
|
||||
ctaText String? @map("cta_text")
|
||||
wheelDesign WheelDesign @default(CLASSIC_CASINO) @map("wheel_design")
|
||||
settings Json? // primaryColor, secondaryColor, headerText, buttonText, headerImage, celebrationEffect...
|
||||
workspaceId String? @map("workspace_id")
|
||||
workspace Workspace? @relation(fields: [workspaceId], references: [id])
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -181,6 +192,7 @@ model Campaign {
|
||||
userPrizes UserPrize[]
|
||||
|
||||
@@index([status, startAt, endAt])
|
||||
@@index([workspaceId])
|
||||
@@map("campaigns")
|
||||
}
|
||||
|
||||
@@ -317,3 +329,84 @@ model AdminLog {
|
||||
@@index([entityType, entityId])
|
||||
@@map("admin_logs")
|
||||
}
|
||||
|
||||
// ==================== WORKSPACE & RBAC MODELS ====================
|
||||
|
||||
model Workspace {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
slug String @unique
|
||||
description String?
|
||||
status String @default("ACTIVE")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
campaigns Campaign[]
|
||||
adminAssignments AdminWorkspaceAssignment[]
|
||||
|
||||
@@map("workspaces")
|
||||
}
|
||||
|
||||
model Role {
|
||||
id String @id @default(uuid())
|
||||
code String @unique // e.g. "SUPER_ADMIN", "OPERATOR", "SUPPORT"
|
||||
name String // نام فارسی
|
||||
description String?
|
||||
isSystem Boolean @default(false) @map("is_system")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
permissions RolePermission[]
|
||||
admins AdminRoleAssignment[]
|
||||
|
||||
@@map("roles")
|
||||
}
|
||||
|
||||
model Permission {
|
||||
id String @id @default(uuid())
|
||||
code String @unique // e.g. "campaigns:read", "campaigns:create"
|
||||
module String // e.g. "campaigns", "users", "prizes", "reports", "admins"
|
||||
name String // نام فارسی
|
||||
description String?
|
||||
|
||||
roles RolePermission[]
|
||||
|
||||
@@index([module])
|
||||
@@map("permissions")
|
||||
}
|
||||
|
||||
model RolePermission {
|
||||
roleId String @map("role_id")
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
permissionId String @map("permission_id")
|
||||
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([roleId, permissionId])
|
||||
@@map("role_permissions")
|
||||
}
|
||||
|
||||
model AdminRoleAssignment {
|
||||
id String @id @default(uuid())
|
||||
adminId String @map("admin_id")
|
||||
admin Admin @relation(fields: [adminId], references: [id], onDelete: Cascade)
|
||||
roleId String @map("role_id")
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
assignedAt DateTime @default(now()) @map("assigned_at")
|
||||
|
||||
@@unique([adminId, roleId])
|
||||
@@map("admin_role_assignments")
|
||||
}
|
||||
|
||||
model AdminWorkspaceAssignment {
|
||||
id String @id @default(uuid())
|
||||
adminId String @map("admin_id")
|
||||
admin Admin @relation(fields: [adminId], references: [id], onDelete: Cascade)
|
||||
workspaceId String @map("workspace_id")
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
scopeType AccessScopeType @default(ALL) @map("scope_type")
|
||||
specificCampaignIds Json? @map("specific_campaign_ids")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@unique([adminId, workspaceId])
|
||||
@@map("admin_workspace_assignments")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { AccessScopeType, PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const PERMISSIONS = [
|
||||
// Campaigns
|
||||
{ code: 'campaigns:read', module: 'campaigns', name: 'مشاهده کمپینها', description: 'مشاهده لیست و جزئیات کمپینها' },
|
||||
{ code: 'campaigns:create', module: 'campaigns', name: 'ایجاد کمپین', description: 'ثبت کمپین جدید' },
|
||||
{ code: 'campaigns:update', module: 'campaigns', name: 'ویرایش کمپین', description: 'تغییر تنظیمات و وضعیت کمپین' },
|
||||
{ code: 'campaigns:delete', module: 'campaigns', name: 'حذف کمپین', description: 'حذف کمپین از سامانه' },
|
||||
|
||||
// Prizes
|
||||
{ code: 'prizes:read', module: 'prizes', name: 'مشاهده جوایز', description: 'مشاهده لیست جوایز کمپینها' },
|
||||
{ code: 'prizes:manage', module: 'prizes', name: 'مدیریت جوایز', description: 'افزودن، ویرایش و تنظیم ظرفیت جوایز' },
|
||||
|
||||
// Users & Chances
|
||||
{ code: 'users:read', module: 'users', name: 'مشاهده کاربران', description: 'مشاهده لیست کاربران شرکتکننده و پروفایل' },
|
||||
{ code: 'users:block', module: 'users', name: 'مسدودسازی کاربران', description: 'بلاک یا آنبلاک کردن کاربران' },
|
||||
{ code: 'chances:adjust', module: 'chances', name: 'تنظیم دستی شانس', description: 'افزایش یا کاهش شانس با ثبت لاگ' },
|
||||
|
||||
// Winners & Spins
|
||||
{ code: 'spins:read', module: 'spins', name: 'مشاهده لاگ چرخشها', description: 'بررسی تاریخچه اسپینهای کاربران' },
|
||||
{ code: 'winners:read', module: 'winners', name: 'مشاهده برندگان', description: 'مشاهده لیست برندگان و کدهای هدیه' },
|
||||
{ code: 'winners:manage', module: 'winners', name: 'مدیریت وضعیت جوایز', description: 'تغییر وضعیت کدهای جایزه به تحویلشده و...' },
|
||||
|
||||
// SMS & Logs
|
||||
{ code: 'sms:read', module: 'sms', name: 'مشاهده لاگ پیامک', description: 'مشاهده پیامکهای ارسالی OTP و برندگان' },
|
||||
{ code: 'sms:resend', module: 'sms', name: 'ارسال مجدد پیامک', description: 'ارسال دوباره پیامکهای ناموفق' },
|
||||
{ code: 'audit:read', module: 'audit', name: 'مشاهده لاگ بازرسی', description: 'مشاهده ردپای عملیات و تغییرات مدیران' },
|
||||
|
||||
// Reports
|
||||
{ code: 'reports:read', module: 'reports', name: 'مشاهده گزارشها و آمار', description: 'مشاهده نمودارها، آمار زنده و داشبورد' },
|
||||
{ code: 'reports:export', module: 'reports', name: 'خروجی دادهها', description: 'دریافت خروجی اکسل و CSV گزارشها' },
|
||||
|
||||
// Access Control & Admins
|
||||
{ code: 'admins:read', module: 'admins', name: 'مشاهده مدیران', description: 'مشاهده لیست مدیران و همکاران' },
|
||||
{ code: 'admins:manage', module: 'admins', name: 'مدیریت مدیران', description: 'ایجاد، ویرایش، تغییر رمز و غیرفعالسازی مدیران' },
|
||||
{ code: 'roles:manage', module: 'roles', name: 'مدیریت نقشها و مجوزها', description: 'تعریف نقش جدید و تخصیص مجوزها' },
|
||||
{ code: 'workspaces:manage', module: 'workspaces', name: 'مدیریت فضاهای کاری', description: 'تعریف و مدیریت فضاهای کاری سازمانی' },
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log('Seeding RBAC and Workspaces...');
|
||||
|
||||
// 1. Ensure Default Workspace exists
|
||||
let defaultWorkspace = await prisma.workspace.findUnique({
|
||||
where: { slug: 'default-workspace' },
|
||||
});
|
||||
|
||||
if (!defaultWorkspace) {
|
||||
defaultWorkspace = await prisma.workspace.create({
|
||||
data: {
|
||||
name: 'فضای کاری اصلی',
|
||||
slug: 'default-workspace',
|
||||
description: 'فضای کاری پیشفرض و سراسری سیستم گردونه شانس',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
console.log('Created default workspace:', defaultWorkspace.id);
|
||||
}
|
||||
|
||||
// Link any unassigned campaigns to default workspace
|
||||
await prisma.campaign.updateMany({
|
||||
where: { workspaceId: null },
|
||||
data: { workspaceId: defaultWorkspace.id },
|
||||
});
|
||||
|
||||
// 2. Seed Permissions
|
||||
const permissionMap = new Map<string, string>();
|
||||
for (const perm of PERMISSIONS) {
|
||||
const p = await prisma.permission.upsert({
|
||||
where: { code: perm.code },
|
||||
update: {
|
||||
module: perm.module,
|
||||
name: perm.name,
|
||||
description: perm.description,
|
||||
},
|
||||
create: {
|
||||
code: perm.code,
|
||||
module: perm.module,
|
||||
name: perm.name,
|
||||
description: perm.description,
|
||||
},
|
||||
});
|
||||
permissionMap.set(p.code, p.id);
|
||||
}
|
||||
console.log(`Seeded ${permissionMap.size} permissions.`);
|
||||
|
||||
// 3. Seed Roles
|
||||
// SUPER_ADMIN (All permissions)
|
||||
const superAdminRole = await prisma.role.upsert({
|
||||
where: { code: 'SUPER_ADMIN' },
|
||||
update: {
|
||||
name: 'مدیر ارشد سامانه',
|
||||
description: 'دسترسی کامل و بدون محدودیت به تمامی بخشها و تنظیمات سامانه',
|
||||
isSystem: true,
|
||||
},
|
||||
create: {
|
||||
code: 'SUPER_ADMIN',
|
||||
name: 'مدیر ارشد سامانه',
|
||||
description: 'دسترسی کامل و بدون محدودیت به تمامی بخشها و تنظیمات سامانه',
|
||||
isSystem: true,
|
||||
},
|
||||
});
|
||||
|
||||
// OPERATOR (Operational permissions)
|
||||
const operatorRole = await prisma.role.upsert({
|
||||
where: { code: 'OPERATOR' },
|
||||
update: {
|
||||
name: 'اپراتور سامانه',
|
||||
description: 'مدیریت کمپینها، جوایز، شانسها، برندگان و کاربران بدون دسترسی به مدیریت ادمینها',
|
||||
isSystem: true,
|
||||
},
|
||||
create: {
|
||||
code: 'OPERATOR',
|
||||
name: 'اپراتور سامانه',
|
||||
description: 'مدیریت کمپینها، جوایز، شانسها، برندگان و کاربران بدون دسترسی به مدیریت ادمینها',
|
||||
isSystem: true,
|
||||
},
|
||||
});
|
||||
|
||||
// SUPPORT (Read-only + winner management)
|
||||
const supportRole = await prisma.role.upsert({
|
||||
where: { code: 'SUPPORT' },
|
||||
update: {
|
||||
name: 'کارشناس پشتیبانی',
|
||||
description: 'مشاهده کاربران، برندگان، پیامکها و تغییر وضعیت تحویل جوایز',
|
||||
isSystem: true,
|
||||
},
|
||||
create: {
|
||||
code: 'SUPPORT',
|
||||
name: 'کارشناس پشتیبانی',
|
||||
description: 'مشاهده کاربران، برندگان، پیامکها و تغییر وضعیت تحویل جوایز',
|
||||
isSystem: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Assign permissions to roles
|
||||
// SUPER_ADMIN gets all permissions
|
||||
for (const permId of permissionMap.values()) {
|
||||
await prisma.rolePermission.upsert({
|
||||
where: {
|
||||
roleId_permissionId: {
|
||||
roleId: superAdminRole.id,
|
||||
permissionId: permId,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
roleId: superAdminRole.id,
|
||||
permissionId: permId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// OPERATOR permissions
|
||||
const operatorPermCodes = [
|
||||
'campaigns:read', 'campaigns:create', 'campaigns:update', 'campaigns:delete',
|
||||
'prizes:read', 'prizes:manage',
|
||||
'users:read', 'users:block', 'chances:adjust',
|
||||
'spins:read', 'winners:read', 'winners:manage',
|
||||
'sms:read', 'sms:resend',
|
||||
'reports:read', 'reports:export',
|
||||
'audit:read',
|
||||
];
|
||||
for (const code of operatorPermCodes) {
|
||||
const permId = permissionMap.get(code);
|
||||
if (permId) {
|
||||
await prisma.rolePermission.upsert({
|
||||
where: {
|
||||
roleId_permissionId: {
|
||||
roleId: operatorRole.id,
|
||||
permissionId: permId,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
roleId: operatorRole.id,
|
||||
permissionId: permId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// SUPPORT permissions
|
||||
const supportPermCodes = [
|
||||
'campaigns:read',
|
||||
'prizes:read',
|
||||
'users:read',
|
||||
'spins:read',
|
||||
'winners:read', 'winners:manage',
|
||||
'sms:read',
|
||||
];
|
||||
for (const code of supportPermCodes) {
|
||||
const permId = permissionMap.get(code);
|
||||
if (permId) {
|
||||
await prisma.rolePermission.upsert({
|
||||
where: {
|
||||
roleId_permissionId: {
|
||||
roleId: supportRole.id,
|
||||
permissionId: permId,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
roleId: supportRole.id,
|
||||
permissionId: permId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Backfill existing admins
|
||||
const admins = await prisma.admin.findMany();
|
||||
for (const admin of admins) {
|
||||
const isSuper = admin.role === 'SUPER_ADMIN';
|
||||
|
||||
await prisma.admin.update({
|
||||
where: { id: admin.id },
|
||||
data: {
|
||||
isSuperAdmin: isSuper,
|
||||
},
|
||||
});
|
||||
|
||||
const targetRoleId = isSuper
|
||||
? superAdminRole.id
|
||||
: admin.role === 'OPERATOR'
|
||||
? operatorRole.id
|
||||
: supportRole.id;
|
||||
|
||||
await prisma.adminRoleAssignment.upsert({
|
||||
where: {
|
||||
adminId_roleId: {
|
||||
adminId: admin.id,
|
||||
roleId: targetRoleId,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
adminId: admin.id,
|
||||
roleId: targetRoleId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.adminWorkspaceAssignment.upsert({
|
||||
where: {
|
||||
adminId_workspaceId: {
|
||||
adminId: admin.id,
|
||||
workspaceId: defaultWorkspace.id,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
adminId: admin.id,
|
||||
workspaceId: defaultWorkspace.id,
|
||||
scopeType: AccessScopeType.ALL,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Backfilled ${admins.length} existing admins with roles and workspace assignments.`);
|
||||
console.log('RBAC seed completed successfully.');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { validateEnv } from './config/env.validation';
|
||||
import { AdminAuditModule } from './modules/admin-audit/admin-audit.module';
|
||||
import { AdminAuthModule } from './modules/admin-auth/admin-auth.module';
|
||||
import { AdminManagementModule } from './modules/admin-management/admin-management.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { CampaignModule } from './modules/campaign/campaign.module';
|
||||
import { ChanceModule } from './modules/chance/chance.module';
|
||||
@@ -57,6 +58,7 @@ import { PrismaModule } from './prisma/prisma.module';
|
||||
WinnerModule,
|
||||
ReportModule,
|
||||
AdminAuditModule,
|
||||
AdminManagementModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -7,6 +7,13 @@ export interface AuthenticatedAdmin {
|
||||
mobile: string;
|
||||
name: string;
|
||||
role: AdminRole;
|
||||
isSuperAdmin?: boolean;
|
||||
permissions?: string[];
|
||||
workspaceAssignments?: Array<{
|
||||
workspaceId: string;
|
||||
scopeType: string;
|
||||
specificCampaignIds?: string[] | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const CurrentAdmin = createParamDecorator(
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const PERMISSIONS_KEY = 'permissions';
|
||||
|
||||
export const RequirePermission = (...permissions: string[]) =>
|
||||
SetMetadata(PERMISSIONS_KEY, permissions);
|
||||
@@ -0,0 +1,43 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { PERMISSIONS_KEY } from '../decorators/require-permission.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(PERMISSIONS_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
// If no permission is required on the route, allow access
|
||||
if (!requiredPermissions || requiredPermissions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const admin = request.admin || request.user;
|
||||
|
||||
if (!admin) {
|
||||
throw new ForbiddenException('ACCESS_DENIED');
|
||||
}
|
||||
|
||||
// Super Admin has full unrestricted bypass access
|
||||
if (admin.isSuperAdmin === true || admin.role === 'SUPER_ADMIN') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const adminPermissions: string[] = admin.permissions || [];
|
||||
|
||||
// Check if admin has all required permissions (or at least one if multiple are given)
|
||||
const hasRequired = requiredPermissions.every((perm) => adminPermissions.includes(perm));
|
||||
|
||||
if (!hasRequired) {
|
||||
throw new ForbiddenException('INSUFFICIENT_PERMISSIONS');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { AdminAuditService } from './admin-audit.service';
|
||||
|
||||
@ApiTags('Admin Audit Logs (لاگهای بازرسی مدیران)')
|
||||
@Controller('admin/audit-logs')
|
||||
@UseGuards(JwtAdminGuard)
|
||||
@UseGuards(JwtAdminGuard, PermissionsGuard)
|
||||
@ApiBearerAuth('AdminAuth')
|
||||
export class AdminAuditController {
|
||||
constructor(private readonly auditService: AdminAuditService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('audit:read')
|
||||
@ApiOperation({ summary: 'مشاهده تمام لاگهای بازرسی اقدامات ادمین با فیلتر' })
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, HttpCode, HttpStatus, Patch, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, HttpCode, HttpStatus, Patch, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard';
|
||||
@@ -31,6 +31,14 @@ export class AdminAuthController {
|
||||
return this.adminAuthService.refreshToken(dto);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAdminGuard)
|
||||
@ApiBearerAuth('AdminAuth')
|
||||
@ApiOperation({ summary: 'دریافت مشخصات کامل و مجوزهای مدیر جاری' })
|
||||
async getMe(@Req() req: any) {
|
||||
return this.adminAuthService.getProfile(req.user.id);
|
||||
}
|
||||
|
||||
@Patch('profile')
|
||||
@UseGuards(JwtAdminGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -56,6 +56,26 @@ export class AdminAuthService implements OnModuleInit {
|
||||
const { email, password } = dto;
|
||||
const admin = await this.prisma.admin.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceAssignments: {
|
||||
include: {
|
||||
workspace: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
@@ -71,6 +91,19 @@ export class AdminAuthService implements OnModuleInit {
|
||||
throw new UnauthorizedException('ایمیل یا کلمه عبور اشتباه است.');
|
||||
}
|
||||
|
||||
// Update lastLoginAt
|
||||
await this.prisma.admin.update({
|
||||
where: { id: admin.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
|
||||
const permissions = new Set<string>();
|
||||
admin.roles.forEach((ra) => {
|
||||
ra.role.permissions.forEach((rp) => {
|
||||
permissions.add(rp.permission.code);
|
||||
});
|
||||
});
|
||||
|
||||
const tokens = this.generateAdminTokens(admin);
|
||||
|
||||
return {
|
||||
@@ -81,6 +114,19 @@ export class AdminAuthService implements OnModuleInit {
|
||||
email: admin.email,
|
||||
mobile: admin.mobile,
|
||||
role: admin.role,
|
||||
isSuperAdmin: admin.isSuperAdmin,
|
||||
permissions: Array.from(permissions),
|
||||
roles: admin.roles.map((ra) => ({
|
||||
id: ra.role.id,
|
||||
code: ra.role.code,
|
||||
name: ra.role.name,
|
||||
})),
|
||||
workspaces: admin.workspaceAssignments.map((wa) => ({
|
||||
workspaceId: wa.workspaceId,
|
||||
workspaceName: wa.workspace.name,
|
||||
scopeType: wa.scopeType,
|
||||
specificCampaignIds: wa.specificCampaignIds,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -183,9 +229,68 @@ export class AdminAuthService implements OnModuleInit {
|
||||
mobile: true,
|
||||
role: true,
|
||||
status: true,
|
||||
isSuperAdmin: true,
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async getProfile(adminId: string) {
|
||||
const admin = await this.prisma.admin.findUnique({
|
||||
where: { id: adminId },
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceAssignments: {
|
||||
include: {
|
||||
workspace: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!admin || admin.status !== AdminStatus.ACTIVE) {
|
||||
throw new UnauthorizedException('حساب کاربری یافت نشد یا غیرفعال است.');
|
||||
}
|
||||
|
||||
const permissions = new Set<string>();
|
||||
admin.roles.forEach((ra) => {
|
||||
ra.role.permissions.forEach((rp) => {
|
||||
permissions.add(rp.permission.code);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
id: admin.id,
|
||||
name: admin.name,
|
||||
email: admin.email,
|
||||
mobile: admin.mobile,
|
||||
role: admin.role,
|
||||
isSuperAdmin: admin.isSuperAdmin,
|
||||
permissions: Array.from(permissions),
|
||||
roles: admin.roles.map((ra) => ({
|
||||
id: ra.role.id,
|
||||
code: ra.role.code,
|
||||
name: ra.role.name,
|
||||
})),
|
||||
workspaces: admin.workspaceAssignments.map((wa) => ({
|
||||
workspaceId: wa.workspaceId,
|
||||
workspaceName: wa.workspace.name,
|
||||
scopeType: wa.scopeType,
|
||||
specificCampaignIds: wa.specificCampaignIds,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,15 +25,58 @@ export class JwtAdminStrategy extends PassportStrategy(Strategy, 'jwt-admin') {
|
||||
|
||||
const admin = await this.prisma.admin.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: { id: true, email: true, name: true, mobile: true, role: true, status: true },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
mobile: true,
|
||||
role: true,
|
||||
status: true,
|
||||
isSuperAdmin: true,
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceAssignments: {
|
||||
select: {
|
||||
workspaceId: true,
|
||||
scopeType: true,
|
||||
specificCampaignIds: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!admin || admin.status !== AdminStatus.ACTIVE) {
|
||||
throw new UnauthorizedException('ADMIN_INACTIVE_OR_NOT_FOUND');
|
||||
}
|
||||
|
||||
const permissions = new Set<string>();
|
||||
admin.roles.forEach((ra) => {
|
||||
ra.role.permissions.forEach((rp) => {
|
||||
permissions.add(rp.permission.code);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
...admin,
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
name: admin.name,
|
||||
mobile: admin.mobile,
|
||||
role: admin.role,
|
||||
status: admin.status,
|
||||
isSuperAdmin: admin.isSuperAdmin,
|
||||
permissions: Array.from(permissions),
|
||||
workspaceAssignments: admin.workspaceAssignments,
|
||||
type: 'admin',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { AdminStatus } from '@prisma/client';
|
||||
import { CurrentAdmin } from '../../common/decorators/current-admin.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
import { AuditInterceptor } from '../../common/interceptors/audit.interceptor';
|
||||
import { AdminManagementService } from './admin-management.service';
|
||||
import { CreateAdminDto } from './dto/create-admin.dto';
|
||||
import { UpdateAdminDto } from './dto/update-admin.dto';
|
||||
|
||||
@ApiTags('Admin Management (مدیریت مدیران و دسترسیها)')
|
||||
@Controller('admin/managers')
|
||||
@UseGuards(JwtAdminGuard, PermissionsGuard)
|
||||
@UseInterceptors(AuditInterceptor)
|
||||
@ApiBearerAuth('AdminAuth')
|
||||
export class AdminManagementController {
|
||||
constructor(private readonly service: AdminManagementService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('admins:read')
|
||||
@ApiOperation({ summary: 'لیست مدیران سامانه با فیلتر جستجو، نقش و وضعیت' })
|
||||
@ApiQuery({ name: 'search', required: false })
|
||||
@ApiQuery({ name: 'roleCode', required: false })
|
||||
@ApiQuery({ name: 'status', enum: AdminStatus, required: false })
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
async findAll(@Query() query: any) {
|
||||
return this.service.findAllAdmins(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('admins:read')
|
||||
@ApiOperation({ summary: 'مشاهده مشخصات کامل مدیر، نقشها و فضاهای کاری' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.service.findAdminById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('admins:manage')
|
||||
@ApiOperation({ summary: 'افزودن مدیر جدید به همراه نقشها و اسکوپ فضاهای کاری' })
|
||||
@ApiResponse({ status: 201, description: 'مدیر با موفقیت ایجاد شد' })
|
||||
async create(@Body() dto: CreateAdminDto) {
|
||||
return this.service.createAdmin(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermission('admins:manage')
|
||||
@ApiOperation({ summary: 'ویرایش مشخصات مدیر و نقشها/فضاهای کاری' })
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateAdminDto,
|
||||
@CurrentAdmin('id') currentAdminId: string,
|
||||
) {
|
||||
return this.service.updateAdmin(id, dto, currentAdminId);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@RequirePermission('admins:manage')
|
||||
@ApiOperation({ summary: 'تغییر وضعیت فعال یا غیرفعال مدیر' })
|
||||
async toggleStatus(
|
||||
@Param('id') id: string,
|
||||
@Body('status') status: AdminStatus,
|
||||
@CurrentAdmin('id') currentAdminId: string,
|
||||
) {
|
||||
return this.service.toggleAdminStatus(id, status, currentAdminId);
|
||||
}
|
||||
|
||||
@Patch(':id/password')
|
||||
@RequirePermission('admins:manage')
|
||||
@ApiOperation({ summary: 'بازنشانی کلمه عبور مدیر توسط سوپرادمین' })
|
||||
async resetPassword(
|
||||
@Param('id') id: string,
|
||||
@Body('password') password: string,
|
||||
) {
|
||||
return this.service.updateAdmin(id, { password }, '');
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('admins:manage')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'حذف مدیر (در صورت عدم وجود سوابق تراکنش)' })
|
||||
async delete(
|
||||
@Param('id') id: string,
|
||||
@CurrentAdmin('id') currentAdminId: string,
|
||||
) {
|
||||
return this.service.deleteAdmin(id, currentAdminId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminManagementController } from './admin-management.controller';
|
||||
import { AdminManagementService } from './admin-management.service';
|
||||
import { RoleManagementController } from './role-management.controller';
|
||||
import { WorkspaceController } from './workspace.controller';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [
|
||||
AdminManagementController,
|
||||
RoleManagementController,
|
||||
WorkspaceController,
|
||||
],
|
||||
providers: [AdminManagementService],
|
||||
exports: [AdminManagementService],
|
||||
})
|
||||
export class AdminManagementModule {}
|
||||
@@ -0,0 +1,611 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AccessScopeType, AdminRole, AdminStatus } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CreateAdminDto } from './dto/create-admin.dto';
|
||||
import { CreateRoleDto } from './dto/create-role.dto';
|
||||
import { CreateWorkspaceDto } from './dto/create-workspace.dto';
|
||||
import { UpdateAdminDto } from './dto/update-admin.dto';
|
||||
import { UpdateRoleDto } from './dto/update-role.dto';
|
||||
import { UpdateWorkspaceDto } from './dto/update-workspace.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminManagementService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
// =========================================================================
|
||||
// ADMINS CRUD
|
||||
// =========================================================================
|
||||
|
||||
async findAllAdmins(params: {
|
||||
search?: string;
|
||||
roleCode?: string;
|
||||
status?: AdminStatus;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
const page = Math.max(1, Number(params.page) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, Number(params.pageSize) || 10));
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = {};
|
||||
|
||||
if (params.status) {
|
||||
where.status = params.status;
|
||||
}
|
||||
|
||||
if (params.search && params.search.trim()) {
|
||||
const q = params.search.trim();
|
||||
where.OR = [
|
||||
{ name: { contains: q, mode: 'insensitive' } },
|
||||
{ email: { contains: q, mode: 'insensitive' } },
|
||||
{ mobile: { contains: q } },
|
||||
];
|
||||
}
|
||||
|
||||
if (params.roleCode) {
|
||||
where.roles = {
|
||||
some: {
|
||||
role: {
|
||||
code: params.roleCode,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const [total, admins] = await Promise.all([
|
||||
this.prisma.admin.count({ where }),
|
||||
this.prisma.admin.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
mobile: true,
|
||||
role: true,
|
||||
status: true,
|
||||
isSuperAdmin: true,
|
||||
lastLoginAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
roles: {
|
||||
select: {
|
||||
role: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
isSystem: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceAssignments: {
|
||||
select: {
|
||||
workspace: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
scopeType: true,
|
||||
specificCampaignIds: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: admins,
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async findAdminById(id: string) {
|
||||
const admin = await this.prisma.admin.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceAssignments: {
|
||||
include: {
|
||||
workspace: true,
|
||||
},
|
||||
},
|
||||
auditLogs: {
|
||||
take: 10,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
throw new NotFoundException('مدیر مورد نظر یافت نشد.');
|
||||
}
|
||||
|
||||
const { passwordHash, ...safeAdmin } = admin;
|
||||
return safeAdmin;
|
||||
}
|
||||
|
||||
async createAdmin(dto: CreateAdminDto) {
|
||||
const email = dto.email.toLowerCase().trim();
|
||||
const mobile = dto.mobile.trim();
|
||||
|
||||
const [existingEmail, existingMobile] = await Promise.all([
|
||||
this.prisma.admin.findUnique({ where: { email } }),
|
||||
this.prisma.admin.findUnique({ where: { mobile } }),
|
||||
]);
|
||||
|
||||
if (existingEmail) {
|
||||
throw new ConflictException('این آدرس ایمیل قبلاً در سامانه ثبت شده است.');
|
||||
}
|
||||
if (existingMobile) {
|
||||
throw new ConflictException('این شماره موبایل قبلاً برای مدیر دیگری ثبت شده است.');
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const admin = await tx.admin.create({
|
||||
data: {
|
||||
name: dto.name.trim(),
|
||||
email,
|
||||
mobile,
|
||||
passwordHash,
|
||||
status: dto.status || AdminStatus.ACTIVE,
|
||||
isSuperAdmin: dto.isSuperAdmin || false,
|
||||
role: dto.isSuperAdmin ? AdminRole.SUPER_ADMIN : AdminRole.OPERATOR,
|
||||
},
|
||||
});
|
||||
|
||||
// Role assignments
|
||||
if (dto.roleIds && dto.roleIds.length > 0) {
|
||||
await tx.adminRoleAssignment.createMany({
|
||||
data: dto.roleIds.map((roleId) => ({
|
||||
adminId: admin.id,
|
||||
roleId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Workspace assignments
|
||||
if (dto.workspaceAssignments && dto.workspaceAssignments.length > 0) {
|
||||
await tx.adminWorkspaceAssignment.createMany({
|
||||
data: dto.workspaceAssignments.map((wa) => ({
|
||||
adminId: admin.id,
|
||||
workspaceId: wa.workspaceId,
|
||||
scopeType: wa.scopeType || AccessScopeType.ALL,
|
||||
specificCampaignIds: wa.specificCampaignIds ? wa.specificCampaignIds : undefined,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
// Assign to default workspace if none specified
|
||||
const defaultWs = await tx.workspace.findUnique({ where: { slug: 'default-workspace' } });
|
||||
if (defaultWs) {
|
||||
await tx.adminWorkspaceAssignment.create({
|
||||
data: {
|
||||
adminId: admin.id,
|
||||
workspaceId: defaultWs.id,
|
||||
scopeType: AccessScopeType.ALL,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tx.admin.findUnique({
|
||||
where: { id: admin.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
mobile: true,
|
||||
status: true,
|
||||
isSuperAdmin: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async updateAdmin(id: string, dto: UpdateAdminDto, currentAdminId: string) {
|
||||
const admin = await this.prisma.admin.findUnique({ where: { id } });
|
||||
if (!admin) {
|
||||
throw new NotFoundException('مدیر مورد نظر یافت نشد.');
|
||||
}
|
||||
|
||||
// Protection rule: Self modification of status / isSuperAdmin
|
||||
if (id === currentAdminId) {
|
||||
if (dto.status !== undefined && dto.status !== admin.status) {
|
||||
throw new BadRequestException('امکان تغییر وضعیت حساب کاربری جاری توسط خودتان وجود ندارد.');
|
||||
}
|
||||
if (dto.isSuperAdmin !== undefined && dto.isSuperAdmin !== admin.isSuperAdmin) {
|
||||
throw new BadRequestException('امکان تغییر اختیارات ارشد حساب کاربری خودتان وجود ندارد.');
|
||||
}
|
||||
}
|
||||
|
||||
// Protection rule: Last Super Admin demotion/deactivation
|
||||
if (admin.isSuperAdmin) {
|
||||
const willDemoteOrDeactivate =
|
||||
(dto.isSuperAdmin === false) ||
|
||||
(dto.status === AdminStatus.DISABLED);
|
||||
|
||||
if (willDemoteOrDeactivate) {
|
||||
const activeSuperAdminCount = await this.prisma.admin.count({
|
||||
where: { isSuperAdmin: true, status: AdminStatus.ACTIVE },
|
||||
});
|
||||
if (activeSuperAdminCount <= 1) {
|
||||
throw new BadRequestException('امکان غیرفعالسازی یا خلع اختیارات آخرین مدیر ارشد سامانه وجود ندارد.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dataToUpdate: any = {};
|
||||
if (dto.name) dataToUpdate.name = dto.name.trim();
|
||||
|
||||
if (dto.email) {
|
||||
const emailLower = dto.email.toLowerCase().trim();
|
||||
const existing = await this.prisma.admin.findFirst({
|
||||
where: { email: emailLower, id: { not: id } },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('این ایمیل قبلاً برای مدیر دیگری ثبت شده است.');
|
||||
}
|
||||
dataToUpdate.email = emailLower;
|
||||
}
|
||||
|
||||
if (dto.mobile) {
|
||||
const mobileClean = dto.mobile.trim();
|
||||
const existing = await this.prisma.admin.findFirst({
|
||||
where: { mobile: mobileClean, id: { not: id } },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('این شماره موبایل قبلاً برای مدیر دیگری ثبت شده است.');
|
||||
}
|
||||
dataToUpdate.mobile = mobileClean;
|
||||
}
|
||||
|
||||
if (dto.password) {
|
||||
dataToUpdate.passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
}
|
||||
|
||||
if (dto.status !== undefined) {
|
||||
dataToUpdate.status = dto.status;
|
||||
}
|
||||
|
||||
if (dto.isSuperAdmin !== undefined) {
|
||||
dataToUpdate.isSuperAdmin = dto.isSuperAdmin;
|
||||
if (dto.isSuperAdmin) {
|
||||
dataToUpdate.role = AdminRole.SUPER_ADMIN;
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.admin.update({
|
||||
where: { id },
|
||||
data: dataToUpdate,
|
||||
});
|
||||
|
||||
// Update role assignments if explicitly provided
|
||||
if (dto.roleIds !== undefined) {
|
||||
await tx.adminRoleAssignment.deleteMany({ where: { adminId: id } });
|
||||
if (dto.roleIds.length > 0) {
|
||||
await tx.adminRoleAssignment.createMany({
|
||||
data: dto.roleIds.map((roleId) => ({
|
||||
adminId: id,
|
||||
roleId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update workspace assignments if explicitly provided
|
||||
if (dto.workspaceAssignments !== undefined) {
|
||||
await tx.adminWorkspaceAssignment.deleteMany({ where: { adminId: id } });
|
||||
if (dto.workspaceAssignments.length > 0) {
|
||||
await tx.adminWorkspaceAssignment.createMany({
|
||||
data: dto.workspaceAssignments.map((wa) => ({
|
||||
adminId: id,
|
||||
workspaceId: wa.workspaceId,
|
||||
scopeType: wa.scopeType || AccessScopeType.ALL,
|
||||
specificCampaignIds: wa.specificCampaignIds ? wa.specificCampaignIds : undefined,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tx.admin.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
mobile: true,
|
||||
status: true,
|
||||
isSuperAdmin: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async toggleAdminStatus(id: string, status: AdminStatus, currentAdminId: string) {
|
||||
if (id === currentAdminId) {
|
||||
throw new BadRequestException('امکان غیرفعالسازی حساب کاربری جاری توسط خودتان وجود ندارد.');
|
||||
}
|
||||
|
||||
const admin = await this.prisma.admin.findUnique({ where: { id } });
|
||||
if (!admin) {
|
||||
throw new NotFoundException('مدیر مورد نظر یافت نشد.');
|
||||
}
|
||||
|
||||
if (admin.isSuperAdmin && status === AdminStatus.DISABLED) {
|
||||
const activeSuperAdminCount = await this.prisma.admin.count({
|
||||
where: { isSuperAdmin: true, status: AdminStatus.ACTIVE },
|
||||
});
|
||||
if (activeSuperAdminCount <= 1) {
|
||||
throw new BadRequestException('امکان غیرفعالسازی آخرین مدیر ارشد سامانه وجود ندارد.');
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.admin.update({
|
||||
where: { id },
|
||||
data: { status },
|
||||
select: { id: true, name: true, status: true },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAdmin(id: string, currentAdminId: string) {
|
||||
if (id === currentAdminId) {
|
||||
throw new BadRequestException('امکان حذف حساب کاربری خودتان وجود ندارد.');
|
||||
}
|
||||
|
||||
const admin = await this.prisma.admin.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
chanceTxns: { take: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
throw new NotFoundException('مدیر مورد نظر یافت نشد.');
|
||||
}
|
||||
|
||||
if (admin.isSuperAdmin) {
|
||||
const activeSuperAdminCount = await this.prisma.admin.count({
|
||||
where: { isSuperAdmin: true, status: AdminStatus.ACTIVE },
|
||||
});
|
||||
if (activeSuperAdminCount <= 1) {
|
||||
throw new BadRequestException('امکان حذف آخرین مدیر ارشد سامانه وجود ندارد.');
|
||||
}
|
||||
}
|
||||
|
||||
if (admin.chanceTxns && admin.chanceTxns.length > 0) {
|
||||
throw new BadRequestException(
|
||||
'این مدیر دارای سوابق ثبت یا تغییر شانس است و حذف فیزیکی آن یکپارچگی دادهها را مخدوش میکند. لطفاً وضعیت حساب آن را به «غیرفعال» تغییر دهید.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.admin.delete({ where: { id } });
|
||||
return { success: true, message: 'مدیر با موفقیت حذف شد.' };
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ROLES & PERMISSIONS
|
||||
// =========================================================================
|
||||
|
||||
async findAllRoles() {
|
||||
return this.prisma.role.findMany({
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { admins: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findRoleById(id: string) {
|
||||
const role = await this.prisma.role.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
throw new NotFoundException('نقش مورد نظر یافت نشد.');
|
||||
}
|
||||
|
||||
return role;
|
||||
}
|
||||
|
||||
async createRole(dto: CreateRoleDto) {
|
||||
const existing = await this.prisma.role.findUnique({
|
||||
where: { code: dto.code.toUpperCase().trim() },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('نقشی با این کد انگلیسی قبلاً ثبت شده است.');
|
||||
}
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const role = await tx.role.create({
|
||||
data: {
|
||||
code: dto.code.toUpperCase().trim(),
|
||||
name: dto.name.trim(),
|
||||
description: dto.description?.trim(),
|
||||
isSystem: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (dto.permissionIds && dto.permissionIds.length > 0) {
|
||||
await tx.rolePermission.createMany({
|
||||
data: dto.permissionIds.map((permId) => ({
|
||||
roleId: role.id,
|
||||
permissionId: permId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return tx.role.findUnique({
|
||||
where: { id: role.id },
|
||||
include: {
|
||||
permissions: {
|
||||
include: { permission: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async updateRole(id: string, dto: UpdateRoleDto) {
|
||||
const role = await this.prisma.role.findUnique({ where: { id } });
|
||||
if (!role) {
|
||||
throw new NotFoundException('نقش مورد نظر یافت نشد.');
|
||||
}
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.role.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name?.trim(),
|
||||
description: dto.description?.trim(),
|
||||
},
|
||||
});
|
||||
|
||||
if (dto.permissionIds !== undefined) {
|
||||
await tx.rolePermission.deleteMany({ where: { roleId: id } });
|
||||
if (dto.permissionIds.length > 0) {
|
||||
await tx.rolePermission.createMany({
|
||||
data: dto.permissionIds.map((permId) => ({
|
||||
roleId: id,
|
||||
permissionId: permId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tx.role.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
permissions: {
|
||||
include: { permission: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async deleteRole(id: string) {
|
||||
const role = await this.prisma.role.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: {
|
||||
select: { admins: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
throw new NotFoundException('نقش مورد نظر یافت نشد.');
|
||||
}
|
||||
|
||||
if (role.isSystem) {
|
||||
throw new BadRequestException('امکان حذف نقشهای پیشفرض و سیستمی وجود ندارد.');
|
||||
}
|
||||
|
||||
if (role._count.admins > 0) {
|
||||
throw new BadRequestException('به دلیل وجود مدیران منتسب به این نقش، ابتدا نقش آنها را تغییر داده و سپس اقدام به حذف نمایید.');
|
||||
}
|
||||
|
||||
await this.prisma.role.delete({ where: { id } });
|
||||
return { success: true, message: 'نقش با موفقیت حذف شد.' };
|
||||
}
|
||||
|
||||
async findAllPermissions() {
|
||||
return this.prisma.permission.findMany({
|
||||
orderBy: [{ module: 'asc' }, { code: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// WORKSPACES
|
||||
// =========================================================================
|
||||
|
||||
async findAllWorkspaces() {
|
||||
return this.prisma.workspace.findMany({
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: { campaigns: true, adminAssignments: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createWorkspace(dto: CreateWorkspaceDto) {
|
||||
const existing = await this.prisma.workspace.findUnique({
|
||||
where: { slug: dto.slug.toLowerCase().trim() },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('فضای کاری با این اسلاگ انگلیسی قبلاً ثبت شده است.');
|
||||
}
|
||||
|
||||
return this.prisma.workspace.create({
|
||||
data: {
|
||||
name: dto.name.trim(),
|
||||
slug: dto.slug.toLowerCase().trim(),
|
||||
description: dto.description?.trim(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateWorkspace(id: string, dto: UpdateWorkspaceDto) {
|
||||
const workspace = await this.prisma.workspace.findUnique({ where: { id } });
|
||||
if (!workspace) {
|
||||
throw new NotFoundException('فضای کاری یافت نشد.');
|
||||
}
|
||||
|
||||
return this.prisma.workspace.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name?.trim(),
|
||||
description: dto.description?.trim(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { AccessScopeType, AdminStatus } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class AdminWorkspaceAssignmentDto {
|
||||
@ApiProperty({ description: 'شناسه فضای کاری' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
workspaceId: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: AccessScopeType, default: AccessScopeType.ALL })
|
||||
@IsEnum(AccessScopeType)
|
||||
@IsOptional()
|
||||
scopeType?: AccessScopeType = AccessScopeType.ALL;
|
||||
|
||||
@ApiPropertyOptional({ description: 'لیست شناسه کمپینهای مجاز در صورت اسکوپ سفارشی' })
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
specificCampaignIds?: string[];
|
||||
}
|
||||
|
||||
export class CreateAdminDto {
|
||||
@ApiProperty({ description: 'نام و نام خانوادگی مدیر', example: 'علی رضایی' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'نام مدیر الزامی است.' })
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ description: 'ایمیل سازمانی مدیر', example: 'ali@example.com' })
|
||||
@IsEmail({}, { message: 'فرمت ایمیل نامعتبر است.' })
|
||||
@IsNotEmpty({ message: 'ایمیل الزامی است.' })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ description: 'شماره موبایل مدیر (۱۱ رقم با ۰۹)', example: '09123456789' })
|
||||
@Matches(/^09\d{9}$/, { message: 'شماره موبایل باید ۱۱ رقمی بوده و با ۰۹ شروع شود.' })
|
||||
@IsNotEmpty({ message: 'شماره موبایل الزامی است.' })
|
||||
mobile: string;
|
||||
|
||||
@ApiProperty({ description: 'کلمه عبور ورود به سامانه (حداقل ۶ کاراکتر)', example: 'SecurePass123' })
|
||||
@IsString()
|
||||
@MinLength(6, { message: 'کلمه عبور باید حداقل ۶ کاراکتر باشد.' })
|
||||
@IsNotEmpty({ message: 'کلمه عبور الزامی است.' })
|
||||
password: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: AdminStatus, default: AdminStatus.ACTIVE })
|
||||
@IsEnum(AdminStatus)
|
||||
@IsOptional()
|
||||
status?: AdminStatus = AdminStatus.ACTIVE;
|
||||
|
||||
@ApiPropertyOptional({ description: 'آیا این کاربر مدیر ارشد تامالاختیار است؟', default: false })
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isSuperAdmin?: boolean = false;
|
||||
|
||||
@ApiPropertyOptional({ description: 'شناسه نقشهای تخصیصیافته به مدیر', type: [String] })
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
roleIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'تخصیصهای فضاهای کاری و اسکوپ دسترسی', type: [AdminWorkspaceAssignmentDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AdminWorkspaceAssignmentDto)
|
||||
@IsOptional()
|
||||
workspaceAssignments?: AdminWorkspaceAssignmentDto[];
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsArray, IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator';
|
||||
|
||||
export class CreateRoleDto {
|
||||
@ApiProperty({ description: 'کد یکتا و انگلیسی نقش', example: 'MARKETING_LEAD' })
|
||||
@IsString()
|
||||
@Matches(/^[A-Z0-9_]+$/, { message: 'کد نقش باید فقط شامل حروف بزرگ انگلیسی، اعداد و خط زیر (_) باشد.' })
|
||||
@IsNotEmpty({ message: 'کد نقش الزامی است.' })
|
||||
code: string;
|
||||
|
||||
@ApiProperty({ description: 'نام نمایشی نقش به فارسی', example: 'سرپرست بازاریابی' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'نام نقش الزامی است.' })
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'توضیحات نقش' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'لیست شناسههای پرمیشنهای منتسب به این نقش', type: [String] })
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
permissionIds?: string[];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator';
|
||||
|
||||
export class CreateWorkspaceDto {
|
||||
@ApiProperty({ description: 'نام فضای کاری', example: 'برند دیجیکالا' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'نام فضای کاری الزامی است.' })
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ description: 'شناسه انگلیسی یکتا (اسلاگ)', example: 'digikala-brand' })
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9-]+$/, { message: 'اسلاگ باید فقط شامل حروف کوچک انگلیسی، اعداد و خط تیره (-) باشد.' })
|
||||
@IsNotEmpty({ message: 'اسلاگ الزامی است.' })
|
||||
slug: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'توضیحات فضای کاری' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { AdminStatus } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { AdminWorkspaceAssignmentDto } from './create-admin.dto';
|
||||
|
||||
export class UpdateAdminDto {
|
||||
@ApiPropertyOptional({ description: 'نام و نام خانوادگی مدیر' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'ایمیل سازمانی مدیر' })
|
||||
@IsEmail({}, { message: 'فرمت ایمیل نامعتبر است.' })
|
||||
@IsOptional()
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'شماره موبایل مدیر (۱۱ رقم با ۰۹)' })
|
||||
@Matches(/^09\d{9}$/, { message: 'شماره موبایل باید ۱۱ رقمی بوده و با ۰۹ شروع شود.' })
|
||||
@IsOptional()
|
||||
mobile?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'کلمه عبور جدید (اختیاری)' })
|
||||
@IsString()
|
||||
@MinLength(6, { message: 'کلمه عبور باید حداقل ۶ کاراکتر باشد.' })
|
||||
@IsOptional()
|
||||
password?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: AdminStatus })
|
||||
@IsEnum(AdminStatus)
|
||||
@IsOptional()
|
||||
status?: AdminStatus;
|
||||
|
||||
@ApiPropertyOptional({ description: 'آیا این کاربر مدیر ارشد تامالاختیار است؟' })
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isSuperAdmin?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'شناسه نقشهای تخصیصیافته به مدیر', type: [String] })
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
roleIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'تخصیصهای فضاهای کاری و اسکوپ دسترسی', type: [AdminWorkspaceAssignmentDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AdminWorkspaceAssignmentDto)
|
||||
@IsOptional()
|
||||
workspaceAssignments?: AdminWorkspaceAssignmentDto[];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsArray, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateRoleDto {
|
||||
@ApiPropertyOptional({ description: 'نام نمایشی نقش به فارسی' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'توضیحات نقش' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'لیست شناسههای پرمیشنهای منتسب به این نقش', type: [String] })
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
permissionIds?: string[];
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateWorkspaceDto {
|
||||
@ApiPropertyOptional({ description: 'نام فضای کاری' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'توضیحات فضای کاری' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
import { AuditInterceptor } from '../../common/interceptors/audit.interceptor';
|
||||
import { AdminManagementService } from './admin-management.service';
|
||||
import { CreateRoleDto } from './dto/create-role.dto';
|
||||
import { UpdateRoleDto } from './dto/update-role.dto';
|
||||
|
||||
@ApiTags('Role & Permission Management (مدیریت نقشها و مجوزها)')
|
||||
@Controller('admin')
|
||||
@UseGuards(JwtAdminGuard, PermissionsGuard)
|
||||
@UseInterceptors(AuditInterceptor)
|
||||
@ApiBearerAuth('AdminAuth')
|
||||
export class RoleManagementController {
|
||||
constructor(private readonly service: AdminManagementService) {}
|
||||
|
||||
@Get('roles')
|
||||
@RequirePermission('admins:read')
|
||||
@ApiOperation({ summary: 'لیست تمام نقشهای تعریفشده به همراه تعداد مدیران و مجوزها' })
|
||||
async findAllRoles() {
|
||||
return this.service.findAllRoles();
|
||||
}
|
||||
|
||||
@Get('roles/:id')
|
||||
@RequirePermission('roles:manage')
|
||||
@ApiOperation({ summary: 'مشاهده جزئیات نقش و شناسههای مجوزهای آن' })
|
||||
async findRoleById(@Param('id') id: string) {
|
||||
return this.service.findRoleById(id);
|
||||
}
|
||||
|
||||
@Post('roles')
|
||||
@RequirePermission('roles:manage')
|
||||
@ApiOperation({ summary: 'ایجاد نقش جدید و تخصیص مجوزها' })
|
||||
@ApiResponse({ status: 201, description: 'نقش با موفقیت ایجاد شد' })
|
||||
async createRole(@Body() dto: CreateRoleDto) {
|
||||
return this.service.createRole(dto);
|
||||
}
|
||||
|
||||
@Patch('roles/:id')
|
||||
@RequirePermission('roles:manage')
|
||||
@ApiOperation({ summary: 'ویرایش مشخصات یا مجوزهای نقش' })
|
||||
async updateRole(@Param('id') id: string, @Body() dto: UpdateRoleDto) {
|
||||
return this.service.updateRole(id, dto);
|
||||
}
|
||||
|
||||
@Delete('roles/:id')
|
||||
@RequirePermission('roles:manage')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'حذف نقش سفارشی' })
|
||||
async deleteRole(@Param('id') id: string) {
|
||||
return this.service.deleteRole(id);
|
||||
}
|
||||
|
||||
@Get('permissions')
|
||||
@RequirePermission('admins:read')
|
||||
@ApiOperation({ summary: 'کاتالوگ کامل مجوزهای سیستم دستهبندیشده بر اساس ماژول' })
|
||||
async findAllPermissions() {
|
||||
return this.service.findAllPermissions();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
import { AuditInterceptor } from '../../common/interceptors/audit.interceptor';
|
||||
import { AdminManagementService } from './admin-management.service';
|
||||
import { CreateWorkspaceDto } from './dto/create-workspace.dto';
|
||||
import { UpdateWorkspaceDto } from './dto/update-workspace.dto';
|
||||
|
||||
@ApiTags('Workspace Management (مدیریت فضاهای کاری)')
|
||||
@Controller('admin/workspaces')
|
||||
@UseGuards(JwtAdminGuard, PermissionsGuard)
|
||||
@UseInterceptors(AuditInterceptor)
|
||||
@ApiBearerAuth('AdminAuth')
|
||||
export class WorkspaceController {
|
||||
constructor(private readonly service: AdminManagementService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('admins:read')
|
||||
@ApiOperation({ summary: 'لیست فضاهای کاری به همراه آمار کمپینها و مدیران' })
|
||||
async findAll() {
|
||||
return this.service.findAllWorkspaces();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('workspaces:manage')
|
||||
@ApiOperation({ summary: 'تعریف فضای کاری جدید' })
|
||||
@ApiResponse({ status: 201, description: 'فضای کاری با موفقیت ایجاد شد' })
|
||||
async create(@Body() dto: CreateWorkspaceDto) {
|
||||
return this.service.createWorkspace(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermission('workspaces:manage')
|
||||
@ApiOperation({ summary: 'ویرایش مشخصات فضای کاری' })
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateWorkspaceDto) {
|
||||
return this.service.updateWorkspace(id, dto);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { AuditInterceptor } from '../../common/interceptors/audit.interceptor';
|
||||
import { CampaignService } from './campaign.service';
|
||||
import { CampaignFilterDto } from './dto/campaign-filter.dto';
|
||||
@@ -20,13 +22,14 @@ import { UpdateCampaignDto } from './dto/update-campaign.dto';
|
||||
|
||||
@ApiTags('Admin Campaigns (مدیریت کمپینها)')
|
||||
@Controller('admin/campaigns')
|
||||
@UseGuards(JwtAdminGuard)
|
||||
@UseGuards(JwtAdminGuard, PermissionsGuard)
|
||||
@UseInterceptors(AuditInterceptor)
|
||||
@ApiBearerAuth('AdminAuth')
|
||||
export class AdminCampaignController {
|
||||
constructor(private readonly campaignService: CampaignService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('campaigns:create')
|
||||
@ApiOperation({ summary: 'ایجاد کمپین جدید' })
|
||||
@ApiResponse({ status: 201, description: 'کمپین با موفقیت ایجاد شد' })
|
||||
async create(@Body() dto: CreateCampaignDto) {
|
||||
@@ -34,30 +37,35 @@ export class AdminCampaignController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('campaigns:read')
|
||||
@ApiOperation({ summary: 'لیست کمپینها با فیلتر وضعیت، جستجو و صفحهبندی' })
|
||||
async findAll(@Query() filter: CampaignFilterDto) {
|
||||
return this.campaignService.findAll(filter);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('campaigns:read')
|
||||
@ApiOperation({ summary: 'دریافت جزئیات کامل کمپین به همراه جوایز و آمار' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.campaignService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermission('campaigns:update')
|
||||
@ApiOperation({ summary: 'ویرایش اطلاعات و وضعیت کمپین' })
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateCampaignDto) {
|
||||
return this.campaignService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('campaigns:delete')
|
||||
@ApiOperation({ summary: 'حذف کمپین (در صورت عدم وجود اسپین ثبتشده)' })
|
||||
async remove(@Param('id') id: string) {
|
||||
return this.campaignService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/duplicate')
|
||||
@RequirePermission('campaigns:create')
|
||||
@ApiOperation({ summary: 'کپیبرداری سریع از یک کمپین به همراه جوایز آن' })
|
||||
async duplicate(@Param('id') id: string) {
|
||||
return this.campaignService.duplicate(id);
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentAdmin } from '../../common/decorators/current-admin.decorator';
|
||||
import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { AuditInterceptor } from '../../common/interceptors/audit.interceptor';
|
||||
import { ChanceService } from './chance.service';
|
||||
import { AdjustChanceDto, AdminAdjustChancePayloadDto } from './dto/adjust-chance.dto';
|
||||
@@ -17,13 +19,14 @@ import { AdjustChanceDto, AdminAdjustChancePayloadDto } from './dto/adjust-chanc
|
||||
|
||||
@ApiTags('Admin Chance Management (مدیریت شانس)')
|
||||
@Controller('admin')
|
||||
@UseGuards(JwtAdminGuard)
|
||||
@UseGuards(JwtAdminGuard, PermissionsGuard)
|
||||
@UseInterceptors(AuditInterceptor)
|
||||
@ApiBearerAuth('AdminAuth')
|
||||
export class AdminChanceController {
|
||||
constructor(private readonly chanceService: ChanceService) {}
|
||||
|
||||
@Post('chances/adjust')
|
||||
@RequirePermission('chances:adjust')
|
||||
@ApiOperation({ summary: 'تغییر شانس کاربر در کمپین (اندپوئینت مستقیم پنل مدیریت)' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@@ -46,6 +49,7 @@ export class AdminChanceController {
|
||||
}
|
||||
|
||||
@Post('users/:userId/campaigns/:campaignId/chances')
|
||||
@RequirePermission('chances:adjust')
|
||||
@ApiOperation({ summary: 'اضافه یا کم کردن دستی شانس کاربر در یک کمپین خاص' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { AuditInterceptor } from '../../common/interceptors/audit.interceptor';
|
||||
import { CreatePrizeDto } from './dto/create-prize.dto';
|
||||
import { UpdatePrizeDto } from './dto/update-prize.dto';
|
||||
@@ -20,13 +22,14 @@ import { PrizeService } from './prize.service';
|
||||
|
||||
@ApiTags('Admin Prizes (مدیریت جوایز)')
|
||||
@Controller('admin')
|
||||
@UseGuards(JwtAdminGuard)
|
||||
@UseGuards(JwtAdminGuard, PermissionsGuard)
|
||||
@UseInterceptors(AuditInterceptor)
|
||||
@ApiBearerAuth('AdminAuth')
|
||||
export class AdminPrizeController {
|
||||
constructor(private readonly prizeService: PrizeService) {}
|
||||
|
||||
@Post('campaigns/:campaignId/prizes')
|
||||
@RequirePermission('prizes:manage')
|
||||
@ApiOperation({ summary: 'ایجاد جایزه جدید برای یک کمپین' })
|
||||
@ApiResponse({ status: 201, description: 'جایزه با موفقیت به کمپین اضافه شد' })
|
||||
async create(
|
||||
@@ -37,12 +40,14 @@ export class AdminPrizeController {
|
||||
}
|
||||
|
||||
@Get('campaigns/:campaignId/prizes')
|
||||
@RequirePermission('prizes:read')
|
||||
@ApiOperation({ summary: 'لیست تمام جوایز تعریفشده برای یک کمپین' })
|
||||
async findByCampaign(@Param('campaignId') campaignId: string) {
|
||||
return this.prizeService.findByCampaign(campaignId);
|
||||
}
|
||||
|
||||
@Get('prizes/:id')
|
||||
@RequirePermission('prizes:read')
|
||||
@ApiOperation({ summary: 'دریافت مشخصات کامل یک جایزه' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.prizeService.findOne(id);
|
||||
@@ -50,18 +55,21 @@ export class AdminPrizeController {
|
||||
|
||||
@Patch('prizes/:id')
|
||||
@Put('prizes/:id')
|
||||
@RequirePermission('prizes:manage')
|
||||
@ApiOperation({ summary: 'ویرایش اطلاعات و وزن جایزه' })
|
||||
async update(@Param('id') id: string, @Body() dto: UpdatePrizeDto) {
|
||||
return this.prizeService.update(id, dto);
|
||||
}
|
||||
|
||||
@Patch('prizes/:id/stock')
|
||||
@RequirePermission('prizes:manage')
|
||||
@ApiOperation({ summary: 'شارژ یا اصلاح دستی موجودی جایزه' })
|
||||
async updateStock(@Param('id') id: string, @Body() dto: UpdateStockDto) {
|
||||
return this.prizeService.updateStock(id, dto);
|
||||
}
|
||||
|
||||
@Delete('prizes/:id')
|
||||
@RequirePermission('prizes:manage')
|
||||
@ApiOperation({ summary: 'حذف جایزه (در صورت عدم ثبت در نتایج قرعهکشی)' })
|
||||
async remove(@Param('id') id: string) {
|
||||
return this.prizeService.remove(id);
|
||||
|
||||
@@ -2,16 +2,19 @@ import { Controller, Get, Header, Query, Res, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { ReportService } from './report.service';
|
||||
|
||||
@ApiTags('Admin Reports (گزارشگیری و داشبورد)')
|
||||
@Controller('admin/reports')
|
||||
@UseGuards(JwtAdminGuard)
|
||||
@UseGuards(JwtAdminGuard, PermissionsGuard)
|
||||
@ApiBearerAuth('AdminAuth')
|
||||
export class AdminReportController {
|
||||
constructor(private readonly reportService: ReportService) {}
|
||||
|
||||
@Get('dashboard')
|
||||
@RequirePermission('reports:read')
|
||||
@ApiOperation({ summary: 'دریافت تمام آمارهای تجمیعی داشبورد اصلی، چارتهای روزانه و آخرین برندگان' })
|
||||
@ApiResponse({ status: 200, description: 'شاخصهای KPI داشبورد' })
|
||||
async getDashboard() {
|
||||
@@ -19,6 +22,7 @@ export class AdminReportController {
|
||||
}
|
||||
|
||||
@Get('participation')
|
||||
@RequirePermission('reports:read')
|
||||
@ApiOperation({ summary: 'گزارش مشارکت کاربران و تحلیل شانسها با فیلتر بازه زمانی' })
|
||||
@ApiQuery({ name: 'from', required: false })
|
||||
@ApiQuery({ name: 'to', required: false })
|
||||
@@ -28,6 +32,7 @@ export class AdminReportController {
|
||||
}
|
||||
|
||||
@Get('export')
|
||||
@RequirePermission('reports:export')
|
||||
@ApiOperation({ summary: 'خروجی اکسل گزارش مشارکت' })
|
||||
@Header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
@Header('Content-Disposition', 'attachment; filename=participation-report.xlsx')
|
||||
|
||||
@@ -9,18 +9,21 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { AuditInterceptor } from '../../common/interceptors/audit.interceptor';
|
||||
import { SmsService } from './sms.service';
|
||||
|
||||
@ApiTags('Admin SMS Logs (مدیریت پیامکها)')
|
||||
@Controller('admin/sms-logs')
|
||||
@UseGuards(JwtAdminGuard)
|
||||
@UseGuards(JwtAdminGuard, PermissionsGuard)
|
||||
@UseInterceptors(AuditInterceptor)
|
||||
@ApiBearerAuth('AdminAuth')
|
||||
export class AdminSmsController {
|
||||
constructor(private readonly smsService: SmsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('sms:read')
|
||||
@ApiOperation({ summary: 'لیست تمام پیامکهای ارسالی با فیلتر وضعیت و موبایل' })
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
@@ -31,6 +34,7 @@ export class AdminSmsController {
|
||||
}
|
||||
|
||||
@Post(':id/resend')
|
||||
@RequirePermission('sms:resend')
|
||||
@ApiOperation({ summary: 'ارسال مجدد پیامک ناموفق' })
|
||||
@ApiResponse({ status: 200, description: 'پیامک در صف ارسال مجدد قرار گرفت' })
|
||||
async resend(@Param('id') id: string) {
|
||||
|
||||
@@ -11,18 +11,21 @@ import {
|
||||
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { UserStatus } from '@prisma/client';
|
||||
import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { AuditInterceptor } from '../../common/interceptors/audit.interceptor';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@ApiTags('Admin Users (مدیریت کاربران)')
|
||||
@Controller('admin/users')
|
||||
@UseGuards(JwtAdminGuard)
|
||||
@UseGuards(JwtAdminGuard, PermissionsGuard)
|
||||
@UseInterceptors(AuditInterceptor)
|
||||
@ApiBearerAuth('AdminAuth')
|
||||
export class AdminUserController {
|
||||
constructor(private readonly userService: UserService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('users:read')
|
||||
@ApiOperation({ summary: 'لیست کاربران با جستجوی شماره موبایل/نام و فیلتر وضعیت' })
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
@@ -33,12 +36,14 @@ export class AdminUserController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('users:read')
|
||||
@ApiOperation({ summary: 'مشاهده پروفایل کامل، کمپینها، شانسها، اسپینها و جوایز کاربر' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.userService.findOneForAdmin(id);
|
||||
}
|
||||
|
||||
@Patch(':id/block')
|
||||
@RequirePermission('users:block')
|
||||
@ApiOperation({ summary: 'مسدودسازی یا رفع مسدودیت کاربر' })
|
||||
async toggleBlock(
|
||||
@Param('id') id: string,
|
||||
|
||||
@@ -10,18 +10,21 @@ import {
|
||||
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { AuditInterceptor } from '../../common/interceptors/audit.interceptor';
|
||||
import { WinnerService } from './winner.service';
|
||||
|
||||
@ApiTags('Admin Spins (تاریخچه اسپینها)')
|
||||
@Controller('admin/spins')
|
||||
@UseGuards(JwtAdminGuard)
|
||||
@UseGuards(JwtAdminGuard, PermissionsGuard)
|
||||
@UseInterceptors(AuditInterceptor)
|
||||
@ApiBearerAuth('AdminAuth')
|
||||
export class AdminSpinController {
|
||||
constructor(private readonly winnerService: WinnerService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('spins:read')
|
||||
@ApiOperation({ summary: 'لیست تاریخچه تمام چرخاندنهای گردونه با فیلتر و جستجو' })
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
@@ -33,6 +36,7 @@ export class AdminSpinController {
|
||||
}
|
||||
|
||||
@Get('export')
|
||||
@RequirePermission('reports:export')
|
||||
@ApiOperation({ summary: 'خروجی فایل اکسل از تاریخچه تمام اسپینها' })
|
||||
@Header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
@Header('Content-Disposition', 'attachment; filename=spins.xlsx')
|
||||
|
||||
@@ -15,19 +15,22 @@ import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@ne
|
||||
import { Response } from 'express';
|
||||
import { CurrentAdmin } from '../../common/decorators/current-admin.decorator';
|
||||
import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { AuditInterceptor } from '../../common/interceptors/audit.interceptor';
|
||||
import { UpdatePrizeStatusDto } from './dto/update-prize-status.dto';
|
||||
import { WinnerService } from './winner.service';
|
||||
|
||||
@ApiTags('Admin Winners (مدیریت برندگان)')
|
||||
@Controller('admin/user-prizes')
|
||||
@UseGuards(JwtAdminGuard)
|
||||
@UseGuards(JwtAdminGuard, PermissionsGuard)
|
||||
@UseInterceptors(AuditInterceptor)
|
||||
@ApiBearerAuth('AdminAuth')
|
||||
export class AdminWinnerController {
|
||||
constructor(private readonly winnerService: WinnerService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('winners:read')
|
||||
@ApiOperation({ summary: 'لیست برندگان با فیلتر کمپین، وضعیت جایزه و جستجو' })
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
@@ -39,6 +42,7 @@ export class AdminWinnerController {
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@RequirePermission('winners:manage')
|
||||
@ApiOperation({ summary: 'تغییر وضعیت جایزه برنده (تحویل شد / تماس گرفته شد / منقضی شد)' })
|
||||
async updateStatus(
|
||||
@Param('id') id: string,
|
||||
@@ -51,6 +55,7 @@ export class AdminWinnerController {
|
||||
}
|
||||
|
||||
@Get('export')
|
||||
@RequirePermission('reports:export')
|
||||
@ApiOperation({ summary: 'خروجی فایل اکسل از لیست برندگان' })
|
||||
@Header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
@Header('Content-Disposition', 'attachment; filename=winners.xlsx')
|
||||
|
||||
@@ -13,6 +13,11 @@ export enum AdminRole {
|
||||
SUPPORT = 'SUPPORT',
|
||||
}
|
||||
|
||||
export enum AccessScopeType {
|
||||
ALL = 'ALL',
|
||||
CUSTOM_CAMPAIGNS = 'CUSTOM_CAMPAIGNS',
|
||||
}
|
||||
|
||||
export enum UserStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
BLOCKED = 'BLOCKED',
|
||||
@@ -206,4 +211,56 @@ export interface PrizeDistributionDataPoint {
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface PermissionDto {
|
||||
id: string;
|
||||
code: string;
|
||||
module: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface RoleDto {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
isSystem: boolean;
|
||||
permissions?: PermissionDto[];
|
||||
_count?: {
|
||||
admins?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkspaceDto {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
_count?: {
|
||||
campaigns?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AdminManagerDto {
|
||||
id: string;
|
||||
name: string;
|
||||
mobile: string;
|
||||
email: string;
|
||||
status: AdminStatus;
|
||||
isSuperAdmin: boolean;
|
||||
lastLoginAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
roles: Array<{
|
||||
role: RoleDto;
|
||||
}>;
|
||||
workspaceAssignments: Array<{
|
||||
workspace: WorkspaceDto;
|
||||
scopeType: AccessScopeType;
|
||||
specificCampaignIds?: string[] | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export * from './farsi-number';
|
||||
|
||||
Reference in New Issue
Block a user