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:
s.esmaeili
2026-10-02 16:29:23 +03:30
parent afde44ab8a
commit c5a841c35f
51 changed files with 5402 additions and 48 deletions
@@ -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);
+5 -1
View File
@@ -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);
}
+78 -14
View File
@@ -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;
+18 -3
View File
@@ -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;
+2
View File
@@ -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);
+78 -1
View File
@@ -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();
});
+50 -2
View File
@@ -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>