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/20260209000000_add_hr_enhancements/migration.sql b/backend/prisma/migrations/20260209000000_add_hr_enhancements/migration.sql new file mode 100644 index 0000000..7bc614c --- /dev/null +++ b/backend/prisma/migrations/20260209000000_add_hr_enhancements/migration.sql @@ -0,0 +1,131 @@ +-- AlterTable: Add attendancePin to employees +ALTER TABLE "employees" ADD COLUMN IF NOT EXISTS "attendancePin" TEXT; + +-- CreateIndex (unique) on attendancePin - only if column added +CREATE UNIQUE INDEX IF NOT EXISTS "employees_attendancePin_key" ON "employees"("attendancePin"); + +-- AlterTable: Add ZK Tico fields to attendances +ALTER TABLE "attendances" ADD COLUMN IF NOT EXISTS "sourceDeviceId" TEXT; +ALTER TABLE "attendances" ADD COLUMN IF NOT EXISTS "externalId" TEXT; +ALTER TABLE "attendances" ADD COLUMN IF NOT EXISTS "rawData" JSONB; + +-- CreateIndex on sourceDeviceId +CREATE INDEX IF NOT EXISTS "attendances_sourceDeviceId_idx" ON "attendances"("sourceDeviceId"); + +-- CreateTable: loans +CREATE TABLE "loans" ( + "id" TEXT NOT NULL, + "employeeId" TEXT NOT NULL, + "loanNumber" TEXT NOT NULL, + "type" TEXT NOT NULL, + "amount" DECIMAL(12,2) NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'SAR', + "installments" INTEGER NOT NULL DEFAULT 1, + "monthlyAmount" DECIMAL(12,2), + "reason" TEXT, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "approvedBy" TEXT, + "approvedAt" TIMESTAMP(3), + "rejectedReason" TEXT, + "startDate" DATE, + "endDate" DATE, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "loans_pkey" PRIMARY KEY ("id") +); + +-- CreateTable: loan_installments +CREATE TABLE "loan_installments" ( + "id" TEXT NOT NULL, + "loanId" TEXT NOT NULL, + "installmentNumber" INTEGER NOT NULL, + "dueDate" DATE NOT NULL, + "amount" DECIMAL(12,2) NOT NULL, + "paidDate" DATE, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "loan_installments_pkey" PRIMARY KEY ("id") +); + +-- CreateTable: purchase_requests +CREATE TABLE "purchase_requests" ( + "id" TEXT NOT NULL, + "requestNumber" TEXT NOT NULL, + "employeeId" TEXT NOT NULL, + "items" JSONB NOT NULL, + "totalAmount" DECIMAL(12,2), + "reason" TEXT, + "priority" TEXT NOT NULL DEFAULT 'NORMAL', + "status" TEXT NOT NULL DEFAULT 'PENDING', + "approvedBy" TEXT, + "approvedAt" TIMESTAMP(3), + "rejectedReason" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "purchase_requests_pkey" PRIMARY KEY ("id") +); + +-- CreateTable: leave_entitlements +CREATE TABLE "leave_entitlements" ( + "id" TEXT NOT NULL, + "employeeId" TEXT NOT NULL, + "year" INTEGER NOT NULL, + "leaveType" TEXT NOT NULL, + "totalDays" INTEGER NOT NULL DEFAULT 0, + "usedDays" INTEGER NOT NULL DEFAULT 0, + "carriedOver" INTEGER NOT NULL DEFAULT 0, + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "leave_entitlements_pkey" PRIMARY KEY ("id") +); + +-- CreateTable: employee_contracts +CREATE TABLE "employee_contracts" ( + "id" TEXT NOT NULL, + "employeeId" TEXT NOT NULL, + "contractNumber" TEXT NOT NULL, + "type" TEXT NOT NULL, + "startDate" DATE NOT NULL, + "endDate" DATE, + "salary" DECIMAL(12,2) NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'SAR', + "documentUrl" TEXT, + "status" TEXT NOT NULL DEFAULT 'ACTIVE', + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "employee_contracts_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "loans_loanNumber_key" ON "loans"("loanNumber"); +CREATE INDEX "loans_employeeId_idx" ON "loans"("employeeId"); +CREATE INDEX "loans_status_idx" ON "loans"("status"); + +CREATE UNIQUE INDEX "loan_installments_loanId_installmentNumber_key" ON "loan_installments"("loanId", "installmentNumber"); +CREATE INDEX "loan_installments_loanId_idx" ON "loan_installments"("loanId"); + +CREATE UNIQUE INDEX "purchase_requests_requestNumber_key" ON "purchase_requests"("requestNumber"); +CREATE INDEX "purchase_requests_employeeId_idx" ON "purchase_requests"("employeeId"); +CREATE INDEX "purchase_requests_status_idx" ON "purchase_requests"("status"); + +CREATE UNIQUE INDEX "leave_entitlements_employeeId_year_leaveType_key" ON "leave_entitlements"("employeeId", "year", "leaveType"); +CREATE INDEX "leave_entitlements_employeeId_idx" ON "leave_entitlements"("employeeId"); + +CREATE UNIQUE INDEX "employee_contracts_contractNumber_key" ON "employee_contracts"("contractNumber"); +CREATE INDEX "employee_contracts_employeeId_idx" ON "employee_contracts"("employeeId"); +CREATE INDEX "employee_contracts_status_idx" ON "employee_contracts"("status"); + +-- AddForeignKey +ALTER TABLE "loans" ADD CONSTRAINT "loans_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "loan_installments" ADD CONSTRAINT "loan_installments_loanId_fkey" FOREIGN KEY ("loanId") REFERENCES "loans"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "purchase_requests" ADD CONSTRAINT "purchase_requests_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "leave_entitlements" ADD CONSTRAINT "leave_entitlements_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "employee_contracts" ADD CONSTRAINT "employee_contracts_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 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 6992d0a..69c39ea 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 // رقم الموظف الموحد @@ -129,6 +178,9 @@ model Employee { // Documents documents Json? // Array of document references + // ZK Tico / Attendance device - maps to employee pin on device + attendancePin String? @unique + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -143,6 +195,10 @@ model Employee { disciplinaryActions DisciplinaryAction[] allowances Allowance[] commissions Commission[] + loans Loan[] + purchaseRequests PurchaseRequest[] + leaveEntitlements LeaveEntitlement[] + employeeContracts EmployeeContract[] @@index([departmentId]) @@index([positionId]) @@ -221,12 +277,18 @@ model Attendance { status String // PRESENT, ABSENT, LATE, HALF_DAY, etc. notes String? + // ZK Tico / External device sync + sourceDeviceId String? + externalId String? + rawData Json? + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@unique([employeeId, date]) @@index([employeeId]) @@index([date]) + @@index([sourceDeviceId]) @@map("attendances") } @@ -369,6 +431,115 @@ model DisciplinaryAction { @@map("disciplinary_actions") } +model Loan { + id String @id @default(uuid()) + employeeId String + employee Employee @relation(fields: [employeeId], references: [id]) + loanNumber String @unique + type String // SALARY_ADVANCE, EQUIPMENT, PERSONAL, etc. + amount Decimal @db.Decimal(12, 2) + currency String @default("SAR") + installments Int @default(1) + monthlyAmount Decimal? @db.Decimal(12, 2) + reason String? + status String @default("PENDING") // PENDING, APPROVED, REJECTED, ACTIVE, PAID_OFF + approvedBy String? + approvedAt DateTime? + rejectedReason String? + startDate DateTime? @db.Date + endDate DateTime? @db.Date + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + installmentsList LoanInstallment[] + + @@index([employeeId]) + @@index([status]) + @@map("loans") +} + +model LoanInstallment { + id String @id @default(uuid()) + loanId String + loan Loan @relation(fields: [loanId], references: [id], onDelete: Cascade) + installmentNumber Int + dueDate DateTime @db.Date + amount Decimal @db.Decimal(12, 2) + paidDate DateTime? @db.Date + status String @default("PENDING") // PENDING, PAID, OVERDUE + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([loanId, installmentNumber]) + @@index([loanId]) + @@map("loan_installments") +} + +model PurchaseRequest { + id String @id @default(uuid()) + requestNumber String @unique + employeeId String + employee Employee @relation(fields: [employeeId], references: [id]) + items Json // Array of { description, quantity, estimatedPrice, etc. } + totalAmount Decimal? @db.Decimal(12, 2) + reason String? + priority String @default("NORMAL") // LOW, NORMAL, HIGH, URGENT + status String @default("PENDING") // PENDING, APPROVED, REJECTED, ORDERED + approvedBy String? + approvedAt DateTime? + rejectedReason String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([employeeId]) + @@index([status]) + @@map("purchase_requests") +} + +model LeaveEntitlement { + id String @id @default(uuid()) + employeeId String + employee Employee @relation(fields: [employeeId], references: [id]) + year Int + leaveType String // ANNUAL, SICK, EMERGENCY, etc. + totalDays Int @default(0) + usedDays Int @default(0) + carriedOver Int @default(0) + notes String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([employeeId, year, leaveType]) + @@index([employeeId]) + @@map("leave_entitlements") +} + +model EmployeeContract { + id String @id @default(uuid()) + employeeId String + employee Employee @relation(fields: [employeeId], references: [id]) + contractNumber String @unique + type String // FIXED, UNLIMITED, PROBATION, etc. + startDate DateTime @db.Date + endDate DateTime? @db.Date + salary Decimal @db.Decimal(12, 2) + currency String @default("SAR") + documentUrl String? + status String @default("ACTIVE") // DRAFT, ACTIVE, EXPIRED, TERMINATED + notes String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([employeeId]) + @@index([status]) + @@map("employee_contracts") +} + // ============================================ // MODULE 1: CONTACT MANAGEMENT // ============================================ 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 76461f5..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,342 +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'); - - const itSupportPosition = await prisma.position.create({ - data: { - title: 'IT Support', - titleAr: 'دعم فني', - code: 'IT_SUPPORT', - departmentId: itDept.id, - level: 4, - description: 'IT Support Technician', - }, - }); - - const itDeveloperPosition = await prisma.position.create({ - data: { - title: 'Developer', - titleAr: 'مطور', - code: 'IT_DEV', - departmentId: itDept.id, - level: 4, - description: 'Software Developer', - }, - }); - - // Employee position for ALL departments (added) - const salesEmployeePosition = await prisma.position.create({ - data: { - title: 'Employee', - titleAr: 'موظف', - code: 'SALES_EMPLOYEE', - departmentId: salesDept.id, - level: 5, - description: 'General employee - Sales Department', - }, - }); - - const itEmployeePosition = await prisma.position.create({ - data: { - title: 'Employee', - titleAr: 'موظف', - code: 'IT_EMPLOYEE', - departmentId: itDept.id, - level: 5, - description: 'General employee - IT Department', - }, - }); - - const hrEmployeePosition = await prisma.position.create({ - data: { - title: 'Employee', - titleAr: 'موظف', - code: 'HR_EMPLOYEE', - departmentId: hrDept.id, - level: 5, - description: 'General employee - HR Department', - }, - }); - - // 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({ - data: { - positionId: gmPosition.id, - module: 'admin', - resource: '*', - actions: ['*'], - }, - }); - - // 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: salesRepPosition.id, module: 'contacts', resource: '*', actions: ['read', 'create', 'update'] }, + { positionId: salesRepPosition.id, module: 'crm', resource: 'deals', actions: ['read', 'create', 'update'] }, ], }); - // Create Permissions for Sales Rep + 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: 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'], - }, + { 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 permissions'); + console.log('✅ Created position and 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'); } @@ -413,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 2080c18..24ab2b9 100644 --- a/backend/src/modules/admin/admin.controller.ts +++ b/backend/src/modules/admin/admin.controller.ts @@ -136,17 +136,15 @@ class AdminController { async createPosition(req: AuthRequest, res: Response, next: NextFunction) { try { - const userId = req.user!.id; - const position = await adminService.createPosition( - { - title: req.body.title, - titleAr: req.body.titleAr, - departmentId: req.body.departmentId, - level: req.body.level, - code: req.body.code, - }, - userId - ); + 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); @@ -155,15 +153,15 @@ class AdminController { async updatePosition(req: AuthRequest, res: Response, next: NextFunction) { try { - const userId = req.user!.id; - const position = await adminService.updatePosition( - req.params.id, - { - title: req.body.title, - titleAr: req.body.titleAr, - }, - userId - ); + 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); @@ -182,15 +180,73 @@ class AdminController { } } - async deletePosition(req: AuthRequest, res: Response, next: NextFunction) { + // ========== PERMISSION GROUPS (Phase 3) ========== + + async getPermissionGroups(req: AuthRequest, res: Response, next: NextFunction) { try { - const userId = req.user!.id; - await adminService.deletePosition(req.params.id, userId); - res.json(ResponseFormatter.success(null, 'Role deleted successfully')); + 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(); \ No newline at end of file +export const adminController = new AdminController(); diff --git a/backend/src/modules/admin/admin.routes.ts b/backend/src/modules/admin/admin.routes.ts index ce84a7d..1692ee5 100644 --- a/backend/src/modules/admin/admin.routes.ts +++ b/backend/src/modules/admin/admin.routes.ts @@ -89,43 +89,33 @@ router.get( adminController.getPositions ); -// Create role router.post( '/positions', authorize('admin', 'roles', 'create'), [ body('title').notEmpty().trim(), - body('titleAr').optional().isString().trim(), + body('code').notEmpty().trim(), body('departmentId').isUUID(), body('level').optional().isInt({ min: 1 }), - body('code').optional().isString().trim(), ], validate, adminController.createPosition ); -// Update role name (title/titleAr) router.put( '/positions/:id', authorize('admin', 'roles', 'update'), [ param('id').isUUID(), body('title').optional().notEmpty().trim(), - body('titleAr').optional().isString().trim(), + body('code').optional().notEmpty().trim(), + body('departmentId').optional().isUUID(), + body('level').optional().isInt({ min: 1 }), ], validate, adminController.updatePosition ); -// Delete (soft delete) a role/position -router.delete( - '/positions/:id', - authorize('admin', 'roles', 'delete'), - param('id').isUUID(), - validate, - adminController.deletePosition -); - router.put( '/positions/:id/permissions', authorize('admin', 'roles', 'update'), @@ -137,4 +127,68 @@ router.put( adminController.updatePositionPermissions ); -export default router; \ No newline at end of file +// ========== 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 453913d..1282f4b 100644 --- a/backend/src/modules/admin/admin.service.ts +++ b/backend/src/modules/admin/admin.service.ts @@ -39,19 +39,6 @@ export interface AuditLogFilters { pageSize?: number; } -export interface CreatePositionData { - title: string; - titleAr?: string; - departmentId: string; - level?: number; - code?: string; -} - -export interface UpdatePositionData { - title?: string; - titleAr?: string; -} - class AdminService { // ========== USERS ========== @@ -107,7 +94,7 @@ class AdminService { ]); const sanitized = users.map((u) => { - const { password: _, ...rest } = u as any; + const { password: _, ...rest } = u; return rest; }); @@ -137,7 +124,7 @@ class AdminService { throw new AppError(404, 'المستخدم غير موجود - User not found'); } - const { password: _, ...rest } = user as any; + const { password: _, ...rest } = user; return rest; } @@ -236,7 +223,7 @@ class AdminService { ...(data.email && { email: data.email }), ...(data.username && { username: data.username }), ...(data.isActive !== undefined && { isActive: data.isActive }), - ...(data.employeeId !== undefined && { employeeId: (data.employeeId as any) || null }), + ...(data.employeeId !== undefined && { employeeId: data.employeeId || null }), }; if (data.password && data.password.length >= 8) { @@ -255,7 +242,7 @@ class AdminService { }, }); - const { password: _, ...sanitized } = user as any; + const { password: _, ...sanitized } = user; await AuditLogger.log({ entityType: 'USER', @@ -286,7 +273,7 @@ class AdminService { }, }); - const { password: _, ...sanitized } = updated as any; + const { password: _, ...sanitized } = updated; await AuditLogger.log({ entityType: 'USER', @@ -393,7 +380,7 @@ class AdminService { const positions = await prisma.position.findMany({ where: { isActive: true }, include: { - department: { select: { id: true, name: true, nameAr: true } }, + department: { select: { name: true, nameAr: true } }, permissions: true, _count: { select: { @@ -419,118 +406,100 @@ class AdminService { return withUserCount; } - private async generateUniqueCode(base: string) { - const cleaned = (base || 'ROLE') - .toUpperCase() - .replace(/[^A-Z0-9]+/g, '_') - .replace(/^_+|_+$/g, '') - .slice(0, 18) || 'ROLE'; - - for (let i = 0; i < 25; i++) { - const suffix = Math.floor(1000 + Math.random() * 9000); - const code = `${cleaned}_${suffix}`; - const exists = await prisma.position.findUnique({ where: { code } }); - if (!exists) return code; + 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'); } - // fallback - return `${cleaned}_${Date.now()}`; - } - - async createPosition(data: CreatePositionData, createdById: string) { - const title = (data.title || '').trim(); - const titleAr = (data.titleAr || '').trim(); - - if (!title && !titleAr) { - throw new AppError(400, 'اسم الدور مطلوب - Role name is required'); - } - - const department = await prisma.department.findUnique({ + const dept = await prisma.department.findUnique({ where: { id: data.departmentId }, }); - - if (!department || !department.isActive) { + if (!dept) { throw new AppError(400, 'القسم غير موجود - Department not found'); } - let code = (data.code || '').trim(); - if (code) { - code = code.toUpperCase().replace(/[^A-Z0-9_]+/g, '_'); - const exists = await prisma.position.findUnique({ where: { code } }); - if (exists) { - throw new AppError(400, 'الكود مستخدم بالفعل - Code already exists'); - } - } else { - code = await this.generateUniqueCode(title || titleAr || 'ROLE'); - } - - const level = Number.isFinite(data.level as any) ? Math.max(1, Number(data.level)) : 1; - - const created = await prisma.position.create({ + return prisma.position.create({ data: { - title: title || titleAr, - titleAr: titleAr || null, - code, + title: data.title, + titleAr: data.titleAr, + code: data.code.trim().toUpperCase().replace(/\s+/g, '_'), departmentId: data.departmentId, - level, + level: data.level ?? 5, + description: data.description, + isActive: data.isActive ?? true, + }, + include: { + department: { select: { name: true, nameAr: true } }, + permissions: true, }, }); - - await AuditLogger.log({ - entityType: 'POSITION', - entityId: created.id, - action: 'CREATE', - userId: createdById, - changes: { - created: { - title: created.title, - titleAr: created.titleAr, - code: created.code, - departmentId: created.departmentId, - level: created.level, - }, - }, - }); - - const all = await this.getPositions(); - return all.find((p: any) => p.id === created.id) || created; } - async updatePosition(positionId: string, data: UpdatePositionData, updatedById: string) { - const existing = await prisma.position.findUnique({ where: { id: positionId } }); - if (!existing) { + 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'); } - const nextTitle = data.title !== undefined ? (data.title || '').trim() : existing.title; - const nextTitleAr = data.titleAr !== undefined ? (data.titleAr || '').trim() : (existing.titleAr || ''); - - const finalTitle = nextTitle || nextTitleAr; - if (!finalTitle) { - throw new AppError(400, 'اسم الدور مطلوب - Role name is required'); + 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'); + } } - const updated = await prisma.position.update({ + 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: { - title: finalTitle, - titleAr: nextTitleAr ? nextTitleAr : null, + data: updateData, + include: { + department: { select: { name: true, nameAr: true } }, + permissions: true, }, }); - - await AuditLogger.log({ - entityType: 'POSITION', - entityId: positionId, - action: 'UPDATE', - userId: updatedById, - changes: { - before: { title: existing.title, titleAr: existing.titleAr }, - after: { title: updated.title, titleAr: updated.titleAr }, - }, - }); - - const all = await this.getPositions(); - return all.find((p: any) => p.id === positionId) || updated; } async updatePositionPermissions(positionId: string, permissions: Array<{ module: string; resource: string; actions: string[] }>) { @@ -554,59 +523,118 @@ class AdminService { }); } - return this.getPositions().then((pos: any) => pos.find((p: any) => p.id === positionId) || position); + return this.getPositions().then((pos) => pos.find((p) => p.id === positionId) || position); } - /** - * Soft delete a role (Position). - * - Prevent deletion if the position is assigned to any employees. - * - Clean up position permissions. - */ - async deletePosition(positionId: string, deletedById: string) { - const position = await prisma.position.findUnique({ - where: { id: positionId }, + // ========== PERMISSION GROUPS (Phase 3 - optional roles for multi-group) ========== + + async getPermissionGroups() { + return prisma.role.findMany({ + where: { isActive: true }, include: { - _count: { select: { employees: true } }, + 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 } }, }, }); + } - if (!position) { - throw new AppError(404, 'الدور غير موجود - Position not found'); + 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'); } - if (position._count.employees > 0) { - throw new AppError( - 400, - 'لا يمكن حذف هذا الدور لأنه مرتبط بموظفين. قم بتغيير دور الموظفين أولاً - Cannot delete: position is assigned to employees' - ); + 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'); } - - // Soft delete the position - await prisma.position.update({ - where: { id: positionId }, - data: { isActive: false }, - }); - - // Clean up permissions linked to this position - await prisma.positionPermission.deleteMany({ - where: { positionId }, - }); - - await AuditLogger.log({ - entityType: 'POSITION', - entityId: positionId, - action: 'DELETE', - userId: deletedById, - changes: { - softDeleted: true, - title: position.title, - titleAr: position.titleAr, - code: position.code, - }, - }); - return { success: true }; } } -export const adminService = new AdminService(); \ No newline at end of file +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..1de40fd 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) { @@ -92,6 +98,16 @@ export class HRController { } } + async bulkSyncAttendance(req: AuthRequest, res: Response, next: NextFunction) { + try { + const { deviceId, records } = req.body; + const results = await hrService.bulkSyncAttendanceFromDevice(deviceId, records || [], req.user!.id); + res.json(ResponseFormatter.success(results, 'تم مزامنة الحضور - Attendance synced')); + } catch (error) { + next(error); + } + } + // ========== LEAVES ========== async createLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) { @@ -112,6 +128,29 @@ export class HRController { } } + async rejectLeave(req: AuthRequest, res: Response, next: NextFunction) { + try { + const { rejectedReason } = req.body; + const leave = await hrService.rejectLeave(req.params.id, rejectedReason || '', req.user!.id); + res.json(ResponseFormatter.success(leave, 'تم رفض طلب الإجازة - Leave rejected')); + } catch (error) { + next(error); + } + } + + async findAllLeaves(req: AuthRequest, res: Response, next: NextFunction) { + try { + const page = Math.max(1, parseInt(req.query.page as string) || 1); + const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20)); + const employeeId = req.query.employeeId as string | undefined; + const status = req.query.status as string | undefined; + const result = await hrService.findAllLeaves({ employeeId, status }, page, pageSize); + res.json(ResponseFormatter.paginated(result.leaves, result.total, result.page, result.pageSize)); + } catch (error) { + next(error); + } + } + // ========== SALARIES ========== async processSalary(req: AuthRequest, res: Response, next: NextFunction) { @@ -135,6 +174,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) { @@ -145,6 +220,198 @@ export class HRController { next(error); } } + + // ========== LOANS ========== + + async findAllLoans(req: AuthRequest, res: Response, next: NextFunction) { + try { + const page = Math.max(1, parseInt(req.query.page as string) || 1); + const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20)); + const employeeId = req.query.employeeId as string | undefined; + const status = req.query.status as string | undefined; + const result = await hrService.findAllLoans({ employeeId, status }, page, pageSize); + res.json(ResponseFormatter.paginated(result.loans, result.total, result.page, result.pageSize)); + } catch (error) { + next(error); + } + } + + async findLoanById(req: AuthRequest, res: Response, next: NextFunction) { + try { + const loan = await hrService.findLoanById(req.params.id); + res.json(ResponseFormatter.success(loan)); + } catch (error) { + next(error); + } + } + + async createLoan(req: AuthRequest, res: Response, next: NextFunction) { + try { + const loan = await hrService.createLoan(req.body, req.user!.id); + res.status(201).json(ResponseFormatter.success(loan, 'تم إنشاء طلب القرض - Loan request created')); + } catch (error) { + next(error); + } + } + + async approveLoan(req: AuthRequest, res: Response, next: NextFunction) { + try { + const { startDate } = req.body; + const loan = await hrService.approveLoan(req.params.id, req.user!.id, startDate ? new Date(startDate) : new Date(), req.user!.id); + res.json(ResponseFormatter.success(loan, 'تمت الموافقة على القرض - Loan approved')); + } catch (error) { + next(error); + } + } + + async rejectLoan(req: AuthRequest, res: Response, next: NextFunction) { + try { + const { rejectedReason } = req.body; + const loan = await hrService.rejectLoan(req.params.id, rejectedReason || '', req.user!.id); + res.json(ResponseFormatter.success(loan, 'تم رفض القرض - Loan rejected')); + } catch (error) { + next(error); + } + } + + async recordLoanInstallmentPayment(req: AuthRequest, res: Response, next: NextFunction) { + try { + const { installmentId, paidDate } = req.body; + const loan = await hrService.recordLoanInstallmentPayment(req.params.id, installmentId, paidDate ? new Date(paidDate) : new Date(), req.user!.id); + res.json(ResponseFormatter.success(loan, 'تم تسجيل الدفعة - Payment recorded')); + } catch (error) { + next(error); + } + } + + // ========== PURCHASE REQUESTS ========== + + async findAllPurchaseRequests(req: AuthRequest, res: Response, next: NextFunction) { + try { + const page = Math.max(1, parseInt(req.query.page as string) || 1); + const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20)); + const employeeId = req.query.employeeId as string | undefined; + const status = req.query.status as string | undefined; + const result = await hrService.findAllPurchaseRequests({ employeeId, status }, page, pageSize); + res.json(ResponseFormatter.paginated(result.purchaseRequests, result.total, result.page, result.pageSize)); + } catch (error) { + next(error); + } + } + + async findPurchaseRequestById(req: AuthRequest, res: Response, next: NextFunction) { + try { + const pr = await hrService.findPurchaseRequestById(req.params.id); + res.json(ResponseFormatter.success(pr)); + } catch (error) { + next(error); + } + } + + async createPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) { + try { + const pr = await hrService.createPurchaseRequest(req.body, req.user!.id); + res.status(201).json(ResponseFormatter.success(pr, 'تم إنشاء طلب الشراء - Purchase request created')); + } catch (error) { + next(error); + } + } + + async approvePurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) { + try { + const pr = await hrService.approvePurchaseRequest(req.params.id, req.user!.id, req.user!.id); + res.json(ResponseFormatter.success(pr, 'تمت الموافقة على طلب الشراء - Purchase request approved')); + } catch (error) { + next(error); + } + } + + async rejectPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) { + try { + const { rejectedReason } = req.body; + const pr = await hrService.rejectPurchaseRequest(req.params.id, rejectedReason || '', req.user!.id); + res.json(ResponseFormatter.success(pr, 'تم رفض طلب الشراء - Purchase request rejected')); + } catch (error) { + next(error); + } + } + + // ========== LEAVE ENTITLEMENTS ========== + + async getLeaveBalance(req: AuthRequest, res: Response, next: NextFunction) { + try { + const employeeId = req.params.employeeId || req.query.employeeId as string; + const year = parseInt(req.query.year as string) || new Date().getFullYear(); + const balance = await hrService.getLeaveBalance(employeeId, year); + res.json(ResponseFormatter.success(balance)); + } catch (error) { + next(error); + } + } + + async findAllLeaveEntitlements(req: AuthRequest, res: Response, next: NextFunction) { + try { + const employeeId = req.query.employeeId as string | undefined; + const year = req.query.year ? parseInt(req.query.year as string) : undefined; + const list = await hrService.findAllLeaveEntitlements(employeeId, year); + res.json(ResponseFormatter.success(list)); + } catch (error) { + next(error); + } + } + + async upsertLeaveEntitlement(req: AuthRequest, res: Response, next: NextFunction) { + try { + const ent = await hrService.upsertLeaveEntitlement(req.body, req.user!.id); + res.json(ResponseFormatter.success(ent, 'تم حفظ رصيد الإجازة - Leave entitlement saved')); + } catch (error) { + next(error); + } + } + + // ========== EMPLOYEE CONTRACTS ========== + + async findAllEmployeeContracts(req: AuthRequest, res: Response, next: NextFunction) { + try { + const page = Math.max(1, parseInt(req.query.page as string) || 1); + const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20)); + const employeeId = req.query.employeeId as string | undefined; + const status = req.query.status as string | undefined; + const result = await hrService.findAllEmployeeContracts({ employeeId, status }, page, pageSize); + res.json(ResponseFormatter.paginated(result.contracts, result.total, result.page, result.pageSize)); + } catch (error) { + next(error); + } + } + + async findEmployeeContractById(req: AuthRequest, res: Response, next: NextFunction) { + try { + const c = await hrService.findEmployeeContractById(req.params.id); + res.json(ResponseFormatter.success(c)); + } catch (error) { + next(error); + } + } + + async createEmployeeContract(req: AuthRequest, res: Response, next: NextFunction) { + try { + const data = { ...req.body, startDate: new Date(req.body.startDate), endDate: req.body.endDate ? new Date(req.body.endDate) : undefined }; + const c = await hrService.createEmployeeContract(data, req.user!.id); + res.status(201).json(ResponseFormatter.success(c, 'تم إنشاء العقد - Contract created')); + } catch (error) { + next(error); + } + } + + async updateEmployeeContract(req: AuthRequest, res: Response, next: NextFunction) { + try { + const data = { ...req.body, endDate: req.body.endDate ? new Date(req.body.endDate) : undefined }; + const c = await hrService.updateEmployeeContract(req.params.id, data, req.user!.id); + res.json(ResponseFormatter.success(c, 'تم تحديث العقد - Contract updated')); + } catch (error) { + next(error); + } + } } export const hrController = new HRController(); diff --git a/backend/src/modules/hr/hr.routes.ts b/backend/src/modules/hr/hr.routes.ts index cbaa267..3833917 100644 --- a/backend/src/modules/hr/hr.routes.ts +++ b/backend/src/modules/hr/hr.routes.ts @@ -1,12 +1,24 @@ import { Router } from 'express'; -import { body, param } from 'express-validator'; import { hrController } from './hr.controller'; +import { portalController } from './portal.controller'; import { authenticate, authorize } from '../../shared/middleware/auth'; -import { validate } from '../../shared/middleware/validation'; const router = Router(); router.use(authenticate); +// ========== EMPLOYEE PORTAL (authenticate only, scoped by employeeId) ========== + +router.get('/portal/me', portalController.getMe); +router.get('/portal/loans', portalController.getMyLoans); +router.post('/portal/loans', portalController.submitLoanRequest); +router.get('/portal/leave-balance', portalController.getMyLeaveBalance); +router.get('/portal/leaves', portalController.getMyLeaves); +router.post('/portal/leaves', portalController.submitLeaveRequest); +router.get('/portal/purchase-requests', portalController.getMyPurchaseRequests); +router.post('/portal/purchase-requests', portalController.submitPurchaseRequest); +router.get('/portal/attendance', portalController.getMyAttendance); +router.get('/portal/salaries', portalController.getMySalaries); + // ========== EMPLOYEES ========== router.get('/employees', authorize('hr', 'employees', 'read'), hrController.findAllEmployees); @@ -19,11 +31,14 @@ router.post('/employees/:id/terminate', authorize('hr', 'employees', 'terminate' router.post('/attendance', authorize('hr', 'attendance', 'create'), hrController.recordAttendance); router.get('/attendance/:employeeId', authorize('hr', 'attendance', 'read'), hrController.getAttendance); +router.post('/attendance/sync', authorize('hr', 'attendance', 'create'), hrController.bulkSyncAttendance); // ========== LEAVES ========== +router.get('/leaves', authorize('hr', 'leaves', 'read'), hrController.findAllLeaves); router.post('/leaves', authorize('hr', 'leaves', 'create'), hrController.createLeaveRequest); router.post('/leaves/:id/approve', authorize('hr', 'leaves', 'approve'), hrController.approveLeave); +router.post('/leaves/:id/reject', authorize('hr', 'leaves', 'approve'), hrController.rejectLeave); // ========== SALARIES ========== @@ -32,10 +47,44 @@ 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 ========== router.get('/positions', authorize('hr', 'all', 'read'), hrController.findAllPositions); +// ========== LOANS ========== + +router.get('/loans', authorize('hr', 'all', 'read'), hrController.findAllLoans); +router.get('/loans/:id', authorize('hr', 'all', 'read'), hrController.findLoanById); +router.post('/loans', authorize('hr', 'all', 'create'), hrController.createLoan); +router.post('/loans/:id/approve', authorize('hr', 'all', 'approve'), hrController.approveLoan); +router.post('/loans/:id/reject', authorize('hr', 'all', 'approve'), hrController.rejectLoan); +router.post('/loans/:id/pay-installment', authorize('hr', 'all', 'update'), hrController.recordLoanInstallmentPayment); + +// ========== PURCHASE REQUESTS ========== + +router.get('/purchase-requests', authorize('hr', 'all', 'read'), hrController.findAllPurchaseRequests); +router.get('/purchase-requests/:id', authorize('hr', 'all', 'read'), hrController.findPurchaseRequestById); +router.post('/purchase-requests', authorize('hr', 'all', 'create'), hrController.createPurchaseRequest); +router.post('/purchase-requests/:id/approve', authorize('hr', 'all', 'approve'), hrController.approvePurchaseRequest); +router.post('/purchase-requests/:id/reject', authorize('hr', 'all', 'approve'), hrController.rejectPurchaseRequest); + +// ========== LEAVE ENTITLEMENTS ========== + +router.get('/leave-balance/:employeeId', authorize('hr', 'all', 'read'), hrController.getLeaveBalance); +router.get('/leave-entitlements', authorize('hr', 'all', 'read'), hrController.findAllLeaveEntitlements); +router.post('/leave-entitlements', authorize('hr', 'all', 'create'), hrController.upsertLeaveEntitlement); + +// ========== EMPLOYEE CONTRACTS ========== + +router.get('/contracts', authorize('hr', 'all', 'read'), hrController.findAllEmployeeContracts); +router.get('/contracts/:id', authorize('hr', 'all', 'read'), hrController.findEmployeeContractById); +router.post('/contracts', authorize('hr', 'all', 'create'), hrController.createEmployeeContract); +router.put('/contracts/:id', authorize('hr', 'all', 'update'), hrController.updateEmployeeContract); + export default router; diff --git a/backend/src/modules/hr/hr.service.ts b/backend/src/modules/hr/hr.service.ts index d09567e..dd0781a 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, @@ -188,12 +227,74 @@ class HRService { async recordAttendance(data: any, userId: string) { const attendance = await prisma.attendance.create({ - data, + data: { + ...data, + sourceDeviceId: data.sourceDeviceId ?? undefined, + externalId: data.externalId ?? undefined, + rawData: data.rawData ?? undefined, + }, }); - return attendance; } + async bulkSyncAttendanceFromDevice(deviceId: string, records: Array<{ employeePin: string; checkIn?: string; checkOut?: string; date: string }>, userId: string) { + const results: { created: number; updated: number; skipped: number } = { created: 0, updated: 0, skipped: 0 }; + for (const rec of records) { + const emp = await prisma.employee.findFirst({ + where: { + OR: [ + { attendancePin: rec.employeePin }, + { uniqueEmployeeId: rec.employeePin }, + ], + }, + }); + if (!emp) { + results.skipped++; + continue; + } + const date = new Date(rec.date); + const existing = await prisma.attendance.findUnique({ + where: { employeeId_date: { employeeId: emp.id, date } }, + }); + const checkIn = rec.checkIn ? new Date(rec.checkIn) : null; + const checkOut = rec.checkOut ? new Date(rec.checkOut) : null; + const workHours = checkIn && checkOut ? (checkOut.getTime() - checkIn.getTime()) / (1000 * 60 * 60) : null; + + if (existing) { + await prisma.attendance.update({ + where: { id: existing.id }, + data: { + checkIn: checkIn ?? existing.checkIn, + checkOut: checkOut ?? existing.checkOut, + workHours: workHours ?? existing.workHours, + status: rec.checkIn ? 'PRESENT' : existing.status, + sourceDeviceId: deviceId, + externalId: `${deviceId}-${emp.id}-${rec.date}`, + rawData: rec as any, + }, + }); + results.updated++; + } else { + await prisma.attendance.create({ + data: { + employeeId: emp.id, + date, + checkIn, + checkOut, + workHours, + status: rec.checkIn ? 'PRESENT' : 'ABSENT', + sourceDeviceId: deviceId, + externalId: `${deviceId}-${emp.id}-${rec.date}`, + rawData: rec as any, + }, + }); + results.created++; + } + } + await AuditLogger.log({ entityType: 'ATTENDANCE', entityId: deviceId, action: 'BULK_SYNC', userId, changes: results }); + return results; + } + async getAttendance(employeeId: string, month: number, year: number) { return prisma.attendance.findMany({ where: { @@ -212,10 +313,24 @@ class HRService { // ========== LEAVES ========== async createLeaveRequest(data: any, userId: string) { + const days = this.calculateLeaveDays(data.startDate, data.endDate); + const startDate = new Date(data.startDate); + const year = startDate.getFullYear(); + + const ent = await prisma.leaveEntitlement.findUnique({ + where: { employeeId_year_leaveType: { employeeId: data.employeeId, year, leaveType: data.leaveType } }, + }); + if (ent) { + const available = ent.totalDays + ent.carriedOver - ent.usedDays; + if (days > available) { + throw new AppError(400, `رصيد الإجازة غير كافٍ - Insufficient leave balance. Available: ${available}, Requested: ${days}`); + } + } + const leave = await prisma.leave.create({ data: { ...data, - days: this.calculateLeaveDays(data.startDate, data.endDate), + days, }, include: { employee: true, @@ -239,12 +354,16 @@ class HRService { status: 'APPROVED', approvedBy, approvedAt: new Date(), + rejectedReason: null, }, include: { employee: true, }, }); + const year = new Date(leave.startDate).getFullYear(); + await this.updateLeaveEntitlementUsed(leave.employeeId, year, leave.leaveType, leave.days); + await AuditLogger.log({ entityType: 'LEAVE', entityId: leave.id, @@ -255,6 +374,62 @@ class HRService { return leave; } + async rejectLeave(id: string, rejectedReason: string, userId: string) { + const leave = await prisma.leave.findUnique({ where: { id } }); + if (!leave) throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found'); + + const updated = await prisma.leave.update({ + where: { id }, + data: { + status: 'REJECTED', + rejectedReason, + approvedBy: null, + approvedAt: null, + }, + include: { employee: true }, + }); + + await AuditLogger.log({ + entityType: 'LEAVE', + entityId: id, + action: 'REJECT', + userId, + reason: rejectedReason, + }); + return updated; + } + + async findAllLeaves(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) { + const skip = (page - 1) * pageSize; + const where: any = {}; + if (filters.employeeId) where.employeeId = filters.employeeId; + if (filters.status && filters.status !== 'all') where.status = filters.status; + + const [total, leaves] = await Promise.all([ + prisma.leave.count({ where }), + prisma.leave.findMany({ + where, + skip, + take: pageSize, + include: { employee: { select: { id: true, firstName: true, lastName: true, uniqueEmployeeId: true } } }, + orderBy: { createdAt: 'desc' }, + }), + ]); + return { leaves, total, page, pageSize }; + } + + private async updateLeaveEntitlementUsed(employeeId: string, year: number, leaveType: string, days: number) { + const ent = await prisma.leaveEntitlement.findUnique({ + where: { employeeId_year_leaveType: { employeeId, year, leaveType } }, + }); + if (ent) { + await prisma.leaveEntitlement.update({ + where: { employeeId_year_leaveType: { employeeId, year, leaveType } }, + data: { usedDays: { increment: days } }, + }); + } + } + // ========== SALARIES ========== async processSalary(employeeId: string, month: number, year: number, userId: string) { @@ -341,6 +516,323 @@ class HRService { return salary; } + // ========== LOANS ========== + + private async generateLoanNumber(): Promise { + const year = new Date().getFullYear(); + const prefix = `LN-${year}-`; + const last = await prisma.loan.findFirst({ + where: { loanNumber: { startsWith: prefix } }, + orderBy: { createdAt: 'desc' }, + select: { loanNumber: true }, + }); + let next = 1; + if (last) { + const parts = last.loanNumber.split('-'); + next = parseInt(parts[2] || '0') + 1; + } + return `${prefix}${next.toString().padStart(4, '0')}`; + } + + async findAllLoans(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) { + const skip = (page - 1) * pageSize; + const where: any = {}; + if (filters.employeeId) where.employeeId = filters.employeeId; + if (filters.status && filters.status !== 'all') where.status = filters.status; + const [total, loans] = await Promise.all([ + prisma.loan.count({ where }), + prisma.loan.findMany({ + where, + skip, + take: pageSize, + include: { employee: { select: { id: true, firstName: true, lastName: true, uniqueEmployeeId: true } }, installmentsList: true }, + orderBy: { createdAt: 'desc' }, + }), + ]); + return { loans, total, page, pageSize }; + } + + async findLoanById(id: string) { + const loan = await prisma.loan.findUnique({ + where: { id }, + include: { employee: true, installmentsList: { orderBy: { installmentNumber: 'asc' } } }, + }); + if (!loan) throw new AppError(404, 'القرض غير موجود - Loan not found'); + return loan; + } + + async createLoan(data: { employeeId: string; type: string; amount: number; installments?: number; reason?: string }, userId: string) { + const loanNumber = await this.generateLoanNumber(); + const installments = data.installments || 1; + const monthlyAmount = data.amount / installments; + const loan = await prisma.loan.create({ + data: { + loanNumber, + employeeId: data.employeeId, + type: data.type, + amount: data.amount, + installments, + monthlyAmount, + reason: data.reason, + status: 'PENDING', + }, + include: { employee: true }, + }); + await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId }); + return loan; + } + + async approveLoan(id: string, approvedBy: string, startDate: Date, userId: string) { + const loan = await prisma.loan.findUnique({ where: { id }, include: { installmentsList: true } }); + if (!loan) throw new AppError(404, 'القرض غير موجود - Loan not found'); + if (loan.status !== 'PENDING') throw new AppError(400, 'لا يمكن الموافقة على قرض غير معلق - Cannot approve non-pending loan'); + + const monthlyAmount = Number(loan.monthlyAmount || loan.amount) / loan.installments; + const installments: { installmentNumber: number; dueDate: Date; amount: number }[] = []; + let d = new Date(startDate); + for (let i = 1; i <= loan.installments; i++) { + installments.push({ installmentNumber: i, dueDate: new Date(d), amount: monthlyAmount }); + d.setMonth(d.getMonth() + 1); + } + const endDate = installments.length ? installments[installments.length - 1].dueDate : null; + + await prisma.$transaction([ + prisma.loan.update({ + where: { id }, + data: { status: 'ACTIVE', approvedBy, approvedAt: new Date(), startDate, endDate }, + }), + ...installments.map((inst) => + prisma.loanInstallment.create({ + data: { loanId: id, installmentNumber: inst.installmentNumber, dueDate: inst.dueDate, amount: inst.amount, status: 'PENDING' }, + }) + ), + ]); + + await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: 'APPROVE', userId }); + return this.findLoanById(id); + } + + async rejectLoan(id: string, rejectedReason: string, userId: string) { + const loan = await prisma.loan.update({ + where: { id }, + data: { status: 'REJECTED', rejectedReason }, + include: { employee: true }, + }); + await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: 'REJECT', userId, reason: rejectedReason }); + return loan; + } + + async recordLoanInstallmentPayment(loanId: string, installmentId: string, paidDate: Date, userId: string) { + await prisma.loanInstallment.update({ + where: { id: installmentId }, + data: { status: 'PAID', paidDate }, + }); + const allPaid = (await prisma.loanInstallment.count({ where: { loanId, status: 'PENDING' } })) === 0; + if (allPaid) { + await prisma.loan.update({ where: { id: loanId }, data: { status: 'PAID_OFF' } }); + } + await AuditLogger.log({ entityType: 'LOAN_INSTALLMENT', entityId: installmentId, action: 'PAY', userId }); + return this.findLoanById(loanId); + } + + // ========== PURCHASE REQUESTS ========== + + private async generatePurchaseRequestNumber(): Promise { + const year = new Date().getFullYear(); + const prefix = `PR-${year}-`; + const last = await prisma.purchaseRequest.findFirst({ + where: { requestNumber: { startsWith: prefix } }, + orderBy: { createdAt: 'desc' }, + select: { requestNumber: true }, + }); + let next = 1; + if (last) { + const parts = last.requestNumber.split('-'); + next = parseInt(parts[2] || '0') + 1; + } + return `${prefix}${next.toString().padStart(4, '0')}`; + } + + async findAllPurchaseRequests(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) { + const skip = (page - 1) * pageSize; + const where: any = {}; + if (filters.employeeId) where.employeeId = filters.employeeId; + if (filters.status && filters.status !== 'all') where.status = filters.status; + const [total, requests] = await Promise.all([ + prisma.purchaseRequest.count({ where }), + prisma.purchaseRequest.findMany({ + where, + skip, + take: pageSize, + include: { employee: { select: { id: true, firstName: true, lastName: true, uniqueEmployeeId: true } } }, + orderBy: { createdAt: 'desc' }, + }), + ]); + return { purchaseRequests: requests, total, page, pageSize }; + } + + async findPurchaseRequestById(id: string) { + const req = await prisma.purchaseRequest.findUnique({ where: { id }, include: { employee: true } }); + if (!req) throw new AppError(404, 'طلب الشراء غير موجود - Purchase request not found'); + return req; + } + + async createPurchaseRequest(data: { employeeId: string; items: any[]; reason?: string; priority?: string }, userId: string) { + const requestNumber = await this.generatePurchaseRequestNumber(); + const totalAmount = Array.isArray(data.items) + ? data.items.reduce((s: number, i: any) => s + (Number(i.estimatedPrice || 0) * Number(i.quantity || 1)), 0) + : 0; + const req = await prisma.purchaseRequest.create({ + data: { + requestNumber, + employeeId: data.employeeId, + items: data.items, + totalAmount, + reason: data.reason, + priority: data.priority || 'NORMAL', + status: 'PENDING', + }, + include: { employee: true }, + }); + await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: req.id, action: 'CREATE', userId }); + return req; + } + + async approvePurchaseRequest(id: string, approvedBy: string, userId: string) { + const req = await prisma.purchaseRequest.update({ + where: { id }, + data: { status: 'APPROVED', approvedBy, approvedAt: new Date(), rejectedReason: null }, + include: { employee: true }, + }); + await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'APPROVE', userId }); + return req; + } + + async rejectPurchaseRequest(id: string, rejectedReason: string, userId: string) { + const req = await prisma.purchaseRequest.update({ + where: { id }, + data: { status: 'REJECTED', rejectedReason }, + include: { employee: true }, + }); + await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'REJECT', userId, reason: rejectedReason }); + return req; + } + + // ========== LEAVE ENTITLEMENTS ========== + + async getLeaveBalance(employeeId: string, year: number) { + const entitlements = await prisma.leaveEntitlement.findMany({ + where: { employeeId, year }, + }); + const approvedLeaves = await prisma.leave.findMany({ + where: { employeeId, status: 'APPROVED', startDate: { gte: new Date(year, 0, 1) }, endDate: { lte: new Date(year, 11, 31) } }, + }); + const usedByType: Record = {}; + for (const l of approvedLeaves) { + usedByType[l.leaveType] = (usedByType[l.leaveType] || 0) + l.days; + } + return entitlements.map((e) => ({ + leaveType: e.leaveType, + totalDays: e.totalDays, + carriedOver: e.carriedOver, + usedDays: usedByType[e.leaveType] ?? e.usedDays, + available: e.totalDays + e.carriedOver - (usedByType[e.leaveType] ?? e.usedDays), + })); + } + + async findAllLeaveEntitlements(employeeId?: string, year?: number) { + const where: any = {}; + if (employeeId) where.employeeId = employeeId; + if (year) where.year = year; + return prisma.leaveEntitlement.findMany({ + where, + include: { employee: { select: { id: true, firstName: true, lastName: true } } }, + orderBy: [{ employeeId: 'asc' }, { year: 'desc' }, { leaveType: 'asc' } ], + }); + } + + async upsertLeaveEntitlement(data: { employeeId: string; year: number; leaveType: string; totalDays: number; carriedOver?: number; notes?: string }, userId: string) { + const ent = await prisma.leaveEntitlement.upsert({ + where: { employeeId_year_leaveType: { employeeId: data.employeeId, year: data.year, leaveType: data.leaveType } }, + create: { employeeId: data.employeeId, year: data.year, leaveType: data.leaveType, totalDays: data.totalDays, carriedOver: data.carriedOver || 0, notes: data.notes }, + update: { totalDays: data.totalDays, carriedOver: data.carriedOver ?? undefined, notes: data.notes }, + }); + await AuditLogger.log({ entityType: 'LEAVE_ENTITLEMENT', entityId: ent.id, action: 'UPSERT', userId }); + return ent; + } + + // ========== EMPLOYEE CONTRACTS ========== + + private async generateContractNumber(): Promise { + const year = new Date().getFullYear(); + const prefix = `ECT-${year}-`; + const last = await prisma.employeeContract.findFirst({ + where: { contractNumber: { startsWith: prefix } }, + orderBy: { createdAt: 'desc' }, + select: { contractNumber: true }, + }); + let next = 1; + if (last) { + const parts = last.contractNumber.split('-'); + next = parseInt(parts[2] || '0') + 1; + } + return `${prefix}${next.toString().padStart(4, '0')}`; + } + + async findAllEmployeeContracts(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) { + const skip = (page - 1) * pageSize; + const where: any = {}; + if (filters.employeeId) where.employeeId = filters.employeeId; + if (filters.status && filters.status !== 'all') where.status = filters.status; + const [total, contracts] = await Promise.all([ + prisma.employeeContract.count({ where }), + prisma.employeeContract.findMany({ + where, + skip, + take: pageSize, + include: { employee: { select: { id: true, firstName: true, lastName: true, uniqueEmployeeId: true } } }, + orderBy: { startDate: 'desc' }, + }), + ]); + return { contracts, total, page, pageSize }; + } + + async findEmployeeContractById(id: string) { + const c = await prisma.employeeContract.findUnique({ where: { id }, include: { employee: true } }); + if (!c) throw new AppError(404, 'العقد غير موجود - Contract not found'); + return c; + } + + async createEmployeeContract(data: { employeeId: string; type: string; startDate: Date; endDate?: Date; salary: number; documentUrl?: string; notes?: string }, userId: string) { + const contractNumber = await this.generateContractNumber(); + const contract = await prisma.employeeContract.create({ + data: { + contractNumber, + employeeId: data.employeeId, + type: data.type, + startDate: data.startDate, + endDate: data.endDate, + salary: data.salary, + documentUrl: data.documentUrl, + notes: data.notes, + status: 'ACTIVE', + }, + include: { employee: true }, + }); + await AuditLogger.log({ entityType: 'EMPLOYEE_CONTRACT', entityId: contract.id, action: 'CREATE', userId }); + return contract; + } + + async updateEmployeeContract(id: string, data: { type?: string; endDate?: Date; salary?: number; documentUrl?: string; status?: string; notes?: string }, userId: string) { + const contract = await prisma.employeeContract.update({ + where: { id }, + data, + include: { employee: true }, + }); + await AuditLogger.log({ entityType: 'EMPLOYEE_CONTRACT', entityId: id, action: 'UPDATE', userId }); + return contract; + } + // ========== HELPERS ========== private async generateEmployeeId(): Promise { @@ -383,11 +875,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/modules/hr/portal.controller.ts b/backend/src/modules/hr/portal.controller.ts new file mode 100644 index 0000000..e16adc2 --- /dev/null +++ b/backend/src/modules/hr/portal.controller.ts @@ -0,0 +1,106 @@ +import { Response, NextFunction } from 'express'; +import { AuthRequest } from '../../shared/middleware/auth'; +import { portalService } from './portal.service'; +import { ResponseFormatter } from '../../shared/utils/responseFormatter'; + +export class PortalController { + async getMe(req: AuthRequest, res: Response, next: NextFunction) { + try { + const data = await portalService.getMe(req.user?.employeeId); + res.json(ResponseFormatter.success(data)); + } catch (error) { + next(error); + } + } + + async getMyLoans(req: AuthRequest, res: Response, next: NextFunction) { + try { + const loans = await portalService.getMyLoans(req.user?.employeeId); + res.json(ResponseFormatter.success(loans)); + } catch (error) { + next(error); + } + } + + async submitLoanRequest(req: AuthRequest, res: Response, next: NextFunction) { + try { + const loan = await portalService.submitLoanRequest(req.user?.employeeId, req.body, req.user!.id); + res.status(201).json(ResponseFormatter.success(loan, 'تم إرسال طلب القرض - Loan request submitted')); + } catch (error) { + next(error); + } + } + + async getMyLeaveBalance(req: AuthRequest, res: Response, next: NextFunction) { + try { + const year = req.query.year ? parseInt(req.query.year as string) : undefined; + const balance = await portalService.getMyLeaveBalance(req.user?.employeeId, year); + res.json(ResponseFormatter.success(balance)); + } catch (error) { + next(error); + } + } + + async getMyLeaves(req: AuthRequest, res: Response, next: NextFunction) { + try { + const leaves = await portalService.getMyLeaves(req.user?.employeeId); + res.json(ResponseFormatter.success(leaves)); + } catch (error) { + next(error); + } + } + + async submitLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) { + try { + const data = { + ...req.body, + startDate: new Date(req.body.startDate), + endDate: new Date(req.body.endDate), + }; + const leave = await portalService.submitLeaveRequest(req.user?.employeeId, data, req.user!.id); + res.status(201).json(ResponseFormatter.success(leave, 'تم إرسال طلب الإجازة - Leave request submitted')); + } catch (error) { + next(error); + } + } + + async getMyPurchaseRequests(req: AuthRequest, res: Response, next: NextFunction) { + try { + const requests = await portalService.getMyPurchaseRequests(req.user?.employeeId); + res.json(ResponseFormatter.success(requests)); + } catch (error) { + next(error); + } + } + + async submitPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) { + try { + const pr = await portalService.submitPurchaseRequest(req.user?.employeeId, req.body, req.user!.id); + res.status(201).json(ResponseFormatter.success(pr, 'تم إرسال طلب الشراء - Purchase request submitted')); + } catch (error) { + next(error); + } + } + + async getMyAttendance(req: AuthRequest, res: Response, next: NextFunction) { + try { + const month = req.query.month ? parseInt(req.query.month as string) : undefined; + const year = req.query.year ? parseInt(req.query.year as string) : undefined; + const attendance = await portalService.getMyAttendance(req.user?.employeeId, month, year); + res.json(ResponseFormatter.success(attendance)); + } catch (error) { + next(error); + } + } + + async getMySalaries(req: AuthRequest, res: Response, next: NextFunction) { + try { + const salaries = await portalService.getMySalaries(req.user?.employeeId); + res.json(ResponseFormatter.success(salaries)); + } catch (error) { + next(error); + } + } +} + +export const portalController = new PortalController(); diff --git a/backend/src/modules/hr/portal.service.ts b/backend/src/modules/hr/portal.service.ts new file mode 100644 index 0000000..981b86a --- /dev/null +++ b/backend/src/modules/hr/portal.service.ts @@ -0,0 +1,114 @@ +import prisma from '../../config/database'; +import { AppError } from '../../shared/middleware/errorHandler'; +import { hrService } from './hr.service'; + +class PortalService { + private requireEmployeeId(employeeId: string | undefined): string { + if (!employeeId) { + throw new AppError(403, 'يجب ربط المستخدم بموظف للوصول للبوابة - Employee link required for portal access'); + } + return employeeId; + } + + async getMe(employeeId: string | undefined) { + const empId = this.requireEmployeeId(employeeId); + const employee = await prisma.employee.findUnique({ + where: { id: empId }, + select: { + id: true, + uniqueEmployeeId: true, + firstName: true, + lastName: true, + firstNameAr: true, + lastNameAr: true, + email: true, + department: { select: { name: true, nameAr: true } }, + position: { select: { title: true, titleAr: true } }, + }, + }); + if (!employee) throw new AppError(404, 'الموظف غير موجود - Employee not found'); + + const [loansCount, pendingLeaves, pendingPurchaseRequests, leaveBalance] = await Promise.all([ + prisma.loan.count({ where: { employeeId: empId, status: { in: ['PENDING', 'ACTIVE'] } } }), + prisma.leave.count({ where: { employeeId: empId, status: 'PENDING' } }), + prisma.purchaseRequest.count({ where: { employeeId: empId, status: 'PENDING' } }), + hrService.getLeaveBalance(empId, new Date().getFullYear()), + ]); + + return { + employee, + stats: { + activeLoansCount: loansCount, + pendingLeavesCount: pendingLeaves, + pendingPurchaseRequestsCount: pendingPurchaseRequests, + leaveBalance, + }, + }; + } + + async getMyLoans(employeeId: string | undefined) { + const empId = this.requireEmployeeId(employeeId); + return prisma.loan.findMany({ + where: { employeeId: empId }, + include: { installmentsList: { orderBy: { installmentNumber: 'asc' } } }, + orderBy: { createdAt: 'desc' }, + }); + } + + async submitLoanRequest(employeeId: string | undefined, data: { type: string; amount: number; installments?: number; reason?: string }, userId: string) { + const empId = this.requireEmployeeId(employeeId); + return hrService.createLoan({ ...data, employeeId: empId }, userId); + } + + async getMyLeaveBalance(employeeId: string | undefined, year?: number) { + const empId = this.requireEmployeeId(employeeId); + const y = year || new Date().getFullYear(); + return hrService.getLeaveBalance(empId, y); + } + + async getMyLeaves(employeeId: string | undefined) { + const empId = this.requireEmployeeId(employeeId); + return prisma.leave.findMany({ + where: { employeeId: empId }, + orderBy: { createdAt: 'desc' }, + take: 50, + }); + } + + async submitLeaveRequest(employeeId: string | undefined, data: { leaveType: string; startDate: Date; endDate: Date; reason?: string }, userId: string) { + const empId = this.requireEmployeeId(employeeId); + return hrService.createLeaveRequest({ ...data, employeeId: empId }, userId); + } + + async getMyPurchaseRequests(employeeId: string | undefined) { + const empId = this.requireEmployeeId(employeeId); + return prisma.purchaseRequest.findMany({ + where: { employeeId: empId }, + orderBy: { createdAt: 'desc' }, + }); + } + + async submitPurchaseRequest(employeeId: string | undefined, data: { items: any[]; reason?: string; priority?: string }, userId: string) { + const empId = this.requireEmployeeId(employeeId); + return hrService.createPurchaseRequest({ ...data, employeeId: empId }, userId); + } + + async getMyAttendance(employeeId: string | undefined, month?: number, year?: number) { + const empId = this.requireEmployeeId(employeeId); + const now = new Date(); + const m = month ?? now.getMonth() + 1; + const y = year ?? now.getFullYear(); + return hrService.getAttendance(empId, m, y); + } + + async getMySalaries(employeeId: string | undefined) { + const empId = this.requireEmployeeId(employeeId); + return prisma.salary.findMany({ + where: { employeeId: empId }, + orderBy: [{ year: 'desc' }, { month: 'desc' }], + take: 24, + }); + } +} + +export const portalService = new PortalService(); 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 cc32179..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, @@ -16,8 +17,7 @@ import { Clock, Building2, LogOut, - LayoutDashboard, - Users2 + LayoutDashboard } from 'lucide-react' function AdminLayoutContent({ children }: { children: React.ReactNode }) { @@ -28,7 +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: Users2, label: 'مجموعات الصلاحيات', href: '/admin/permission-groups' }, + { 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 index 33de75b..1bd7fab 100644 --- a/frontend/src/app/admin/permission-groups/page.tsx +++ b/frontend/src/app/admin/permission-groups/page.tsx @@ -1,259 +1,371 @@ -'use client' +'use client'; -import { useEffect, useMemo, useState } from 'react' -import { Plus, Edit, Trash2, Users2 } from 'lucide-react' -import Modal from '@/components/Modal' - -type PermissionGroup = { - id: string - name: string - nameAr?: string - modules: string[] - createdAt: string -} +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: 'إدارة جهات الاتصال' }, - { id: 'crm', name: 'إدارة علاقات العملاء' }, - { id: 'inventory', name: 'المخزون والأصول' }, - { id: 'projects', name: 'المهام والمشاريع' }, - { id: 'hr', name: 'الموارد البشرية' }, - { id: 'marketing', name: 'التسويق' }, - { id: 'admin', name: 'لوحة الإدارة' }, -] + { 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 STORAGE_KEY = 'permissionGroups' +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 [showModal, setShowModal] = useState(false) - const [editing, setEditing] = useState(null) + 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 [name, setName] = useState('') - const [nameAr, setNameAr] = useState('') - const [selectedModules, setSelectedModules] = useState>({}) + 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(() => { - try { - const raw = localStorage.getItem(STORAGE_KEY) - if (raw) setGroups(JSON.parse(raw)) - } catch { - // ignore - } - }, []) + 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 { - localStorage.setItem(STORAGE_KEY, JSON.stringify(groups)) - } catch { - // ignore + 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); } - }, [groups]) + }; - const baseModulesMap = useMemo(() => { - const m: Record = {} - MODULES.forEach(x => (m[x.id] = false)) - return m - }, []) - - const openCreate = () => { - setEditing(null) - setName('') - setNameAr('') - setSelectedModules({ ...baseModulesMap }) - setShowModal(true) - } - - const openEdit = (g: PermissionGroup) => { - setEditing(g) - setName(g.name || '') - setNameAr(g.nameAr || '') - const m = { ...baseModulesMap } - g.modules.forEach(id => (m[id] = true)) - setSelectedModules(m) - setShowModal(true) - } - - const toggleModule = (id: string) => { - setSelectedModules(prev => ({ ...prev, [id]: !prev[id] })) - } - - const save = () => { - const finalName = name.trim() || nameAr.trim() - if (!finalName) { - alert('الرجاء إدخال اسم المجموعة') - return + 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 mods = Object.keys(selectedModules).filter(k => selectedModules[k]) - const now = new Date().toISOString() - - if (editing) { - setGroups(prev => - prev.map(g => - g.id === editing.id - ? { ...g, name: finalName, nameAr: nameAr.trim() || undefined, modules: mods } - : g - ) - ) - } else { - const id = crypto?.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}` - setGroups(prev => [ - { id, name: finalName, nameAr: nameAr.trim() || undefined, modules: mods, createdAt: now }, - ...prev, - ]) - } - - setShowModal(false) - } - - const remove = (g: PermissionGroup) => { - const ok = confirm(`هل أنت متأكد بحذف مجموعة الصلاحيات؟: ${g.nameAr || g.name} ؟`) - if (!ok) return - setGroups(prev => prev.filter(x => x.id !== g.id)) - } + const handleTogglePermission = (moduleId: string, actionId: string) => { + setPermissionMatrix((prev) => ({ + ...prev, + [moduleId]: { + ...(prev[moduleId] || {}), + [actionId]: !prev[moduleId]?.[actionId], + }, + })); + }; return (

مجموعات الصلاحيات

-

إدارة مجموعات لتجميع الوحدات بشكل أسرع

+

مجموعات اختيارية تضيف صلاحيات إضافية للمستخدمين بغض النظر عن وظائفهم

-
- {groups.length === 0 ? ( -
- -

لا توجد مجموعات

-

قم بإضافة مجموعة صلاحيات لتسهيل إدارة الأدوار.

+ {loading ? ( +
+
+ ) : error ? ( +
{error}
) : ( -
- {groups.map(g => ( -
-
-
-

{g.nameAr || g.name}

-

{g.name}

-

- الوحدات: {g.modules.length} -

+
+
+

المجموعات ({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} مستخدم + -
+ ))} +
-
- {g.modules.map(id => { - const m = MODULES.find(x => x.id === id) - return ( - - {m?.name || id} - - ) - })} +
+ {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 ? : } +
+
+
-
- ))} + ) : ( +
+ +

اختر مجموعة

+

أو أنشئ مجموعة جديدة لإضافة صلاحيات اختيارية للمستخدمين

+
+ )} +
)} - setShowModal(false)} - title={editing ? 'تعديل مجموعة الصلاحيات' : 'إضافة مجموعة صلاحيات'} - size="lg" - > -
-
-
- - setName(e.target.value)} - className="w-full border border-gray-300 rounded-lg px-3 py-2" - placeholder="e.g. Sales Group" - /> -
- -
- - setNameAr(e.target.value)} - className="w-full border border-gray-300 rounded-lg px-3 py-2" - placeholder="مثال: مجموعة المبيعات" - /> -
-
- + setShowCreateModal(false)} title="إضافة مجموعة صلاحيات" size="md"> +
- -
- {MODULES.map(m => ( - - ))} + + 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" + /> +
+
+ +