diff --git a/assets/capture--admin-1771758081055.png b/assets/capture--admin-1771758081055.png new file mode 100644 index 0000000..1d3967c Binary files /dev/null and b/assets/capture--admin-1771758081055.png differ diff --git a/assets/capture--dashboard-1771758051783.png b/assets/capture--dashboard-1771758051783.png new file mode 100644 index 0000000..dda3321 Binary files /dev/null and b/assets/capture--dashboard-1771758051783.png differ diff --git a/assets/capture--dashboard-1771758179639.png b/assets/capture--dashboard-1771758179639.png new file mode 100644 index 0000000..f3d398b Binary files /dev/null and b/assets/capture--dashboard-1771758179639.png differ diff --git a/assets/capture--dashboard-1771759025109.png b/assets/capture--dashboard-1771759025109.png new file mode 100644 index 0000000..2f56cac Binary files /dev/null and b/assets/capture--dashboard-1771759025109.png differ diff --git a/assets/capture-latest.png b/assets/capture-latest.png new file mode 100644 index 0000000..17d3379 Binary files /dev/null and b/assets/capture-latest.png differ diff --git a/backend/package.json b/backend/package.json index eb5927f..43eb1a5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,13 +9,13 @@ "start": "node dist/server.js", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", - "prisma:seed": "ts-node prisma/seed.ts", + "prisma:seed": "node prisma/seed.js", "prisma:studio": "prisma studio", "db:clean-and-seed": "node prisma/clean-and-seed.js", "test": "jest" }, "prisma": { - "seed": "ts-node prisma/seed.ts" + "seed": "node prisma/seed.js" }, "dependencies": { "@prisma/client": "^5.8.0", diff --git a/backend/prisma/clean-and-seed.js b/backend/prisma/clean-and-seed.js index 93d860e..7c1995b 100644 --- a/backend/prisma/clean-and-seed.js +++ b/backend/prisma/clean-and-seed.js @@ -83,7 +83,7 @@ async function main() { console.log('\n๐ŸŒฑ Running seed...\n'); const backendDir = path.resolve(__dirname, '..'); - execSync('node prisma/seed-prod.js', { + execSync('node prisma/seed.js', { stdio: 'inherit', cwd: backendDir, env: process.env, diff --git a/backend/prisma/ensure-gm-permissions.sql b/backend/prisma/ensure-gm-permissions.sql new file mode 100644 index 0000000..1907f56 --- /dev/null +++ b/backend/prisma/ensure-gm-permissions.sql @@ -0,0 +1,12 @@ +-- Ensure GM has all module permissions +-- Run: npx prisma db execute --file prisma/ensure-gm-permissions.sql + +INSERT INTO position_permissions (id, "positionId", module, resource, actions, "createdAt", "updatedAt") +SELECT gen_random_uuid(), p.id, m.module, '*', '["*"]', NOW(), NOW() +FROM positions p +CROSS JOIN (VALUES ('contacts'), ('crm'), ('inventory'), ('projects'), ('hr'), ('marketing'), ('admin')) AS m(module) +WHERE p.code = 'GM' +AND NOT EXISTS ( + SELECT 1 FROM position_permissions pp + WHERE pp."positionId" = p.id AND pp.module = m.module AND pp.resource = '*' +); diff --git a/backend/prisma/migrations/20260223105732_add_role_user_role_for_multi_group/migration.sql b/backend/prisma/migrations/20260223105732_add_role_user_role_for_multi_group/migration.sql new file mode 100644 index 0000000..0f9e0b9 --- /dev/null +++ b/backend/prisma/migrations/20260223105732_add_role_user_role_for_multi_group/migration.sql @@ -0,0 +1,59 @@ +-- CreateTable +CREATE TABLE "roles" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "nameAr" TEXT, + "description" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "roles_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "role_permissions" ( + "id" TEXT NOT NULL, + "roleId" TEXT NOT NULL, + "module" TEXT NOT NULL, + "resource" TEXT NOT NULL, + "actions" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_roles" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "roleId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_roles_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "roles_name_key" ON "roles"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "role_permissions_roleId_module_resource_key" ON "role_permissions"("roleId", "module", "resource"); + +-- CreateIndex +CREATE INDEX "user_roles_userId_idx" ON "user_roles"("userId"); + +-- CreateIndex +CREATE INDEX "user_roles_roleId_idx" ON "user_roles"("roleId"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_roles_userId_roleId_key" ON "user_roles"("userId", "roleId"); + +-- AddForeignKey +ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 95daa9a..7b568ed 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -69,10 +69,59 @@ model User { assignedTasks Task[] projectMembers ProjectMember[] campaigns Campaign[] + userRoles UserRole[] @@map("users") } +// Optional roles - user can belong to multiple permission groups (Phase 3 multi-group) +model Role { + id String @id @default(uuid()) + name String @unique + nameAr String? + description String? + isActive Boolean @default(true) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + permissions RolePermission[] + userRoles UserRole[] + + @@map("roles") +} + +model RolePermission { + id String @id @default(uuid()) + roleId String + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + module String + resource String + actions Json // ["read", "create", "update", "delete", ...] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([roleId, module, resource]) + @@map("role_permissions") +} + +model UserRole { + id String @id @default(uuid()) + userId String + roleId String + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + + @@unique([userId, roleId]) + @@index([userId]) + @@index([roleId]) + @@map("user_roles") +} + model Employee { id String @id @default(uuid()) uniqueEmployeeId String @unique // ุฑู‚ู… ุงู„ู…ูˆุธู ุงู„ู…ูˆุญุฏ diff --git a/backend/prisma/seed.js b/backend/prisma/seed.js new file mode 100644 index 0000000..c0d87cf --- /dev/null +++ b/backend/prisma/seed.js @@ -0,0 +1,146 @@ +/** + * Minimal seed - System Administrator only. + * Run with: node prisma/seed.js + */ +const { PrismaClient } = require('@prisma/client'); +const bcrypt = require('bcryptjs'); + +const prisma = new PrismaClient(); + +async function main() { + console.log('๐ŸŒฑ Starting database seeding (minimal - System Administrator only)...'); + + const adminDept = await prisma.department.create({ + data: { + name: 'Administration', + nameAr: 'ุงู„ุฅุฏุงุฑุฉ', + code: 'ADMIN', + description: 'System administration and configuration', + }, + }); + + const sysAdminPosition = await prisma.position.create({ + data: { + title: 'System Administrator', + titleAr: 'ู…ุฏูŠุฑ ุงู„ู†ุธุงู…', + code: 'SYS_ADMIN', + departmentId: adminDept.id, + level: 1, + description: 'Full system access - configure and manage all modules', + }, + }); + + const modules = ['contacts', 'crm', 'inventory', 'projects', 'hr', 'marketing', 'admin']; + for (const module of modules) { + await prisma.positionPermission.create({ + data: { + positionId: sysAdminPosition.id, + module, + resource: '*', + actions: ['*'], + }, + }); + } + + // Create Sales Department and restricted positions + const salesDept = await prisma.department.create({ + data: { + name: 'Sales', + nameAr: 'ุงู„ู…ุจูŠุนุงุช', + code: 'SALES', + description: 'Sales and business development', + }, + }); + + const salesRepPosition = await prisma.position.create({ + data: { + title: 'Sales Representative', + titleAr: 'ู…ู†ุฏูˆุจ ู…ุจูŠุนุงุช', + code: 'SALES_REP', + departmentId: salesDept.id, + level: 3, + description: 'Limited access - Contacts and CRM deals', + }, + }); + + await prisma.positionPermission.createMany({ + data: [ + { positionId: salesRepPosition.id, module: 'contacts', resource: '*', actions: ['read', 'create', 'update'] }, + { positionId: salesRepPosition.id, module: 'crm', resource: 'deals', actions: ['read', 'create', 'update'] }, + ], + }); + + const accountantPosition = await prisma.position.create({ + data: { + title: 'Accountant', + titleAr: 'ู…ุญุงุณุจ', + code: 'ACCOUNTANT', + departmentId: adminDept.id, + level: 2, + description: 'HR read, inventory read, contacts read', + }, + }); + + await prisma.positionPermission.createMany({ + data: [ + { positionId: accountantPosition.id, module: 'contacts', resource: '*', actions: ['read'] }, + { positionId: accountantPosition.id, module: 'crm', resource: '*', actions: ['read'] }, + { positionId: accountantPosition.id, module: 'inventory', resource: '*', actions: ['read'] }, + { positionId: accountantPosition.id, module: 'hr', resource: '*', actions: ['read'] }, + ], + }); + + console.log('โœ… Created position and permissions'); + + const sysAdminEmployee = await prisma.employee.create({ + data: { + uniqueEmployeeId: 'SYS-001', + firstName: 'System', + lastName: 'Administrator', + firstNameAr: 'ู…ุฏูŠุฑ', + lastNameAr: 'ุงู„ู†ุธุงู…', + email: 'admin@system.local', + mobile: '+966500000000', + dateOfBirth: new Date('1990-01-01'), + gender: 'MALE', + nationality: 'Saudi', + employmentType: 'Full-time', + contractType: 'Unlimited', + hireDate: new Date(), + departmentId: adminDept.id, + positionId: sysAdminPosition.id, + basicSalary: 0, + status: 'ACTIVE', + }, + }); + + const hashedPassword = await bcrypt.hash('Admin@123', 10); + await prisma.user.create({ + data: { + email: 'admin@system.local', + username: 'admin', + password: hashedPassword, + employeeId: sysAdminEmployee.id, + isActive: true, + }, + }); + + console.log('โœ… Created System Administrator'); + console.log('\n๐ŸŽ‰ Database seeding completed!\n'); + console.log('๐Ÿ“‹ System Administrator:'); + console.log('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”'); + console.log(' Email: admin@system.local'); + console.log(' Username: admin'); + console.log(' Password: Admin@123'); + console.log(' Access: Full system access (all modules)'); + console.log('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n'); +} + +main() + .catch((e) => { + console.error('โŒ Error seeding database:', e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index f624f78..c94ec85 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -4,58 +4,50 @@ import bcrypt from 'bcryptjs'; const prisma = new PrismaClient(); async function main() { - console.log('๐ŸŒฑ Starting database seeding...'); + console.log('๐ŸŒฑ Starting database seeding (minimal - System Administrator only)...'); - // Create Departments + // Create Administration Department + const adminDept = await prisma.department.create({ + data: { + name: 'Administration', + nameAr: 'ุงู„ุฅุฏุงุฑุฉ', + code: 'ADMIN', + description: 'System administration and configuration', + }, + }); + + // Create System Administrator Position + const sysAdminPosition = await prisma.position.create({ + data: { + title: 'System Administrator', + titleAr: 'ู…ุฏูŠุฑ ุงู„ู†ุธุงู…', + code: 'SYS_ADMIN', + departmentId: adminDept.id, + level: 1, + description: 'Full system access - configure and manage all modules', + }, + }); + + // Create full permissions for all modules + const modules = ['contacts', 'crm', 'inventory', 'projects', 'hr', 'marketing', 'admin']; + for (const module of modules) { + await prisma.positionPermission.create({ + data: { + positionId: sysAdminPosition.id, + module, + resource: '*', + actions: ['*'], + }, + }); + } + + // Create Sales Department and restricted positions const salesDept = await prisma.department.create({ data: { - name: 'Sales Department', - nameAr: 'ู‚ุณู… ุงู„ู…ุจูŠุนุงุช', + name: 'Sales', + nameAr: 'ุงู„ู…ุจูŠุนุงุช', code: 'SALES', - description: 'Sales and Business Development', - }, - }); - - const itDept = await prisma.department.create({ - data: { - name: 'IT Department', - nameAr: 'ู‚ุณู… ุชู‚ู†ูŠุฉ ุงู„ู…ุนู„ูˆู…ุงุช', - code: 'IT', - description: 'Information Technology', - }, - }); - - const hrDept = await prisma.department.create({ - data: { - name: 'HR Department', - nameAr: 'ู‚ุณู… ุงู„ู…ูˆุงุฑุฏ ุงู„ุจุดุฑูŠุฉ', - code: 'HR', - description: 'Human Resources', - }, - }); - - console.log('โœ… Created departments'); - - // Create Positions - const gmPosition = await prisma.position.create({ - data: { - title: 'General Manager', - titleAr: 'ุงู„ู…ุฏูŠุฑ ุงู„ุนุงู…', - code: 'GM', - departmentId: salesDept.id, - level: 1, - description: 'Chief Executive - Full Access', - }, - }); - - const salesManagerPosition = await prisma.position.create({ - data: { - title: 'Sales Manager', - titleAr: 'ู…ุฏูŠุฑ ุงู„ู…ุจูŠุนุงุช', - code: 'SALES_MGR', - departmentId: salesDept.id, - level: 2, - description: 'Sales Department Manager', + description: 'Sales and business development', }, }); @@ -66,286 +58,83 @@ async function main() { code: 'SALES_REP', departmentId: salesDept.id, level: 3, - description: 'Sales Representative', + description: 'Limited access - Contacts and CRM deals', }, }); - console.log('โœ… Created positions'); + await prisma.positionPermission.createMany({ + data: [ + { positionId: salesRepPosition.id, module: 'contacts', resource: '*', actions: ['read', 'create', 'update'] }, + { positionId: salesRepPosition.id, module: 'crm', resource: 'deals', actions: ['read', 'create', 'update'] }, + ], + }); - // Create Permissions for GM (Full Access) - const modules = ['contacts', 'crm', 'inventory', 'projects', 'hr', 'marketing']; - const resources = ['*']; - const actions = ['*']; - - for (const module of modules) { - await prisma.positionPermission.create({ - data: { - positionId: gmPosition.id, - module, - resource: resources[0], - actions, - }, - }); - } - - // Admin permission for GM - await prisma.positionPermission.create({ + const accountantPosition = await prisma.position.create({ data: { - positionId: gmPosition.id, - module: 'admin', - resource: '*', - actions: ['*'], + title: 'Accountant', + titleAr: 'ู…ุญุงุณุจ', + code: 'ACCOUNTANT', + departmentId: adminDept.id, + level: 2, + description: 'HR read, inventory read, contacts read', }, }); - // Create Permissions for Sales Manager await prisma.positionPermission.createMany({ data: [ - { - positionId: salesManagerPosition.id, - module: 'contacts', - resource: 'contacts', - actions: ['create', 'read', 'update', 'merge'], - }, - { - positionId: salesManagerPosition.id, - module: 'crm', - resource: 'deals', - actions: ['create', 'read', 'update', 'approve'], - }, - { - positionId: salesManagerPosition.id, - module: 'crm', - resource: 'quotes', - actions: ['create', 'read', 'update', 'approve'], - }, + { positionId: accountantPosition.id, module: 'contacts', resource: '*', actions: ['read'] }, + { positionId: accountantPosition.id, module: 'crm', resource: '*', actions: ['read'] }, + { positionId: accountantPosition.id, module: 'inventory', resource: '*', actions: ['read'] }, + { positionId: accountantPosition.id, module: 'hr', resource: '*', actions: ['read'] }, ], }); - // Create Permissions for Sales Rep - await prisma.positionPermission.createMany({ - data: [ - { - positionId: salesRepPosition.id, - module: 'contacts', - resource: 'contacts', - actions: ['create', 'read', 'update'], - }, - { - positionId: salesRepPosition.id, - module: 'crm', - resource: 'deals', - actions: ['create', 'read', 'update'], - }, - { - positionId: salesRepPosition.id, - module: 'crm', - resource: 'quotes', - actions: ['create', 'read'], - }, - ], - }); + console.log('โœ… Created position and permissions'); - console.log('โœ… Created permissions'); - - // Create Employees - const gmEmployee = await prisma.employee.create({ + // Create minimal Employee for System Administrator + const sysAdminEmployee = await prisma.employee.create({ data: { - uniqueEmployeeId: 'EMP-2024-0001', - firstName: 'Ahmed', - lastName: 'Al-Mutairi', - firstNameAr: 'ุฃุญู…ุฏ', - lastNameAr: 'ุงู„ู…ุทูŠุฑูŠ', - email: 'gm@atmata.com', - mobile: '+966500000001', - dateOfBirth: new Date('1980-01-01'), + uniqueEmployeeId: 'SYS-001', + firstName: 'System', + lastName: 'Administrator', + firstNameAr: 'ู…ุฏูŠุฑ', + lastNameAr: 'ุงู„ู†ุธุงู…', + email: 'admin@system.local', + mobile: '+966500000000', + dateOfBirth: new Date('1990-01-01'), gender: 'MALE', nationality: 'Saudi', employmentType: 'Full-time', contractType: 'Unlimited', - hireDate: new Date('2020-01-01'), - departmentId: salesDept.id, - positionId: gmPosition.id, - basicSalary: 50000, + hireDate: new Date(), + departmentId: adminDept.id, + positionId: sysAdminPosition.id, + basicSalary: 0, status: 'ACTIVE', }, }); - const salesManagerEmployee = await prisma.employee.create({ - data: { - uniqueEmployeeId: 'EMP-2024-0002', - firstName: 'Fatima', - lastName: 'Al-Zahrani', - firstNameAr: 'ูุงุทู…ุฉ', - lastNameAr: 'ุงู„ุฒู‡ุฑุงู†ูŠ', - email: 'sales.manager@atmata.com', - mobile: '+966500000002', - dateOfBirth: new Date('1985-05-15'), - gender: 'FEMALE', - nationality: 'Saudi', - employmentType: 'Full-time', - contractType: 'Unlimited', - hireDate: new Date('2021-06-01'), - departmentId: salesDept.id, - positionId: salesManagerPosition.id, - reportingToId: gmEmployee.id, - basicSalary: 25000, - status: 'ACTIVE', - }, - }); - - const salesRepEmployee = await prisma.employee.create({ - data: { - uniqueEmployeeId: 'EMP-2024-0003', - firstName: 'Mohammed', - lastName: 'Al-Qahtani', - firstNameAr: 'ู…ุญู…ุฏ', - lastNameAr: 'ุงู„ู‚ุญุทุงู†ูŠ', - email: 'sales.rep@atmata.com', - mobile: '+966500000003', - dateOfBirth: new Date('1992-08-20'), - gender: 'MALE', - nationality: 'Saudi', - employmentType: 'Full-time', - contractType: 'Fixed', - hireDate: new Date('2023-01-15'), - departmentId: salesDept.id, - positionId: salesRepPosition.id, - reportingToId: salesManagerEmployee.id, - basicSalary: 12000, - status: 'ACTIVE', - }, - }); - - console.log('โœ… Created employees'); - - // Create Users + // Create System Administrator User const hashedPassword = await bcrypt.hash('Admin@123', 10); - - const gmUser = await prisma.user.create({ + await prisma.user.create({ data: { - email: 'gm@atmata.com', + email: 'admin@system.local', username: 'admin', password: hashedPassword, - employeeId: gmEmployee.id, + employeeId: sysAdminEmployee.id, isActive: true, }, }); - const salesManagerUser = await prisma.user.create({ - data: { - email: 'sales.manager@atmata.com', - username: 'salesmanager', - password: hashedPassword, - employeeId: salesManagerEmployee.id, - isActive: true, - }, - }); + console.log('โœ… Created System Administrator'); - const salesRepUser = await prisma.user.create({ - data: { - email: 'sales.rep@atmata.com', - username: 'salesrep', - password: hashedPassword, - employeeId: salesRepEmployee.id, - isActive: true, - }, - }); - - console.log('โœ… Created users'); - - // Create Contact Categories - await prisma.contactCategory.createMany({ - data: [ - { name: 'Customer', nameAr: 'ุนู…ูŠู„', description: 'Paying customers' }, - { name: 'Supplier', nameAr: 'ู…ูˆุฑุฏ', description: 'Product/Service suppliers' }, - { name: 'Partner', nameAr: 'ุดุฑูŠูƒ', description: 'Business partners' }, - { name: 'Lead', nameAr: 'ุนู…ูŠู„ ู…ุญุชู…ู„', description: 'Potential customers' }, - { name: 'Company Employee', nameAr: 'ู…ูˆุธู ุงู„ุดุฑูƒุฉ', description: 'Internal company staff' }, - ], - }); - - console.log('โœ… Created contact categories'); - - // Create Product Categories - await prisma.productCategory.createMany({ - data: [ - { name: 'Electronics', nameAr: 'ุฅู„ูƒุชุฑูˆู†ูŠุงุช', code: 'ELEC' }, - { name: 'Software', nameAr: 'ุจุฑู…ุฌูŠุงุช', code: 'SOFT' }, - { name: 'Services', nameAr: 'ุฎุฏู…ุงุช', code: 'SERV' }, - ], - }); - - console.log('โœ… Created product categories'); - - // Create Pipelines - await prisma.pipeline.create({ - data: { - name: 'B2B Sales Pipeline', - nameAr: 'ู…ุณุงุฑ ู…ุจูŠุนุงุช ุงู„ุดุฑูƒุงุช', - structure: 'B2B', - stages: [ - { name: 'OPEN', nameAr: 'ู…ูุชูˆุญุฉ', order: 1 }, - { name: 'QUALIFIED', nameAr: 'ู…ุคู‡ู„ุฉ', order: 2 }, - { name: 'NEGOTIATION', nameAr: 'ุชูุงูˆุถ', order: 3 }, - { name: 'PROPOSAL', nameAr: 'ุนุฑุถ ุณุนุฑ', order: 4 }, - { name: 'WON', nameAr: 'ูุงุฒุช', order: 5 }, - { name: 'LOST', nameAr: 'ุฎุณุฑุช', order: 6 }, - ], - isActive: true, - }, - }); - - await prisma.pipeline.create({ - data: { - name: 'B2C Sales Pipeline', - nameAr: 'ู…ุณุงุฑ ู…ุจูŠุนุงุช ุงู„ุฃูุฑุงุฏ', - structure: 'B2C', - stages: [ - { name: 'LEAD', nameAr: 'ุนู…ูŠู„ ู…ุญุชู…ู„', order: 1 }, - { name: 'CONTACTED', nameAr: 'ุชู… ุงู„ุชูˆุงุตู„', order: 2 }, - { name: 'QUALIFIED', nameAr: 'ู…ุคู‡ู„', order: 3 }, - { name: 'WON', nameAr: 'ุจูŠุน', order: 4 }, - { name: 'LOST', nameAr: 'ุฎุณุงุฑุฉ', order: 5 }, - ], - isActive: true, - }, - }); - - console.log('โœ… Created pipelines'); - - // Create sample warehouse - await prisma.warehouse.create({ - data: { - code: 'WH-MAIN', - name: 'Main Warehouse', - nameAr: 'ุงู„ู…ุณุชูˆุฏุน ุงู„ุฑุฆูŠุณูŠ', - type: 'MAIN', - city: 'Riyadh', - country: 'Saudi Arabia', - isActive: true, - }, - }); - - console.log('โœ… Created warehouse'); - - console.log('\n๐ŸŽ‰ Database seeding completed successfully!\n'); - console.log('๐Ÿ“‹ Default Users Created:'); + console.log('\n๐ŸŽ‰ Database seeding completed!\n'); + console.log('๐Ÿ“‹ System Administrator:'); console.log('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”'); - console.log('1. General Manager'); - console.log(' Email: gm@atmata.com'); + console.log(' Email: admin@system.local'); + console.log(' Username: admin'); console.log(' Password: Admin@123'); - console.log(' Access: Full System Access'); - console.log(''); - console.log('2. Sales Manager'); - console.log(' Email: sales.manager@atmata.com'); - console.log(' Password: Admin@123'); - console.log(' Access: Contacts, CRM with approvals'); - console.log(''); - console.log('3. Sales Representative'); - console.log(' Email: sales.rep@atmata.com'); - console.log(' Password: Admin@123'); - console.log(' Access: Basic Contacts and CRM'); + console.log(' Access: Full system access (all modules)'); console.log('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n'); } @@ -357,4 +146,3 @@ main() .finally(async () => { await prisma.$disconnect(); }); - diff --git a/backend/scripts/ensure-gm-permissions.ts b/backend/scripts/ensure-gm-permissions.ts new file mode 100644 index 0000000..61d98ce --- /dev/null +++ b/backend/scripts/ensure-gm-permissions.ts @@ -0,0 +1,51 @@ +/** + * Ensure GM position has all module permissions. + * Adds any missing permissions for: contacts, crm, inventory, projects, hr, marketing, admin + */ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +const GM_MODULES = ['contacts', 'crm', 'inventory', 'projects', 'hr', 'marketing', 'admin']; + +async function main() { + const gmPosition = await prisma.position.findFirst({ where: { code: 'GM' } }); + if (!gmPosition) { + console.log('GM position not found.'); + process.exit(1); + } + + const existing = await prisma.positionPermission.findMany({ + where: { positionId: gmPosition.id }, + select: { module: true }, + }); + const existingModules = new Set(existing.map((p) => p.module)); + + let added = 0; + for (const module of GM_MODULES) { + if (existingModules.has(module)) continue; + await prisma.positionPermission.create({ + data: { + positionId: gmPosition.id, + module, + resource: '*', + actions: ['*'], + }, + }); + console.log(`Added permission: ${module}`); + added++; + } + + if (added === 0) { + console.log('All GM permissions already exist.'); + } else { + console.log(`Added ${added} permission(s).`); + } +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/backend/scripts/run-production-clean-and-seed.sh b/backend/scripts/run-production-clean-and-seed.sh index 79938cf..e639d72 100755 --- a/backend/scripts/run-production-clean-and-seed.sh +++ b/backend/scripts/run-production-clean-and-seed.sh @@ -53,4 +53,4 @@ npm run db:clean-and-seed echo "" echo "โœ… Done. Restart the application so it uses the cleaned database." -echo " Default logins: gm@atmata.com / sales.manager@atmata.com / sales.rep@atmata.com (Password: Admin@123)" +echo " System Administrator: admin@system.local (Password: Admin@123)" diff --git a/backend/src/modules/admin/admin.controller.ts b/backend/src/modules/admin/admin.controller.ts index 441e44e..24ab2b9 100644 --- a/backend/src/modules/admin/admin.controller.ts +++ b/backend/src/modules/admin/admin.controller.ts @@ -134,6 +134,40 @@ class AdminController { } } + async createPosition(req: AuthRequest, res: Response, next: NextFunction) { + try { + const position = await adminService.createPosition({ + title: req.body.title, + titleAr: req.body.titleAr, + code: req.body.code, + departmentId: req.body.departmentId, + level: req.body.level, + description: req.body.description, + isActive: req.body.isActive, + }); + res.status(201).json(ResponseFormatter.success(position)); + } catch (error) { + next(error); + } + } + + async updatePosition(req: AuthRequest, res: Response, next: NextFunction) { + try { + const position = await adminService.updatePosition(req.params.id, { + title: req.body.title, + titleAr: req.body.titleAr, + code: req.body.code, + departmentId: req.body.departmentId, + level: req.body.level, + description: req.body.description, + isActive: req.body.isActive, + }); + res.json(ResponseFormatter.success(position)); + } catch (error) { + next(error); + } + } + async updatePositionPermissions(req: AuthRequest, res: Response, next: NextFunction) { try { const position = await adminService.updatePositionPermissions( @@ -145,6 +179,74 @@ class AdminController { next(error); } } + + // ========== PERMISSION GROUPS (Phase 3) ========== + + async getPermissionGroups(req: AuthRequest, res: Response, next: NextFunction) { + try { + const groups = await adminService.getPermissionGroups(); + res.json(ResponseFormatter.success(groups)); + } catch (error) { + next(error); + } + } + + async createPermissionGroup(req: AuthRequest, res: Response, next: NextFunction) { + try { + const group = await adminService.createPermissionGroup(req.body); + res.status(201).json(ResponseFormatter.success(group)); + } catch (error) { + next(error); + } + } + + async updatePermissionGroup(req: AuthRequest, res: Response, next: NextFunction) { + try { + const group = await adminService.updatePermissionGroup(req.params.id, req.body); + res.json(ResponseFormatter.success(group)); + } catch (error) { + next(error); + } + } + + async updatePermissionGroupPermissions(req: AuthRequest, res: Response, next: NextFunction) { + try { + const group = await adminService.updatePermissionGroupPermissions( + req.params.id, + req.body.permissions + ); + res.json(ResponseFormatter.success(group)); + } catch (error) { + next(error); + } + } + + async getUserRoles(req: AuthRequest, res: Response, next: NextFunction) { + try { + const roles = await adminService.getUserRoles(req.params.userId); + res.json(ResponseFormatter.success(roles)); + } catch (error) { + next(error); + } + } + + async assignUserRole(req: AuthRequest, res: Response, next: NextFunction) { + try { + const userRole = await adminService.assignUserRole(req.params.userId, req.body.roleId); + res.status(201).json(ResponseFormatter.success(userRole)); + } catch (error) { + next(error); + } + } + + async removeUserRole(req: AuthRequest, res: Response, next: NextFunction) { + try { + await adminService.removeUserRole(req.params.userId, req.params.roleId); + res.json(ResponseFormatter.success({ success: true })); + } catch (error) { + next(error); + } + } } export const adminController = new AdminController(); diff --git a/backend/src/modules/admin/admin.routes.ts b/backend/src/modules/admin/admin.routes.ts index 3ebf286..1692ee5 100644 --- a/backend/src/modules/admin/admin.routes.ts +++ b/backend/src/modules/admin/admin.routes.ts @@ -89,6 +89,33 @@ router.get( adminController.getPositions ); +router.post( + '/positions', + authorize('admin', 'roles', 'create'), + [ + body('title').notEmpty().trim(), + body('code').notEmpty().trim(), + body('departmentId').isUUID(), + body('level').optional().isInt({ min: 1 }), + ], + validate, + adminController.createPosition +); + +router.put( + '/positions/:id', + authorize('admin', 'roles', 'update'), + [ + param('id').isUUID(), + body('title').optional().notEmpty().trim(), + body('code').optional().notEmpty().trim(), + body('departmentId').optional().isUUID(), + body('level').optional().isInt({ min: 1 }), + ], + validate, + adminController.updatePosition +); + router.put( '/positions/:id/permissions', authorize('admin', 'roles', 'update'), @@ -100,4 +127,68 @@ router.put( adminController.updatePositionPermissions ); +// ========== PERMISSION GROUPS (Phase 3 - multi-group) ========== + +router.get( + '/permission-groups', + authorize('admin', 'roles', 'read'), + adminController.getPermissionGroups +); + +router.post( + '/permission-groups', + authorize('admin', 'roles', 'create'), + [ + body('name').notEmpty().trim(), + ], + validate, + adminController.createPermissionGroup +); + +router.put( + '/permission-groups/:id', + authorize('admin', 'roles', 'update'), + [param('id').isUUID()], + validate, + adminController.updatePermissionGroup +); + +router.put( + '/permission-groups/:id/permissions', + authorize('admin', 'roles', 'update'), + [ + param('id').isUUID(), + body('permissions').isArray(), + ], + validate, + adminController.updatePermissionGroupPermissions +); + +router.get( + '/users/:userId/roles', + authorize('admin', 'users', 'read'), + [param('userId').isUUID()], + validate, + adminController.getUserRoles +); + +router.post( + '/users/:userId/roles', + authorize('admin', 'users', 'update'), + [ + param('userId').isUUID(), + body('roleId').isUUID(), + ], + validate, + adminController.assignUserRole +); + +router.delete( + '/users/:userId/roles/:roleId', + authorize('admin', 'users', 'update'), + [param('userId').isUUID(), param('roleId').isUUID()], + validate, + adminController.removeUserRole +); + export default router; diff --git a/backend/src/modules/admin/admin.service.ts b/backend/src/modules/admin/admin.service.ts index d73e12b..1282f4b 100644 --- a/backend/src/modules/admin/admin.service.ts +++ b/backend/src/modules/admin/admin.service.ts @@ -406,6 +406,102 @@ class AdminService { return withUserCount; } + async createPosition(data: { + title: string; + titleAr?: string; + code: string; + departmentId: string; + level?: number; + description?: string; + isActive?: boolean; + }) { + const existing = await prisma.position.findUnique({ + where: { code: data.code }, + }); + if (existing) { + throw new AppError(400, 'ูƒูˆุฏ ุงู„ุฏูˆุฑ ู…ุณุชุฎุฏู… - Position code already exists'); + } + + const dept = await prisma.department.findUnique({ + where: { id: data.departmentId }, + }); + if (!dept) { + throw new AppError(400, 'ุงู„ู‚ุณู… ุบูŠุฑ ู…ูˆุฌูˆุฏ - Department not found'); + } + + return prisma.position.create({ + data: { + title: data.title, + titleAr: data.titleAr, + code: data.code.trim().toUpperCase().replace(/\s+/g, '_'), + departmentId: data.departmentId, + level: data.level ?? 5, + description: data.description, + isActive: data.isActive ?? true, + }, + include: { + department: { select: { name: true, nameAr: true } }, + permissions: true, + }, + }); + } + + async updatePosition( + positionId: string, + data: { + title?: string; + titleAr?: string; + code?: string; + departmentId?: string; + level?: number; + description?: string; + isActive?: boolean; + } + ) { + const position = await prisma.position.findUnique({ + where: { id: positionId }, + }); + if (!position) { + throw new AppError(404, 'ุงู„ุฏูˆุฑ ุบูŠุฑ ู…ูˆุฌูˆุฏ - Position not found'); + } + + if (data.code && data.code !== position.code) { + const existing = await prisma.position.findUnique({ + where: { code: data.code }, + }); + if (existing) { + throw new AppError(400, 'ูƒูˆุฏ ุงู„ุฏูˆุฑ ู…ุณุชุฎุฏู… - Position code already exists'); + } + } + + if (data.departmentId && data.departmentId !== position.departmentId) { + const dept = await prisma.department.findUnique({ + where: { id: data.departmentId }, + }); + if (!dept) { + throw new AppError(400, 'ุงู„ู‚ุณู… ุบูŠุฑ ู…ูˆุฌูˆุฏ - Department not found'); + } + } + + const updateData: Record = {}; + if (data.title !== undefined) updateData.title = data.title; + if (data.titleAr !== undefined) updateData.titleAr = data.titleAr; + if (data.code !== undefined) updateData.code = data.code.trim().toUpperCase().replace(/\s+/g, '_'); + if (data.departmentId !== undefined) updateData.departmentId = data.departmentId; + if (data.level !== undefined) updateData.level = data.level; + if (data.description !== undefined) updateData.description = data.description; + if (data.isActive !== undefined) updateData.isActive = data.isActive; + + return prisma.position.update({ + where: { id: positionId }, + data: updateData, + include: { + department: { select: { name: true, nameAr: true } }, + permissions: true, + }, + }); + } + async updatePositionPermissions(positionId: string, permissions: Array<{ module: string; resource: string; actions: string[] }>) { const position = await prisma.position.findUnique({ where: { id: positionId } }); if (!position) { @@ -429,6 +525,116 @@ class AdminService { return this.getPositions().then((pos) => pos.find((p) => p.id === positionId) || position); } + + // ========== PERMISSION GROUPS (Phase 3 - optional roles for multi-group) ========== + + async getPermissionGroups() { + return prisma.role.findMany({ + where: { isActive: true }, + include: { + permissions: true, + _count: { select: { userRoles: true } }, + }, + orderBy: { name: 'asc' }, + }); + } + + async createPermissionGroup(data: { name: string; nameAr?: string; description?: string }) { + const existing = await prisma.role.findUnique({ where: { name: data.name } }); + if (existing) { + throw new AppError(400, 'ุงุณู… ุงู„ู…ุฌู…ูˆุนุฉ ู…ุณุชุฎุฏู… - Group name already exists'); + } + return prisma.role.create({ + data: { + name: data.name, + nameAr: data.nameAr, + description: data.description, + }, + include: { permissions: true }, + }); + } + + async updatePermissionGroup( + id: string, + data: { name?: string; nameAr?: string; description?: string; isActive?: boolean } + ) { + const role = await prisma.role.findUnique({ where: { id } }); + if (!role) { + throw new AppError(404, 'ุงู„ู…ุฌู…ูˆุนุฉ ุบูŠุฑ ู…ูˆุฌูˆุฏุฉ - Group not found'); + } + if (data.name && data.name !== role.name) { + const existing = await prisma.role.findUnique({ where: { name: data.name } }); + if (existing) { + throw new AppError(400, 'ุงุณู… ุงู„ู…ุฌู…ูˆุนุฉ ู…ุณุชุฎุฏู… - Group name already exists'); + } + } + return prisma.role.update({ + where: { id }, + data, + include: { permissions: true }, + }); + } + + async updatePermissionGroupPermissions( + roleId: string, + permissions: Array<{ module: string; resource: string; actions: string[] }> + ) { + await prisma.rolePermission.deleteMany({ where: { roleId } }); + if (permissions.length > 0) { + await prisma.rolePermission.createMany({ + data: permissions.map((p) => ({ + roleId, + module: p.module, + resource: p.resource, + actions: p.actions, + })), + }); + } + return prisma.role.findUnique({ + where: { id: roleId }, + include: { permissions: true }, + }); + } + + async getUserRoles(userId: string) { + return prisma.userRole.findMany({ + where: { userId }, + include: { + role: { include: { permissions: true } }, + }, + }); + } + + async assignUserRole(userId: string, roleId: string) { + const [user, role] = await Promise.all([ + prisma.user.findUnique({ where: { id: userId } }), + prisma.role.findFirst({ where: { id: roleId, isActive: true } }), + ]); + if (!user) throw new AppError(404, 'ุงู„ู…ุณุชุฎุฏู… ุบูŠุฑ ู…ูˆุฌูˆุฏ - User not found'); + if (!role) throw new AppError(404, 'ุงู„ู…ุฌู…ูˆุนุฉ ุบูŠุฑ ู…ูˆุฌูˆุฏุฉ - Group not found'); + + const existing = await prisma.userRole.findUnique({ + where: { userId_roleId: { userId, roleId } }, + }); + if (existing) { + throw new AppError(400, 'ุงู„ู…ุณุชุฎุฏู… ู…ู†ุชู…ูŠ ุจุงู„ูุนู„ ู„ู‡ุฐู‡ ุงู„ู…ุฌู…ูˆุนุฉ - User already in group'); + } + + return prisma.userRole.create({ + data: { userId, roleId }, + include: { role: true }, + }); + } + + async removeUserRole(userId: string, roleId: string) { + const deleted = await prisma.userRole.deleteMany({ + where: { userId, roleId }, + }); + if (deleted.count === 0) { + throw new AppError(404, 'ู„ู… ูŠุชู… ุงู„ุนุซูˆุฑ ุนู„ู‰ ุงู„ุงู†ุชู…ุงุก - User not in group'); + } + return { success: true }; + } } export const adminService = new AdminService(); diff --git a/backend/src/modules/dashboard/dashboard.controller.ts b/backend/src/modules/dashboard/dashboard.controller.ts new file mode 100644 index 0000000..aab6f75 --- /dev/null +++ b/backend/src/modules/dashboard/dashboard.controller.ts @@ -0,0 +1,35 @@ +import { Response } from 'express'; +import prisma from '../../config/database'; +import { AuthRequest } from '../../shared/middleware/auth'; +import { ResponseFormatter } from '../../shared/utils/responseFormatter'; + +class DashboardController { + async getStats(req: AuthRequest, res: Response) { + const userId = req.user!.id; + + const [contactsCount, activeTasksCount, unreadNotificationsCount] = await Promise.all([ + prisma.contact.count(), + prisma.task.count({ + where: { + status: { notIn: ['COMPLETED', 'CANCELLED'] }, + }, + }), + prisma.notification.count({ + where: { + userId, + isRead: false, + }, + }), + ]); + + res.json( + ResponseFormatter.success({ + contacts: contactsCount, + activeTasks: activeTasksCount, + notifications: unreadNotificationsCount, + }) + ); + } +} + +export default new DashboardController(); diff --git a/backend/src/modules/dashboard/dashboard.routes.ts b/backend/src/modules/dashboard/dashboard.routes.ts new file mode 100644 index 0000000..45b13c7 --- /dev/null +++ b/backend/src/modules/dashboard/dashboard.routes.ts @@ -0,0 +1,9 @@ +import { Router } from 'express'; +import { authenticate } from '../../shared/middleware/auth'; +import dashboardController from './dashboard.controller'; + +const router = Router(); + +router.get('/stats', authenticate, dashboardController.getStats.bind(dashboardController)); + +export default router; diff --git a/backend/src/modules/hr/hr.controller.ts b/backend/src/modules/hr/hr.controller.ts index 1ac81c3..e2bd534 100644 --- a/backend/src/modules/hr/hr.controller.ts +++ b/backend/src/modules/hr/hr.controller.ts @@ -19,15 +19,21 @@ export class HRController { async findAllEmployees(req: AuthRequest, res: Response, next: NextFunction) { try { - const page = parseInt(req.query.page as string) || 1; - const pageSize = parseInt(req.query.pageSize as string) || 20; - - const filters = { - search: req.query.search, - departmentId: req.query.departmentId, - status: req.query.status, - }; - + const rawPage = parseInt(req.query.page as string, 10); + const rawPageSize = parseInt(req.query.pageSize as string, 10); + const page = Number.isNaN(rawPage) || rawPage < 1 ? 1 : rawPage; + const pageSize = Number.isNaN(rawPageSize) || rawPageSize < 1 || rawPageSize > 100 ? 20 : rawPageSize; + + const rawSearch = req.query.search as string; + const rawDepartmentId = req.query.departmentId as string; + const rawStatus = req.query.status as string; + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + + const filters: Record = {}; + if (rawSearch && typeof rawSearch === 'string' && rawSearch.trim()) filters.search = rawSearch.trim(); + if (rawDepartmentId && uuidRegex.test(rawDepartmentId)) filters.departmentId = rawDepartmentId; + if (rawStatus && rawStatus !== 'all' && rawStatus.trim()) filters.status = rawStatus; + const result = await hrService.findAllEmployees(filters, page, pageSize); res.json(ResponseFormatter.paginated(result.employees, result.total, result.page, result.pageSize)); } catch (error) { @@ -135,6 +141,42 @@ export class HRController { } } + async getDepartmentsHierarchy(req: AuthRequest, res: Response, next: NextFunction) { + try { + const tree = await hrService.getDepartmentsHierarchy(); + res.json(ResponseFormatter.success(tree)); + } catch (error) { + next(error); + } + } + + async createDepartment(req: AuthRequest, res: Response, next: NextFunction) { + try { + const department = await hrService.createDepartment(req.body, req.user!.id); + res.status(201).json(ResponseFormatter.success(department, 'ุชู… ุฅุถุงูุฉ ุงู„ู‚ุณู… ุจู†ุฌุงุญ - Department created')); + } catch (error) { + next(error); + } + } + + async updateDepartment(req: AuthRequest, res: Response, next: NextFunction) { + try { + const department = await hrService.updateDepartment(req.params.id, req.body, req.user!.id); + res.json(ResponseFormatter.success(department, 'ุชู… ุชุญุฏูŠุซ ุงู„ู‚ุณู… - Department updated')); + } catch (error) { + next(error); + } + } + + async deleteDepartment(req: AuthRequest, res: Response, next: NextFunction) { + try { + await hrService.deleteDepartment(req.params.id, req.user!.id); + res.json(ResponseFormatter.success({ success: true }, 'ุชู… ุญุฐู ุงู„ู‚ุณู… - Department deleted')); + } catch (error) { + next(error); + } + } + // ========== POSITIONS ========== async findAllPositions(req: AuthRequest, res: Response, next: NextFunction) { diff --git a/backend/src/modules/hr/hr.routes.ts b/backend/src/modules/hr/hr.routes.ts index cbaa267..fc2cec3 100644 --- a/backend/src/modules/hr/hr.routes.ts +++ b/backend/src/modules/hr/hr.routes.ts @@ -32,6 +32,10 @@ router.post('/salaries/process', authorize('hr', 'salaries', 'process'), hrContr // ========== DEPARTMENTS ========== router.get('/departments', authorize('hr', 'all', 'read'), hrController.findAllDepartments); +router.get('/departments/hierarchy', authorize('hr', 'all', 'read'), hrController.getDepartmentsHierarchy); +router.post('/departments', authorize('hr', 'all', 'create'), hrController.createDepartment); +router.put('/departments/:id', authorize('hr', 'all', 'update'), hrController.updateDepartment); +router.delete('/departments/:id', authorize('hr', 'all', 'delete'), hrController.deleteDepartment); // ========== POSITIONS ========== diff --git a/backend/src/modules/hr/hr.service.ts b/backend/src/modules/hr/hr.service.ts index d09567e..3058ce2 100644 --- a/backend/src/modules/hr/hr.service.ts +++ b/backend/src/modules/hr/hr.service.ts @@ -5,14 +5,52 @@ import { AuditLogger } from '../../shared/utils/auditLogger'; class HRService { // ========== EMPLOYEES ========== + private normalizeEmployeeData(data: any): Record { + const toStr = (v: any) => (v != null && String(v).trim()) ? String(v).trim() : undefined; + const toDate = (v: any) => { + if (!v || !String(v).trim()) return undefined; + const d = new Date(v); + return isNaN(d.getTime()) ? undefined : d; + }; + const toNum = (v: any) => (v != null && v !== '') ? Number(v) : undefined; + + const raw: Record = { + firstName: toStr(data.firstName), + lastName: toStr(data.lastName), + firstNameAr: toStr(data.firstNameAr), + lastNameAr: toStr(data.lastNameAr), + email: toStr(data.email), + phone: toStr(data.phone), + mobile: toStr(data.mobile), + dateOfBirth: toDate(data.dateOfBirth), + gender: toStr(data.gender), + nationality: toStr(data.nationality), + nationalId: toStr(data.nationalId), + employmentType: toStr(data.employmentType), + contractType: toStr(data.contractType), + hireDate: toDate(data.hireDate), + departmentId: toStr(data.departmentId), + positionId: toStr(data.positionId), + reportingToId: toStr(data.reportingToId) || undefined, + basicSalary: toNum(data.baseSalary ?? data.basicSalary) ?? 0, + }; + return Object.fromEntries(Object.entries(raw).filter(([, v]) => v !== undefined)); + } + async createEmployee(data: any, userId: string) { const uniqueEmployeeId = await this.generateEmployeeId(); + const payload = this.normalizeEmployeeData(data); + + if (!payload.firstName || !payload.lastName || !payload.email || !payload.mobile || + !payload.hireDate || !payload.departmentId || !payload.positionId) { + throw new AppError(400, 'ุจูŠุงู†ุงุช ุบูŠุฑ ู…ูƒุชู…ู„ุฉ - Missing required fields: firstName, lastName, email, mobile, hireDate, departmentId, positionId'); + } const employee = await prisma.employee.create({ data: { uniqueEmployeeId, - ...data, - }, + ...payload, + } as any, include: { department: true, position: true, @@ -132,9 +170,10 @@ class HRService { throw new AppError(404, 'ุงู„ู…ูˆุธู ุบูŠุฑ ู…ูˆุฌูˆุฏ - Employee not found'); } + const payload = this.normalizeEmployeeData(data); const employee = await prisma.employee.update({ where: { id }, - data, + data: payload, include: { department: true, position: true, @@ -383,11 +422,112 @@ class HRService { async findAllDepartments() { const departments = await prisma.department.findMany({ where: { isActive: true }, + include: { + parent: { select: { id: true, name: true, nameAr: true } }, + _count: { select: { children: true, employees: true } } + }, orderBy: { name: 'asc' } }); return departments; } + async getDepartmentsHierarchy() { + const departments = await prisma.department.findMany({ + where: { isActive: true }, + include: { + parent: { select: { id: true, name: true, nameAr: true } }, + employees: { + where: { status: 'ACTIVE' }, + select: { id: true, firstName: true, lastName: true, firstNameAr: true, lastNameAr: true, position: { select: { title: true, titleAr: true } } } + }, + positions: { select: { id: true, title: true, titleAr: true } }, + _count: { select: { children: true, employees: true } } + }, + orderBy: { name: 'asc' } + }); + const buildTree = (parentId: string | null): any[] => + departments + .filter((d) => d.parentId === parentId) + .map((d) => ({ + id: d.id, + name: d.name, + nameAr: d.nameAr, + code: d.code, + parentId: d.parentId, + description: d.description, + employees: d.employees, + positions: d.positions, + _count: d._count, + children: buildTree(d.id) + })); + return buildTree(null); + } + + async createDepartment(data: { name: string; nameAr?: string; code: string; parentId?: string; description?: string }, userId: string) { + const existing = await prisma.department.findUnique({ where: { code: data.code } }); + if (existing) { + throw new AppError(400, 'ูƒูˆุฏ ุงู„ู‚ุณู… ู…ุณุชุฎุฏู… ู…ุณุจู‚ุงู‹ - Department code already exists'); + } + if (data.parentId) { + const parent = await prisma.department.findUnique({ where: { id: data.parentId } }); + if (!parent) { + throw new AppError(400, 'ุงู„ู‚ุณู… ุงู„ุฃุจ ุบูŠุฑ ู…ูˆุฌูˆุฏ - Parent department not found'); + } + } + const department = await prisma.department.create({ + data: { + name: data.name, + nameAr: data.nameAr, + code: data.code, + parentId: data.parentId || null, + description: data.description + } + }); + await AuditLogger.log({ entityType: 'DEPARTMENT', entityId: department.id, action: 'CREATE', userId, changes: { created: department } }); + return department; + } + + async updateDepartment(id: string, data: { name?: string; nameAr?: string; code?: string; parentId?: string; description?: string; isActive?: boolean }, userId: string) { + const existing = await prisma.department.findUnique({ where: { id } }); + if (!existing) { + throw new AppError(404, 'ุงู„ู‚ุณู… ุบูŠุฑ ู…ูˆุฌูˆุฏ - Department not found'); + } + if (data.code && data.code !== existing.code) { + const duplicate = await prisma.department.findUnique({ where: { code: data.code } }); + if (duplicate) { + throw new AppError(400, 'ูƒูˆุฏ ุงู„ู‚ุณู… ู…ุณุชุฎุฏู… ู…ุณุจู‚ุงู‹ - Department code already exists'); + } + } + if (data.parentId === id) { + throw new AppError(400, 'ู„ุง ูŠู…ูƒู† ุชุนูŠูŠู† ุงู„ู‚ุณู… ูƒุฃุจ ู„ู†ูุณู‡ - Department cannot be its own parent'); + } + const department = await prisma.department.update({ + where: { id }, + data: { name: data.name, nameAr: data.nameAr, code: data.code, parentId: data.parentId ?? undefined, description: data.description, isActive: data.isActive } + }); + await AuditLogger.log({ entityType: 'DEPARTMENT', entityId: id, action: 'UPDATE', userId, changes: { before: existing, after: department } }); + return department; + } + + async deleteDepartment(id: string, userId: string) { + const dept = await prisma.department.findUnique({ + where: { id }, + include: { _count: { select: { children: true, employees: true } } } + }); + if (!dept) { + throw new AppError(404, 'ุงู„ู‚ุณู… ุบูŠุฑ ู…ูˆุฌูˆุฏ - Department not found'); + } + if (dept._count.children > 0) { + throw new AppError(400, 'ู„ุง ูŠู…ูƒู† ุญุฐู ู‚ุณู… ูŠุญุชูˆูŠ ุนู„ู‰ ุฃู‚ุณุงู… ูุฑุนูŠุฉ - Cannot delete department with sub-departments'); + } + if (dept._count.employees > 0) { + throw new AppError(400, 'ู„ุง ูŠู…ูƒู† ุญุฐู ู‚ุณู… ููŠู‡ ู…ูˆุธููˆู† - Cannot delete department with employees'); + } + await prisma.department.delete({ where: { id } }); + await AuditLogger.log({ entityType: 'DEPARTMENT', entityId: id, action: 'DELETE', userId }); + return { success: true }; + } + // ========== POSITIONS ========== async findAllPositions() { diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index fb35791..7b06cd4 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -3,6 +3,7 @@ import adminRoutes from '../modules/admin/admin.routes'; import authRoutes from '../modules/auth/auth.routes'; import contactsRoutes from '../modules/contacts/contacts.routes'; import crmRoutes from '../modules/crm/crm.routes'; +import dashboardRoutes from '../modules/dashboard/dashboard.routes'; import hrRoutes from '../modules/hr/hr.routes'; import inventoryRoutes from '../modules/inventory/inventory.routes'; import projectsRoutes from '../modules/projects/projects.routes'; @@ -12,6 +13,7 @@ const router = Router(); // Module routes router.use('/admin', adminRoutes); +router.use('/dashboard', dashboardRoutes); router.use('/auth', authRoutes); router.use('/contacts', contactsRoutes); router.use('/crm', crmRoutes); diff --git a/backend/src/shared/middleware/auth.ts b/backend/src/shared/middleware/auth.ts index 2c38819..44aafa3 100644 --- a/backend/src/shared/middleware/auth.ts +++ b/backend/src/shared/middleware/auth.ts @@ -4,12 +4,47 @@ import { config } from '../../config'; import { AppError } from './errorHandler'; import prisma from '../../config/database'; +export interface EffectivePermission { + module: string; + resource: string; + actions: string[]; +} + +function mergePermissions( + positionPerms: { module: string; resource: string; actions: unknown }[], + rolePerms: { module: string; resource: string; actions: unknown }[] +): EffectivePermission[] { + const key = (m: string, r: string) => `${m}:${r}`; + const map = new Map>(); + + const add = (m: string, r: string, actions: unknown) => { + const arr = Array.isArray(actions) ? actions : []; + const actionSet = new Set(arr.map(String)); + const k = key(m, r); + const existing = map.get(k); + if (existing) { + actionSet.forEach((a) => existing.add(a)); + } else { + map.set(k, actionSet); + } + }; + + (positionPerms || []).forEach((p) => add(p.module, p.resource, p.actions)); + (rolePerms || []).forEach((p) => add(p.module, p.resource, p.actions)); + + return Array.from(map.entries()).map(([k, actions]) => { + const [module, resource] = k.split(':'); + return { module, resource, actions: Array.from(actions) }; + }); +} + export interface AuthRequest extends Request { user?: { id: string; email: string; employeeId?: string; employee?: any; + effectivePermissions?: EffectivePermission[]; }; } @@ -33,7 +68,7 @@ export const authenticate = async ( email: string; }; - // Get user with employee info + // Get user with employee + roles (Phase 3: multi-group) const user = await prisma.user.findUnique({ where: { id: decoded.id }, include: { @@ -47,6 +82,14 @@ export const authenticate = async ( department: true, }, }, + userRoles: { + where: { role: { isActive: true } }, + include: { + role: { + include: { permissions: true }, + }, + }, + }, }, }); @@ -59,12 +102,18 @@ export const authenticate = async ( throw new AppError(403, 'ุงู„ูˆุตูˆู„ ู…ุฑููˆุถ - Access denied. Active employee record required.'); } - // Attach user to request + const positionPerms = user.employee?.position?.permissions ?? []; + const rolePerms = (user as any).userRoles?.flatMap( + (ur: any) => ur.role?.permissions ?? [] + ) ?? []; + const effectivePermissions = mergePermissions(positionPerms, rolePerms); + req.user = { id: user.id, email: user.email, employeeId: user.employeeId || undefined, employee: user.employee, + effectivePermissions, }; next(); @@ -76,25 +125,24 @@ export const authenticate = async ( } }; -// Permission checking middleware +// Permission checking middleware (Position + Role permissions merged) export const authorize = (module: string, resource: string, action: string) => { return async (req: AuthRequest, res: Response, next: NextFunction) => { try { - if (!req.user?.employee?.position?.permissions) { + const perms = req.user?.effectivePermissions; + if (!perms || perms.length === 0) { throw new AppError(403, 'ุงู„ูˆุตูˆู„ ู…ุฑููˆุถ - Access denied'); } - // Find permission for this module and resource (check exact match or wildcard) - const permission = req.user.employee.position.permissions.find( - (p: any) => p.module === module && (p.resource === resource || p.resource === '*' || p.resource === 'all') + const permission = perms.find( + (p) => p.module === module && (p.resource === resource || p.resource === '*' || p.resource === 'all') ); if (!permission) { throw new AppError(403, 'ุงู„ูˆุตูˆู„ ู…ุฑููˆุถ - Access denied'); } - // Check if action is allowed (check exact match or wildcard) - const actions = permission.actions as string[]; + const actions = permission.actions; if (!actions.includes(action) && !actions.includes('*') && !actions.includes('all')) { throw new AppError(403, 'ุงู„ูˆุตูˆู„ ู…ุฑููˆุถ - Access denied'); } diff --git a/backend/src/shared/middleware/errorHandler.ts b/backend/src/shared/middleware/errorHandler.ts index 5d1014b..f8c195d 100644 --- a/backend/src/shared/middleware/errorHandler.ts +++ b/backend/src/shared/middleware/errorHandler.ts @@ -40,10 +40,12 @@ export const errorHandler = ( // Handle validation errors if (err instanceof Prisma.PrismaClientValidationError) { + const detail = process.env.NODE_ENV !== 'production' ? err.message : undefined; return res.status(400).json({ success: false, message: 'ุจูŠุงู†ุงุช ุบูŠุฑ ุตุงู„ุญุฉ - Invalid data', error: 'VALIDATION_ERROR', + ...(detail && { detail }), }); } diff --git a/docs/PRODUCTION_DATABASE_CLEANUP.md b/docs/PRODUCTION_DATABASE_CLEANUP.md index 415f74c..40ac4cd 100644 --- a/docs/PRODUCTION_DATABASE_CLEANUP.md +++ b/docs/PRODUCTION_DATABASE_CLEANUP.md @@ -5,8 +5,8 @@ Clean the production database so you can load **new real data** that will reflect across the system at all levels. This removes existing (e.g. test/demo) data and leaves the database in a state where: - Schema and migrations are unchanged -- Base configuration is restored (pipelines, categories, departments, roles, default users) -- All business data (contacts, deals, quotes, projects, etc.) is removed so you can enter new real data +- One System Administrator user remains for configuration +- All business data (contacts, deals, quotes, projects, etc.) is removed so you can enter new real data manually ## โš ๏ธ Important @@ -21,7 +21,7 @@ Clean the production database so you can load **new real data** that will reflec This truncates all tables and then runs the seed so you get: - Empty business data (contacts, deals, quotes, projects, inventory, etc.) -- Restored base data: departments, positions, permissions, employees, users, contact categories, product categories, pipelines, one warehouse +- One System Administrator user (admin@system.local) with full access to all modules ### Steps on production server @@ -87,19 +87,17 @@ All rows are removed from every table, including: - Audit logs, notifications, approvals - Users, employees, departments, positions, permissions -Then the **seed** recreates only the base data (users, departments, positions, permissions, employees, contact/product categories, pipelines, one warehouse). +Then the **seed** recreates only the base data (one System Administrator user with full access). No categories, pipelines, or warehousesโ€”you configure these manually. --- -## Default users after re-seed +## Default user after re-seed -| Role | Email | Password | Access | -|-------------------|--------------------------|-----------|---------------| -| General Manager | gm@atmata.com | Admin@123 | Full system | -| Sales Manager | sales.manager@atmata.com | Admin@123 | Contacts, CRM | -| Sales Representative | sales.rep@atmata.com | Admin@123 | Basic CRM | +| Role | Email | Password | Access | +|-------------------|----------------------|-----------|-------------| +| System Administrator | admin@system.local | Admin@123 | Full system | -Change these passwords after first login in production. +Change the password after first login in production. --- diff --git a/frontend/src/app/admin/audit-logs/page.tsx b/frontend/src/app/admin/audit-logs/page.tsx index 10f4c98..933c1ba 100644 --- a/frontend/src/app/admin/audit-logs/page.tsx +++ b/frontend/src/app/admin/audit-logs/page.tsx @@ -43,8 +43,8 @@ export default function AuditLogs() { fetchLogs(); }, [fetchLogs]); - const formatDate = (d: string) => - new Date(d).toLocaleString('ar-SA', { dateStyle: 'medium', timeStyle: 'short' }); + const formatDate = (d: string | null | undefined) => + d ? new Date(d).toLocaleString('ar-SA', { dateStyle: 'medium', timeStyle: 'short' }) : '-'; const getActionLabel = (a: string) => { const labels: Record = { diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx index f07285f..3466be4 100644 --- a/frontend/src/app/admin/layout.tsx +++ b/frontend/src/app/admin/layout.tsx @@ -7,6 +7,7 @@ import { usePathname } from 'next/navigation' import { Users, Shield, + UsersRound, Database, Settings, FileText, @@ -27,6 +28,7 @@ function AdminLayoutContent({ children }: { children: React.ReactNode }) { { icon: LayoutDashboard, label: 'ู„ูˆุญุฉ ุงู„ุชุญูƒู…', href: '/admin', exact: true }, { icon: Users, label: 'ุฅุฏุงุฑุฉ ุงู„ู…ุณุชุฎุฏู…ูŠู†', href: '/admin/users' }, { icon: Shield, label: 'ุงู„ุฃุฏูˆุงุฑ ูˆุงู„ุตู„ุงุญูŠุงุช', href: '/admin/roles' }, + { icon: UsersRound, label: 'ู…ุฌู…ูˆุนุงุช ุงู„ุตู„ุงุญูŠุงุช', href: '/admin/permission-groups' }, { icon: Database, label: 'ุงู„ู†ุณุฎ ุงู„ุงุญุชูŠุงุทูŠ', href: '/admin/backup' }, { icon: Settings, label: 'ุฅุนุฏุงุฏุงุช ุงู„ู†ุธุงู…', href: '/admin/settings' }, { icon: FileText, label: 'ุณุฌู„ ุงู„ุนู…ู„ูŠุงุช', href: '/admin/audit-logs' }, diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index be52aad..d5c65df 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -50,7 +50,8 @@ export default function AdminDashboard() { return labels[a] || a; }; - const formatTime = (d: string) => { + const formatTime = (d: string | null | undefined) => { + if (!d) return '-'; const diff = Date.now() - new Date(d).getTime(); const mins = Math.floor(diff / 60000); const hours = Math.floor(diff / 3600000); diff --git a/frontend/src/app/admin/permission-groups/page.tsx b/frontend/src/app/admin/permission-groups/page.tsx new file mode 100644 index 0000000..1bd7fab --- /dev/null +++ b/frontend/src/app/admin/permission-groups/page.tsx @@ -0,0 +1,371 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { UsersRound, Edit, Users, Check, X, Plus } from 'lucide-react'; +import { permissionGroupsAPI } from '@/lib/api/admin'; +import type { PermissionGroup } from '@/lib/api/admin'; +import Modal from '@/components/Modal'; +import LoadingSpinner from '@/components/LoadingSpinner'; + +const MODULES = [ + { id: 'contacts', name: 'ุฅุฏุงุฑุฉ ุฌู‡ุงุช ุงู„ุงุชุตุงู„', nameEn: 'Contact Management' }, + { id: 'crm', name: 'ุฅุฏุงุฑุฉ ุนู„ุงู‚ุงุช ุงู„ุนู…ู„ุงุก', nameEn: 'CRM' }, + { id: 'inventory', name: 'ุงู„ู…ุฎุฒูˆู† ูˆุงู„ุฃุตูˆู„', nameEn: 'Inventory & Assets' }, + { id: 'projects', name: 'ุงู„ู…ู‡ุงู… ูˆุงู„ู…ุดุงุฑูŠุน', nameEn: 'Tasks & Projects' }, + { id: 'hr', name: 'ุงู„ู…ูˆุงุฑุฏ ุงู„ุจุดุฑูŠุฉ', nameEn: 'HR Management' }, + { id: 'marketing', name: 'ุงู„ุชุณูˆูŠู‚', nameEn: 'Marketing' }, + { id: 'admin', name: 'ู„ูˆุญุฉ ุงู„ุฅุฏุงุฑุฉ', nameEn: 'Admin' }, +]; + +const ACTIONS = [ + { id: 'read', name: 'ุนุฑุถ' }, + { id: 'create', name: 'ุฅู†ุดุงุก' }, + { id: 'update', name: 'ุชุนุฏูŠู„' }, + { id: 'delete', name: 'ุญุฐู' }, + { id: 'export', name: 'ุชุตุฏูŠุฑ' }, + { id: 'approve', name: 'ุงุนุชู…ุงุฏ' }, + { id: 'merge', name: 'ุฏู…ุฌ' }, +]; + +function hasAction(perm: { actions?: unknown } | undefined, action: string): boolean { + if (!perm?.actions) return false; + const actions = Array.isArray(perm.actions) ? perm.actions : []; + return actions.includes('*') || actions.includes('all') || actions.includes(action); +} + +function buildPermissionsFromMatrix(matrix: Record>) { + return MODULES.filter((m) => Object.values(matrix[m.id] || {}).some(Boolean)).map((m) => { + const actions = ACTIONS.filter((a) => matrix[m.id]?.[a.id]).map((a) => a.id); + return { + module: m.id, + resource: '*', + actions: actions.length === ACTIONS.length ? ['*'] : actions, + }; + }); +} + +function buildMatrixFromPermissions(permissions: { module: string; resource: string; actions: string[] }[]) { + const matrix: Record> = {}; + for (const m of MODULES) { + matrix[m.id] = {}; + const perm = permissions.find((p) => p.module === m.id && (p.resource === '*' || p.resource === m.id)); + const hasAll = perm && (Array.isArray(perm.actions) + ? perm.actions.includes('*') || perm.actions.includes('all') + : false); + for (const a of ACTIONS) { + matrix[m.id][a.id] = hasAll || hasAction(perm, a.id); + } + } + return matrix; +} + +export default function PermissionGroupsPage() { + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [showEditModal, setShowEditModal] = useState(false); + const [showCreateModal, setShowCreateModal] = useState(false); + const [createForm, setCreateForm] = useState({ name: '', nameAr: '', description: '' }); + type PermissionMatrix = Record>; + const [permissionMatrix, setPermissionMatrix] = useState({}); + const [saving, setSaving] = useState(false); + + const fetchGroups = useCallback(async () => { + setLoading(true); + setError(null); + try { + const list = await permissionGroupsAPI.getAll(); + setGroups(list); + if (selectedId && !list.find((g) => g.id === selectedId)) setSelectedId(null); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'ูุดู„ ุชุญู…ูŠู„ ุงู„ู…ุฌู…ูˆุนุงุช'); + } finally { + setLoading(false); + } + }, [selectedId]); + + useEffect(() => { + fetchGroups(); + }, [fetchGroups]); + + const currentGroup = groups.find((g) => g.id === selectedId); + + useEffect(() => { + if (currentGroup) { + setPermissionMatrix(buildMatrixFromPermissions(currentGroup.permissions || [])); + } + }, [currentGroup?.id, currentGroup?.permissions]); + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + if (!createForm.name.trim()) { + alert('ุงู„ุงุณู… ู…ุทู„ูˆุจ'); + return; + } + setSaving(true); + try { + const group = await permissionGroupsAPI.create({ + name: createForm.name.trim(), + nameAr: createForm.nameAr.trim() || undefined, + description: createForm.description.trim() || undefined, + }); + setShowCreateModal(false); + setCreateForm({ name: '', nameAr: '', description: '' }); + await fetchGroups(); + setSelectedId(group.id); + setShowEditModal(true); + } catch (err: unknown) { + alert(err instanceof Error ? err.message : 'ูุดู„ ุงู„ุฅู†ุดุงุก'); + } finally { + setSaving(false); + } + }; + + const handleSavePermissions = async () => { + if (!selectedId) return; + setSaving(true); + try { + const permissions = buildPermissionsFromMatrix(permissionMatrix); + await permissionGroupsAPI.updatePermissions(selectedId, permissions); + setShowEditModal(false); + fetchGroups(); + } catch (err: unknown) { + alert(err instanceof Error ? err.message : 'ูุดู„ ุงู„ุญูุธ'); + } finally { + setSaving(false); + } + }; + + const handleTogglePermission = (moduleId: string, actionId: string) => { + setPermissionMatrix((prev) => ({ + ...prev, + [moduleId]: { + ...(prev[moduleId] || {}), + [actionId]: !prev[moduleId]?.[actionId], + }, + })); + }; + + return ( +
+
+
+

ู…ุฌู…ูˆุนุงุช ุงู„ุตู„ุงุญูŠุงุช

+

ู…ุฌู…ูˆุนุงุช ุงุฎุชูŠุงุฑูŠุฉ ุชุถูŠู ุตู„ุงุญูŠุงุช ุฅุถุงููŠุฉ ู„ู„ู…ุณุชุฎุฏู…ูŠู† ุจุบุถ ุงู„ู†ุธุฑ ุนู† ูˆุธุงุฆูู‡ู…

+
+ +
+ + {loading ? ( +
+ +
+ ) : error ? ( +
{error}
+ ) : ( +
+
+

ุงู„ู…ุฌู…ูˆุนุงุช ({groups.length})

+ {groups.map((g) => ( +
setSelectedId(g.id)} + className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${ + selectedId === g.id ? 'border-blue-600 bg-blue-50' : 'border-gray-200 bg-white hover:border-blue-300' + }`} + > +
+
+ +
+
+

{g.nameAr || g.name}

+

{g.name}

+
+
+
+ + + {g._count?.userRoles ?? 0} ู…ุณุชุฎุฏู… + + +
+
+ ))} +
+ +
+ {currentGroup ? ( +
+
+
+

{currentGroup.nameAr || currentGroup.name}

+

{currentGroup.name}

+
+ +
+

ู…ุตููˆูุฉ ุงู„ุตู„ุงุญูŠุงุช

+
+ + + + + {ACTIONS.map((a) => ( + + ))} + + + + {MODULES.map((m) => ( + + + {ACTIONS.map((a) => { + const has = permissionMatrix[m.id]?.[a.id]; + return ( + + ); + })} + + ))} + +
ุงู„ูˆุญุฏุฉ{a.name}
+

{m.name}

+

{m.nameEn}

+
+
+ {has ? : } +
+
+
+
+ ) : ( +
+ +

ุงุฎุชุฑ ู…ุฌู…ูˆุนุฉ

+

ุฃูˆ ุฃู†ุดุฆ ู…ุฌู…ูˆุนุฉ ุฌุฏูŠุฏุฉ ู„ุฅุถุงูุฉ ุตู„ุงุญูŠุงุช ุงุฎุชูŠุงุฑูŠุฉ ู„ู„ู…ุณุชุฎุฏู…ูŠู†

+
+ )} +
+
+ )} + + setShowCreateModal(false)} title="ุฅุถุงูุฉ ู…ุฌู…ูˆุนุฉ ุตู„ุงุญูŠุงุช" size="md"> +
+
+ + setCreateForm((p) => ({ ...p, name: e.target.value }))} + className="w-full px-3 py-2 border border-gray-300 rounded-lg" + placeholder="e.g. Campaign Approver" + /> +
+
+ + setCreateForm((p) => ({ ...p, nameAr: e.target.value }))} + className="w-full px-3 py-2 border border-gray-300 rounded-lg" + /> +
+
+ +