This commit is contained in:
yotakii
2026-03-05 11:57:04 +03:00
58 changed files with 5302 additions and 1608 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

BIN
assets/capture-latest.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

View File

@@ -9,13 +9,13 @@
"start": "node dist/server.js", "start": "node dist/server.js",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:seed": "ts-node prisma/seed.ts", "prisma:seed": "node prisma/seed.js",
"prisma:studio": "prisma studio", "prisma:studio": "prisma studio",
"db:clean-and-seed": "node prisma/clean-and-seed.js", "db:clean-and-seed": "node prisma/clean-and-seed.js",
"test": "jest" "test": "jest"
}, },
"prisma": { "prisma": {
"seed": "ts-node prisma/seed.ts" "seed": "node prisma/seed.js"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^5.8.0", "@prisma/client": "^5.8.0",

View File

@@ -83,7 +83,7 @@ async function main() {
console.log('\n🌱 Running seed...\n'); console.log('\n🌱 Running seed...\n');
const backendDir = path.resolve(__dirname, '..'); const backendDir = path.resolve(__dirname, '..');
execSync('node prisma/seed-prod.js', { execSync('node prisma/seed.js', {
stdio: 'inherit', stdio: 'inherit',
cwd: backendDir, cwd: backendDir,
env: process.env, env: process.env,

View File

@@ -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 = '*'
);

View File

@@ -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;

View File

@@ -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;

View File

@@ -69,10 +69,59 @@ model User {
assignedTasks Task[] assignedTasks Task[]
projectMembers ProjectMember[] projectMembers ProjectMember[]
campaigns Campaign[] campaigns Campaign[]
userRoles UserRole[]
@@map("users") @@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 { model Employee {
id String @id @default(uuid()) id String @id @default(uuid())
uniqueEmployeeId String @unique // رقم الموظف الموحد uniqueEmployeeId String @unique // رقم الموظف الموحد
@@ -129,6 +178,9 @@ model Employee {
// Documents // Documents
documents Json? // Array of document references documents Json? // Array of document references
// ZK Tico / Attendance device - maps to employee pin on device
attendancePin String? @unique
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -143,6 +195,10 @@ model Employee {
disciplinaryActions DisciplinaryAction[] disciplinaryActions DisciplinaryAction[]
allowances Allowance[] allowances Allowance[]
commissions Commission[] commissions Commission[]
loans Loan[]
purchaseRequests PurchaseRequest[]
leaveEntitlements LeaveEntitlement[]
employeeContracts EmployeeContract[]
@@index([departmentId]) @@index([departmentId])
@@index([positionId]) @@index([positionId])
@@ -221,12 +277,18 @@ model Attendance {
status String // PRESENT, ABSENT, LATE, HALF_DAY, etc. status String // PRESENT, ABSENT, LATE, HALF_DAY, etc.
notes String? notes String?
// ZK Tico / External device sync
sourceDeviceId String?
externalId String?
rawData Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@unique([employeeId, date]) @@unique([employeeId, date])
@@index([employeeId]) @@index([employeeId])
@@index([date]) @@index([date])
@@index([sourceDeviceId])
@@map("attendances") @@map("attendances")
} }
@@ -369,6 +431,115 @@ model DisciplinaryAction {
@@map("disciplinary_actions") @@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 // MODULE 1: CONTACT MANAGEMENT
// ============================================ // ============================================

146
backend/prisma/seed.js Normal file
View File

@@ -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();
});

View File

@@ -4,58 +4,50 @@ import bcrypt from 'bcryptjs';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
async function main() { 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({ const salesDept = await prisma.department.create({
data: { data: {
name: 'Sales Department', name: 'Sales',
nameAr: 'قسم المبيعات', nameAr: 'المبيعات',
code: 'SALES', code: 'SALES',
description: 'Sales and Business Development', 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',
}, },
}); });
@@ -66,342 +58,83 @@ async function main() {
code: 'SALES_REP', code: 'SALES_REP',
departmentId: salesDept.id, departmentId: salesDept.id,
level: 3, 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({ await prisma.positionPermission.createMany({
data: [ data: [
{ { positionId: salesRepPosition.id, module: 'contacts', resource: '*', actions: ['read', 'create', 'update'] },
positionId: salesManagerPosition.id, { positionId: salesRepPosition.id, module: 'crm', resource: 'deals', actions: ['read', 'create', 'update'] },
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'],
},
], ],
}); });
// 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({ await prisma.positionPermission.createMany({
data: [ data: [
{ { positionId: accountantPosition.id, module: 'contacts', resource: '*', actions: ['read'] },
positionId: salesRepPosition.id, { positionId: accountantPosition.id, module: 'crm', resource: '*', actions: ['read'] },
module: 'contacts', { positionId: accountantPosition.id, module: 'inventory', resource: '*', actions: ['read'] },
resource: 'contacts', { positionId: accountantPosition.id, module: 'hr', resource: '*', actions: ['read'] },
actions: ['create', 'read', 'update'],
},
{
positionId: salesRepPosition.id,
module: 'crm',
resource: 'deals',
actions: ['create', 'read', 'update'],
},
{
positionId: salesRepPosition.id,
module: 'crm',
resource: 'quotes',
actions: ['create', 'read'],
},
], ],
}); });
console.log('✅ Created permissions'); console.log('✅ Created position and permissions');
// Create Employees // Create minimal Employee for System Administrator
const gmEmployee = await prisma.employee.create({ const sysAdminEmployee = await prisma.employee.create({
data: { data: {
uniqueEmployeeId: 'EMP-2024-0001', uniqueEmployeeId: 'SYS-001',
firstName: 'Ahmed', firstName: 'System',
lastName: 'Al-Mutairi', lastName: 'Administrator',
firstNameAr: 'أحمد', firstNameAr: 'مدير',
lastNameAr: 'المطيري', lastNameAr: 'النظام',
email: 'gm@atmata.com', email: 'admin@system.local',
mobile: '+966500000001', mobile: '+966500000000',
dateOfBirth: new Date('1980-01-01'), dateOfBirth: new Date('1990-01-01'),
gender: 'MALE', gender: 'MALE',
nationality: 'Saudi', nationality: 'Saudi',
employmentType: 'Full-time', employmentType: 'Full-time',
contractType: 'Unlimited', contractType: 'Unlimited',
hireDate: new Date('2020-01-01'), hireDate: new Date(),
departmentId: salesDept.id, departmentId: adminDept.id,
positionId: gmPosition.id, positionId: sysAdminPosition.id,
basicSalary: 50000, basicSalary: 0,
status: 'ACTIVE', status: 'ACTIVE',
}, },
}); });
const salesManagerEmployee = await prisma.employee.create({ // Create System Administrator User
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
const hashedPassword = await bcrypt.hash('Admin@123', 10); const hashedPassword = await bcrypt.hash('Admin@123', 10);
await prisma.user.create({
const gmUser = await prisma.user.create({
data: { data: {
email: 'gm@atmata.com', email: 'admin@system.local',
username: 'admin', username: 'admin',
password: hashedPassword, password: hashedPassword,
employeeId: gmEmployee.id, employeeId: sysAdminEmployee.id,
isActive: true, isActive: true,
}, },
}); });
const salesManagerUser = await prisma.user.create({ console.log('✅ Created System Administrator');
data: {
email: 'sales.manager@atmata.com',
username: 'salesmanager',
password: hashedPassword,
employeeId: salesManagerEmployee.id,
isActive: true,
},
});
const salesRepUser = await prisma.user.create({ console.log('\n🎉 Database seeding completed!\n');
data: { console.log('📋 System Administrator:');
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('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('1. General Manager'); console.log(' Email: admin@system.local');
console.log(' Email: gm@atmata.com'); console.log(' Username: admin');
console.log(' Password: Admin@123'); console.log(' Password: Admin@123');
console.log(' Access: Full System Access'); console.log(' Access: Full system access (all modules)');
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('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
} }
@@ -413,4 +146,3 @@ main()
.finally(async () => { .finally(async () => {
await prisma.$disconnect(); await prisma.$disconnect();
}); });

View File

@@ -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());

View File

@@ -53,4 +53,4 @@ npm run db:clean-and-seed
echo "" echo ""
echo "✅ Done. Restart the application so it uses the cleaned database." 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)"

View File

@@ -136,17 +136,15 @@ class AdminController {
async createPosition(req: AuthRequest, res: Response, next: NextFunction) { async createPosition(req: AuthRequest, res: Response, next: NextFunction) {
try { try {
const userId = req.user!.id; const position = await adminService.createPosition({
const position = await adminService.createPosition( title: req.body.title,
{ titleAr: req.body.titleAr,
title: req.body.title, code: req.body.code,
titleAr: req.body.titleAr, departmentId: req.body.departmentId,
departmentId: req.body.departmentId, level: req.body.level,
level: req.body.level, description: req.body.description,
code: req.body.code, isActive: req.body.isActive,
}, });
userId
);
res.status(201).json(ResponseFormatter.success(position)); res.status(201).json(ResponseFormatter.success(position));
} catch (error) { } catch (error) {
next(error); next(error);
@@ -155,15 +153,15 @@ class AdminController {
async updatePosition(req: AuthRequest, res: Response, next: NextFunction) { async updatePosition(req: AuthRequest, res: Response, next: NextFunction) {
try { try {
const userId = req.user!.id; const position = await adminService.updatePosition(req.params.id, {
const position = await adminService.updatePosition( title: req.body.title,
req.params.id, titleAr: req.body.titleAr,
{ code: req.body.code,
title: req.body.title, departmentId: req.body.departmentId,
titleAr: req.body.titleAr, level: req.body.level,
}, description: req.body.description,
userId isActive: req.body.isActive,
); });
res.json(ResponseFormatter.success(position)); res.json(ResponseFormatter.success(position));
} catch (error) { } catch (error) {
next(error); next(error);
@@ -182,11 +180,69 @@ class AdminController {
} }
} }
async deletePosition(req: AuthRequest, res: Response, next: NextFunction) { // ========== PERMISSION GROUPS (Phase 3) ==========
async getPermissionGroups(req: AuthRequest, res: Response, next: NextFunction) {
try { try {
const userId = req.user!.id; const groups = await adminService.getPermissionGroups();
await adminService.deletePosition(req.params.id, userId); res.json(ResponseFormatter.success(groups));
res.json(ResponseFormatter.success(null, 'Role deleted successfully')); } 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) { } catch (error) {
next(error); next(error);
} }

View File

@@ -89,43 +89,33 @@ router.get(
adminController.getPositions adminController.getPositions
); );
// Create role
router.post( router.post(
'/positions', '/positions',
authorize('admin', 'roles', 'create'), authorize('admin', 'roles', 'create'),
[ [
body('title').notEmpty().trim(), body('title').notEmpty().trim(),
body('titleAr').optional().isString().trim(), body('code').notEmpty().trim(),
body('departmentId').isUUID(), body('departmentId').isUUID(),
body('level').optional().isInt({ min: 1 }), body('level').optional().isInt({ min: 1 }),
body('code').optional().isString().trim(),
], ],
validate, validate,
adminController.createPosition adminController.createPosition
); );
// Update role name (title/titleAr)
router.put( router.put(
'/positions/:id', '/positions/:id',
authorize('admin', 'roles', 'update'), authorize('admin', 'roles', 'update'),
[ [
param('id').isUUID(), param('id').isUUID(),
body('title').optional().notEmpty().trim(), 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, validate,
adminController.updatePosition adminController.updatePosition
); );
// Delete (soft delete) a role/position
router.delete(
'/positions/:id',
authorize('admin', 'roles', 'delete'),
param('id').isUUID(),
validate,
adminController.deletePosition
);
router.put( router.put(
'/positions/:id/permissions', '/positions/:id/permissions',
authorize('admin', 'roles', 'update'), authorize('admin', 'roles', 'update'),
@@ -137,4 +127,68 @@ router.put(
adminController.updatePositionPermissions adminController.updatePositionPermissions
); );
// ========== PERMISSION GROUPS (Phase 3 - multi-group) ==========
router.get(
'/permission-groups',
authorize('admin', 'roles', 'read'),
adminController.getPermissionGroups
);
router.post(
'/permission-groups',
authorize('admin', 'roles', 'create'),
[
body('name').notEmpty().trim(),
],
validate,
adminController.createPermissionGroup
);
router.put(
'/permission-groups/:id',
authorize('admin', 'roles', 'update'),
[param('id').isUUID()],
validate,
adminController.updatePermissionGroup
);
router.put(
'/permission-groups/:id/permissions',
authorize('admin', 'roles', 'update'),
[
param('id').isUUID(),
body('permissions').isArray(),
],
validate,
adminController.updatePermissionGroupPermissions
);
router.get(
'/users/:userId/roles',
authorize('admin', 'users', 'read'),
[param('userId').isUUID()],
validate,
adminController.getUserRoles
);
router.post(
'/users/:userId/roles',
authorize('admin', 'users', 'update'),
[
param('userId').isUUID(),
body('roleId').isUUID(),
],
validate,
adminController.assignUserRole
);
router.delete(
'/users/:userId/roles/:roleId',
authorize('admin', 'users', 'update'),
[param('userId').isUUID(), param('roleId').isUUID()],
validate,
adminController.removeUserRole
);
export default router; export default router;

View File

@@ -39,19 +39,6 @@ export interface AuditLogFilters {
pageSize?: number; pageSize?: number;
} }
export interface CreatePositionData {
title: string;
titleAr?: string;
departmentId: string;
level?: number;
code?: string;
}
export interface UpdatePositionData {
title?: string;
titleAr?: string;
}
class AdminService { class AdminService {
// ========== USERS ========== // ========== USERS ==========
@@ -107,7 +94,7 @@ class AdminService {
]); ]);
const sanitized = users.map((u) => { const sanitized = users.map((u) => {
const { password: _, ...rest } = u as any; const { password: _, ...rest } = u;
return rest; return rest;
}); });
@@ -137,7 +124,7 @@ class AdminService {
throw new AppError(404, 'المستخدم غير موجود - User not found'); throw new AppError(404, 'المستخدم غير موجود - User not found');
} }
const { password: _, ...rest } = user as any; const { password: _, ...rest } = user;
return rest; return rest;
} }
@@ -236,7 +223,7 @@ class AdminService {
...(data.email && { email: data.email }), ...(data.email && { email: data.email }),
...(data.username && { username: data.username }), ...(data.username && { username: data.username }),
...(data.isActive !== undefined && { isActive: data.isActive }), ...(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) { 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({ await AuditLogger.log({
entityType: 'USER', entityType: 'USER',
@@ -286,7 +273,7 @@ class AdminService {
}, },
}); });
const { password: _, ...sanitized } = updated as any; const { password: _, ...sanitized } = updated;
await AuditLogger.log({ await AuditLogger.log({
entityType: 'USER', entityType: 'USER',
@@ -393,7 +380,7 @@ class AdminService {
const positions = await prisma.position.findMany({ const positions = await prisma.position.findMany({
where: { isActive: true }, where: { isActive: true },
include: { include: {
department: { select: { id: true, name: true, nameAr: true } }, department: { select: { name: true, nameAr: true } },
permissions: true, permissions: true,
_count: { _count: {
select: { select: {
@@ -419,118 +406,100 @@ class AdminService {
return withUserCount; return withUserCount;
} }
private async generateUniqueCode(base: string) { async createPosition(data: {
const cleaned = (base || 'ROLE') title: string;
.toUpperCase() titleAr?: string;
.replace(/[^A-Z0-9]+/g, '_') code: string;
.replace(/^_+|_+$/g, '') departmentId: string;
.slice(0, 18) || 'ROLE'; level?: number;
description?: string;
for (let i = 0; i < 25; i++) { isActive?: boolean;
const suffix = Math.floor(1000 + Math.random() * 9000); }) {
const code = `${cleaned}_${suffix}`; const existing = await prisma.position.findUnique({
const exists = await prisma.position.findUnique({ where: { code } }); where: { code: data.code },
if (!exists) return code; });
if (existing) {
throw new AppError(400, 'كود الدور مستخدم - Position code already exists');
} }
// fallback const dept = await prisma.department.findUnique({
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({
where: { id: data.departmentId }, where: { id: data.departmentId },
}); });
if (!dept) {
if (!department || !department.isActive) {
throw new AppError(400, 'القسم غير موجود - Department not found'); throw new AppError(400, 'القسم غير موجود - Department not found');
} }
let code = (data.code || '').trim(); return prisma.position.create({
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({
data: { data: {
title: title || titleAr, title: data.title,
titleAr: titleAr || null, titleAr: data.titleAr,
code, code: data.code.trim().toUpperCase().replace(/\s+/g, '_'),
departmentId: data.departmentId, 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) { async updatePosition(
const existing = await prisma.position.findUnique({ where: { id: positionId } }); positionId: string,
if (!existing) { 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'); throw new AppError(404, 'الدور غير موجود - Position not found');
} }
const nextTitle = data.title !== undefined ? (data.title || '').trim() : existing.title; if (data.code && data.code !== position.code) {
const nextTitleAr = data.titleAr !== undefined ? (data.titleAr || '').trim() : (existing.titleAr || ''); const existing = await prisma.position.findUnique({
where: { code: data.code },
const finalTitle = nextTitle || nextTitleAr; });
if (!finalTitle) { if (existing) {
throw new AppError(400, 'اسم الدور مطلوب - Role name is required'); 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<string, any> = {};
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 }, where: { id: positionId },
data: { data: updateData,
title: finalTitle, include: {
titleAr: nextTitleAr ? nextTitleAr : null, 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[] }>) { async updatePositionPermissions(positionId: string, permissions: Array<{ module: string; resource: string; actions: string[] }>) {
@@ -554,57 +523,116 @@ 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);
} }
/** // ========== PERMISSION GROUPS (Phase 3 - optional roles for multi-group) ==========
* Soft delete a role (Position).
* - Prevent deletion if the position is assigned to any employees. async getPermissionGroups() {
* - Clean up position permissions. return prisma.role.findMany({
*/ where: { isActive: true },
async deletePosition(positionId: string, deletedById: string) {
const position = await prisma.position.findUnique({
where: { id: positionId },
include: { 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) { async assignUserRole(userId: string, roleId: string) {
throw new AppError(404, 'الدور غير موجود - Position not found'); 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) { return prisma.userRole.create({
throw new AppError( data: { userId, roleId },
400, include: { role: true },
'لا يمكن حذف هذا الدور لأنه مرتبط بموظفين. قم بتغيير دور الموظفين أولاً - Cannot delete: position is assigned to employees' });
); }
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 }; return { success: true };
} }
} }

View File

@@ -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();

View File

@@ -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;

View File

@@ -19,14 +19,20 @@ export class HRController {
async findAllEmployees(req: AuthRequest, res: Response, next: NextFunction) { async findAllEmployees(req: AuthRequest, res: Response, next: NextFunction) {
try { try {
const page = parseInt(req.query.page as string) || 1; const rawPage = parseInt(req.query.page as string, 10);
const pageSize = parseInt(req.query.pageSize as string) || 20; 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 filters = { const rawSearch = req.query.search as string;
search: req.query.search, const rawDepartmentId = req.query.departmentId as string;
departmentId: req.query.departmentId, const rawStatus = req.query.status as string;
status: req.query.status, 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<string, string | undefined> = {};
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); const result = await hrService.findAllEmployees(filters, page, pageSize);
res.json(ResponseFormatter.paginated(result.employees, result.total, result.page, result.pageSize)); res.json(ResponseFormatter.paginated(result.employees, result.total, result.page, result.pageSize));
@@ -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 ========== // ========== LEAVES ==========
async createLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) { 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 ========== // ========== SALARIES ==========
async processSalary(req: AuthRequest, res: Response, next: NextFunction) { 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 ========== // ========== POSITIONS ==========
async findAllPositions(req: AuthRequest, res: Response, next: NextFunction) { async findAllPositions(req: AuthRequest, res: Response, next: NextFunction) {
@@ -145,6 +220,198 @@ export class HRController {
next(error); 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(); export const hrController = new HRController();

View File

@@ -1,12 +1,24 @@
import { Router } from 'express'; import { Router } from 'express';
import { body, param } from 'express-validator';
import { hrController } from './hr.controller'; import { hrController } from './hr.controller';
import { portalController } from './portal.controller';
import { authenticate, authorize } from '../../shared/middleware/auth'; import { authenticate, authorize } from '../../shared/middleware/auth';
import { validate } from '../../shared/middleware/validation';
const router = Router(); const router = Router();
router.use(authenticate); 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 ========== // ========== EMPLOYEES ==========
router.get('/employees', authorize('hr', 'employees', 'read'), hrController.findAllEmployees); 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.post('/attendance', authorize('hr', 'attendance', 'create'), hrController.recordAttendance);
router.get('/attendance/:employeeId', authorize('hr', 'attendance', 'read'), hrController.getAttendance); router.get('/attendance/:employeeId', authorize('hr', 'attendance', 'read'), hrController.getAttendance);
router.post('/attendance/sync', authorize('hr', 'attendance', 'create'), hrController.bulkSyncAttendance);
// ========== LEAVES ========== // ========== LEAVES ==========
router.get('/leaves', authorize('hr', 'leaves', 'read'), hrController.findAllLeaves);
router.post('/leaves', authorize('hr', 'leaves', 'create'), hrController.createLeaveRequest); router.post('/leaves', authorize('hr', 'leaves', 'create'), hrController.createLeaveRequest);
router.post('/leaves/:id/approve', authorize('hr', 'leaves', 'approve'), hrController.approveLeave); router.post('/leaves/:id/approve', authorize('hr', 'leaves', 'approve'), hrController.approveLeave);
router.post('/leaves/:id/reject', authorize('hr', 'leaves', 'approve'), hrController.rejectLeave);
// ========== SALARIES ========== // ========== SALARIES ==========
@@ -32,10 +47,44 @@ router.post('/salaries/process', authorize('hr', 'salaries', 'process'), hrContr
// ========== DEPARTMENTS ========== // ========== DEPARTMENTS ==========
router.get('/departments', authorize('hr', 'all', 'read'), hrController.findAllDepartments); 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 ========== // ========== POSITIONS ==========
router.get('/positions', authorize('hr', 'all', 'read'), hrController.findAllPositions); 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; export default router;

View File

@@ -5,14 +5,52 @@ import { AuditLogger } from '../../shared/utils/auditLogger';
class HRService { class HRService {
// ========== EMPLOYEES ========== // ========== EMPLOYEES ==========
private normalizeEmployeeData(data: any): Record<string, any> {
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<string, any> = {
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) { async createEmployee(data: any, userId: string) {
const uniqueEmployeeId = await this.generateEmployeeId(); 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({ const employee = await prisma.employee.create({
data: { data: {
uniqueEmployeeId, uniqueEmployeeId,
...data, ...payload,
}, } as any,
include: { include: {
department: true, department: true,
position: true, position: true,
@@ -132,9 +170,10 @@ class HRService {
throw new AppError(404, 'الموظف غير موجود - Employee not found'); throw new AppError(404, 'الموظف غير موجود - Employee not found');
} }
const payload = this.normalizeEmployeeData(data);
const employee = await prisma.employee.update({ const employee = await prisma.employee.update({
where: { id }, where: { id },
data, data: payload,
include: { include: {
department: true, department: true,
position: true, position: true,
@@ -188,12 +227,74 @@ class HRService {
async recordAttendance(data: any, userId: string) { async recordAttendance(data: any, userId: string) {
const attendance = await prisma.attendance.create({ const attendance = await prisma.attendance.create({
data, data: {
...data,
sourceDeviceId: data.sourceDeviceId ?? undefined,
externalId: data.externalId ?? undefined,
rawData: data.rawData ?? undefined,
},
}); });
return attendance; 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) { async getAttendance(employeeId: string, month: number, year: number) {
return prisma.attendance.findMany({ return prisma.attendance.findMany({
where: { where: {
@@ -212,10 +313,24 @@ class HRService {
// ========== LEAVES ========== // ========== LEAVES ==========
async createLeaveRequest(data: any, userId: string) { 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({ const leave = await prisma.leave.create({
data: { data: {
...data, ...data,
days: this.calculateLeaveDays(data.startDate, data.endDate), days,
}, },
include: { include: {
employee: true, employee: true,
@@ -239,12 +354,16 @@ class HRService {
status: 'APPROVED', status: 'APPROVED',
approvedBy, approvedBy,
approvedAt: new Date(), approvedAt: new Date(),
rejectedReason: null,
}, },
include: { include: {
employee: true, employee: true,
}, },
}); });
const year = new Date(leave.startDate).getFullYear();
await this.updateLeaveEntitlementUsed(leave.employeeId, year, leave.leaveType, leave.days);
await AuditLogger.log({ await AuditLogger.log({
entityType: 'LEAVE', entityType: 'LEAVE',
entityId: leave.id, entityId: leave.id,
@@ -255,6 +374,62 @@ class HRService {
return leave; 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 ========== // ========== SALARIES ==========
async processSalary(employeeId: string, month: number, year: number, userId: string) { async processSalary(employeeId: string, month: number, year: number, userId: string) {
@@ -341,6 +516,323 @@ class HRService {
return salary; return salary;
} }
// ========== LOANS ==========
private async generateLoanNumber(): Promise<string> {
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<string> {
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<string, number> = {};
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<string> {
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 ========== // ========== HELPERS ==========
private async generateEmployeeId(): Promise<string> { private async generateEmployeeId(): Promise<string> {
@@ -383,11 +875,112 @@ class HRService {
async findAllDepartments() { async findAllDepartments() {
const departments = await prisma.department.findMany({ const departments = await prisma.department.findMany({
where: { isActive: true }, where: { isActive: true },
include: {
parent: { select: { id: true, name: true, nameAr: true } },
_count: { select: { children: true, employees: true } }
},
orderBy: { name: 'asc' } orderBy: { name: 'asc' }
}); });
return departments; 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 ========== // ========== POSITIONS ==========
async findAllPositions() { async findAllPositions() {

View File

@@ -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();

View File

@@ -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();

View File

@@ -3,6 +3,7 @@ import adminRoutes from '../modules/admin/admin.routes';
import authRoutes from '../modules/auth/auth.routes'; import authRoutes from '../modules/auth/auth.routes';
import contactsRoutes from '../modules/contacts/contacts.routes'; import contactsRoutes from '../modules/contacts/contacts.routes';
import crmRoutes from '../modules/crm/crm.routes'; import crmRoutes from '../modules/crm/crm.routes';
import dashboardRoutes from '../modules/dashboard/dashboard.routes';
import hrRoutes from '../modules/hr/hr.routes'; import hrRoutes from '../modules/hr/hr.routes';
import inventoryRoutes from '../modules/inventory/inventory.routes'; import inventoryRoutes from '../modules/inventory/inventory.routes';
import projectsRoutes from '../modules/projects/projects.routes'; import projectsRoutes from '../modules/projects/projects.routes';
@@ -12,6 +13,7 @@ const router = Router();
// Module routes // Module routes
router.use('/admin', adminRoutes); router.use('/admin', adminRoutes);
router.use('/dashboard', dashboardRoutes);
router.use('/auth', authRoutes); router.use('/auth', authRoutes);
router.use('/contacts', contactsRoutes); router.use('/contacts', contactsRoutes);
router.use('/crm', crmRoutes); router.use('/crm', crmRoutes);

View File

@@ -4,12 +4,47 @@ import { config } from '../../config';
import { AppError } from './errorHandler'; import { AppError } from './errorHandler';
import prisma from '../../config/database'; 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<string, Set<string>>();
const add = (m: string, r: string, actions: unknown) => {
const arr = Array.isArray(actions) ? actions : [];
const actionSet = new Set<string>(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 { export interface AuthRequest extends Request {
user?: { user?: {
id: string; id: string;
email: string; email: string;
employeeId?: string; employeeId?: string;
employee?: any; employee?: any;
effectivePermissions?: EffectivePermission[];
}; };
} }
@@ -33,7 +68,7 @@ export const authenticate = async (
email: string; email: string;
}; };
// Get user with employee info // Get user with employee + roles (Phase 3: multi-group)
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: decoded.id }, where: { id: decoded.id },
include: { include: {
@@ -47,6 +82,14 @@ export const authenticate = async (
department: true, 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.'); 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 = { req.user = {
id: user.id, id: user.id,
email: user.email, email: user.email,
employeeId: user.employeeId || undefined, employeeId: user.employeeId || undefined,
employee: user.employee, employee: user.employee,
effectivePermissions,
}; };
next(); 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) => { export const authorize = (module: string, resource: string, action: string) => {
return async (req: AuthRequest, res: Response, next: NextFunction) => { return async (req: AuthRequest, res: Response, next: NextFunction) => {
try { try {
if (!req.user?.employee?.position?.permissions) { const perms = req.user?.effectivePermissions;
if (!perms || perms.length === 0) {
throw new AppError(403, 'الوصول مرفوض - Access denied'); throw new AppError(403, 'الوصول مرفوض - Access denied');
} }
// Find permission for this module and resource (check exact match or wildcard) const permission = perms.find(
const permission = req.user.employee.position.permissions.find( (p) => p.module === module && (p.resource === resource || p.resource === '*' || p.resource === 'all')
(p: any) => p.module === module && (p.resource === resource || p.resource === '*' || p.resource === 'all')
); );
if (!permission) { if (!permission) {
throw new AppError(403, 'الوصول مرفوض - Access denied'); throw new AppError(403, 'الوصول مرفوض - Access denied');
} }
// Check if action is allowed (check exact match or wildcard) const actions = permission.actions;
const actions = permission.actions as string[];
if (!actions.includes(action) && !actions.includes('*') && !actions.includes('all')) { if (!actions.includes(action) && !actions.includes('*') && !actions.includes('all')) {
throw new AppError(403, 'الوصول مرفوض - Access denied'); throw new AppError(403, 'الوصول مرفوض - Access denied');
} }

View File

@@ -40,10 +40,12 @@ export const errorHandler = (
// Handle validation errors // Handle validation errors
if (err instanceof Prisma.PrismaClientValidationError) { if (err instanceof Prisma.PrismaClientValidationError) {
const detail = process.env.NODE_ENV !== 'production' ? err.message : undefined;
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: 'بيانات غير صالحة - Invalid data', message: 'بيانات غير صالحة - Invalid data',
error: 'VALIDATION_ERROR', error: 'VALIDATION_ERROR',
...(detail && { detail }),
}); });
} }

View File

@@ -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: 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 - Schema and migrations are unchanged
- Base configuration is restored (pipelines, categories, departments, roles, default users) - One System Administrator user remains for configuration
- All business data (contacts, deals, quotes, projects, etc.) is removed so you can enter new real data - All business data (contacts, deals, quotes, projects, etc.) is removed so you can enter new real data manually
## ⚠️ Important ## ⚠️ 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: This truncates all tables and then runs the seed so you get:
- Empty business data (contacts, deals, quotes, projects, inventory, etc.) - 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 ### Steps on production server
@@ -87,19 +87,17 @@ All rows are removed from every table, including:
- Audit logs, notifications, approvals - Audit logs, notifications, approvals
- Users, employees, departments, positions, permissions - 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 | | Role | Email | Password | Access |
|-------------------|--------------------------|-----------|---------------| |-------------------|----------------------|-----------|-------------|
| General Manager | gm@atmata.com | Admin@123 | Full system | | System Administrator | admin@system.local | Admin@123 | Full system |
| Sales Manager | sales.manager@atmata.com | Admin@123 | Contacts, CRM |
| Sales Representative | sales.rep@atmata.com | Admin@123 | Basic CRM |
Change these passwords after first login in production. Change the password after first login in production.
--- ---

View File

@@ -43,8 +43,8 @@ export default function AuditLogs() {
fetchLogs(); fetchLogs();
}, [fetchLogs]); }, [fetchLogs]);
const formatDate = (d: string) => const formatDate = (d: string | null | undefined) =>
new Date(d).toLocaleString('ar-SA', { dateStyle: 'medium', timeStyle: 'short' }); d ? new Date(d).toLocaleString('ar-SA', { dateStyle: 'medium', timeStyle: 'short' }) : '-';
const getActionLabel = (a: string) => { const getActionLabel = (a: string) => {
const labels: Record<string, string> = { const labels: Record<string, string> = {

View File

@@ -7,6 +7,7 @@ import { usePathname } from 'next/navigation'
import { import {
Users, Users,
Shield, Shield,
UsersRound,
Database, Database,
Settings, Settings,
FileText, FileText,
@@ -16,8 +17,7 @@ import {
Clock, Clock,
Building2, Building2,
LogOut, LogOut,
LayoutDashboard, LayoutDashboard
Users2
} from 'lucide-react' } from 'lucide-react'
function AdminLayoutContent({ children }: { children: React.ReactNode }) { function AdminLayoutContent({ children }: { children: React.ReactNode }) {
@@ -28,7 +28,7 @@ function AdminLayoutContent({ children }: { children: React.ReactNode }) {
{ icon: LayoutDashboard, label: 'لوحة التحكم', href: '/admin', exact: true }, { icon: LayoutDashboard, label: 'لوحة التحكم', href: '/admin', exact: true },
{ icon: Users, label: 'إدارة المستخدمين', href: '/admin/users' }, { icon: Users, label: 'إدارة المستخدمين', href: '/admin/users' },
{ icon: Shield, label: 'الأدوار والصلاحيات', href: '/admin/roles' }, { 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: Database, label: 'النسخ الاحتياطي', href: '/admin/backup' },
{ icon: Settings, label: 'إعدادات النظام', href: '/admin/settings' }, { icon: Settings, label: 'إعدادات النظام', href: '/admin/settings' },
{ icon: FileText, label: 'سجل العمليات', href: '/admin/audit-logs' }, { icon: FileText, label: 'سجل العمليات', href: '/admin/audit-logs' },

View File

@@ -50,7 +50,8 @@ export default function AdminDashboard() {
return labels[a] || a; 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 diff = Date.now() - new Date(d).getTime();
const mins = Math.floor(diff / 60000); const mins = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000); const hours = Math.floor(diff / 3600000);

View File

@@ -1,259 +1,371 @@
'use client' 'use client';
import { useEffect, useMemo, useState } from 'react' import { useState, useEffect, useCallback } from 'react';
import { Plus, Edit, Trash2, Users2 } from 'lucide-react' import { UsersRound, Edit, Users, Check, X, Plus } from 'lucide-react';
import Modal from '@/components/Modal' import { permissionGroupsAPI } from '@/lib/api/admin';
import type { PermissionGroup } from '@/lib/api/admin';
type PermissionGroup = { import Modal from '@/components/Modal';
id: string import LoadingSpinner from '@/components/LoadingSpinner';
name: string
nameAr?: string
modules: string[]
createdAt: string
}
const MODULES = [ const MODULES = [
{ id: 'contacts', name: 'إدارة جهات الاتصال' }, { id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' },
{ id: 'crm', name: 'إدارة علاقات العملاء' }, { id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' },
{ id: 'inventory', name: 'المخزون والأصول' }, { id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
{ id: 'projects', name: 'المهام والمشاريع' }, { id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
{ id: 'hr', name: 'الموارد البشرية' }, { id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' },
{ id: 'marketing', name: 'التسويق' }, { id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
{ id: 'admin', name: 'لوحة الإدارة' }, { 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<string, Record<string, boolean>>) {
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<string, Record<string, boolean>> = {};
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() { export default function PermissionGroupsPage() {
const [groups, setGroups] = useState<PermissionGroup[]>([]) const [groups, setGroups] = useState<PermissionGroup[]>([]);
const [showModal, setShowModal] = useState(false) const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<PermissionGroup | null>(null) const [error, setError] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [showEditModal, setShowEditModal] = useState(false);
const [showCreateModal, setShowCreateModal] = useState(false);
const [createForm, setCreateForm] = useState({ name: '', nameAr: '', description: '' });
type PermissionMatrix = Record<string, Record<string, boolean>>;
const [permissionMatrix, setPermissionMatrix] = useState<PermissionMatrix>({});
const [saving, setSaving] = useState(false);
const [name, setName] = useState('') const fetchGroups = useCallback(async () => {
const [nameAr, setNameAr] = useState('') setLoading(true);
const [selectedModules, setSelectedModules] = useState<Record<string, boolean>>({}) 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(() => { useEffect(() => {
try { fetchGroups();
const raw = localStorage.getItem(STORAGE_KEY) }, [fetchGroups]);
if (raw) setGroups(JSON.parse(raw))
} catch { const currentGroup = groups.find((g) => g.id === selectedId);
// ignore
}
}, [])
useEffect(() => { 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 { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(groups)) const group = await permissionGroupsAPI.create({
} catch { name: createForm.name.trim(),
// ignore 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 handleSavePermissions = async () => {
const m: Record<string, boolean> = {} if (!selectedId) return;
MODULES.forEach(x => (m[x.id] = false)) setSaving(true);
return m try {
}, []) const permissions = buildPermissionsFromMatrix(permissionMatrix);
await permissionGroupsAPI.updatePermissions(selectedId, permissions);
const openCreate = () => { setShowEditModal(false);
setEditing(null) fetchGroups();
setName('') } catch (err: unknown) {
setNameAr('') alert(err instanceof Error ? err.message : 'فشل الحفظ');
setSelectedModules({ ...baseModulesMap }) } finally {
setShowModal(true) setSaving(false);
}
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 mods = Object.keys(selectedModules).filter(k => selectedModules[k]) const handleTogglePermission = (moduleId: string, actionId: string) => {
const now = new Date().toISOString() setPermissionMatrix((prev) => ({
...prev,
if (editing) { [moduleId]: {
setGroups(prev => ...(prev[moduleId] || {}),
prev.map(g => [actionId]: !prev[moduleId]?.[actionId],
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))
}
return ( return (
<div> <div>
<div className="mb-8 flex items-center justify-between"> <div className="mb-8 flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">مجموعات الصلاحيات</h1> <h1 className="text-3xl font-bold text-gray-900 mb-2">مجموعات الصلاحيات</h1>
<p className="text-gray-600">إدارة مجموعات لتجميع الوحدات بشكل أسرع</p> <p className="text-gray-600">مجموعات اختيارية تضيف صلاحيات إضافية للمستخدمين بغض النظر عن وظائفهم</p>
</div> </div>
<button <button
onClick={openCreate} onClick={() => setShowCreateModal(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-semibold" className="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all shadow-md"
> >
<Plus className="h-5 w-5" /> <Plus className="h-5 w-5" />
إضافة مجموعة <span className="font-semibold">إضافة مجموعة</span>
</button> </button>
</div> </div>
{groups.length === 0 ? ( {loading ? (
<div className="bg-white rounded-xl border border-gray-200 p-10 text-center"> <div className="flex justify-center p-12">
<Users2 className="h-14 w-14 text-gray-300 mx-auto mb-3" /> <LoadingSpinner />
<h3 className="text-lg font-bold text-gray-900 mb-1">لا توجد مجموعات</h3>
<p className="text-gray-600">قم بإضافة مجموعة صلاحيات لتسهيل إدارة الأدوار.</p>
</div> </div>
) : error ? (
<div className="text-center text-red-600 p-12">{error}</div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{groups.map(g => ( <div className="lg:col-span-1 space-y-4">
<div key={g.id} className="bg-white rounded-xl border border-gray-200 p-5"> <h2 className="text-xl font-bold text-gray-900 mb-4">المجموعات ({groups.length})</h2>
<div className="flex items-start justify-between"> {groups.map((g) => (
<div> <div
<h3 className="font-bold text-gray-900">{g.nameAr || g.name}</h3> key={g.id}
<p className="text-xs text-gray-600">{g.name}</p> onClick={() => setSelectedId(g.id)}
<p className="text-sm text-gray-700 mt-3"> className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${
الوحدات: <span className="font-semibold">{g.modules.length}</span> selectedId === g.id ? 'border-blue-600 bg-blue-50' : 'border-gray-200 bg-white hover:border-blue-300'
</p> }`}
>
<div className="flex items-center gap-3 mb-2">
<div className={`p-2 rounded-lg ${selectedId === g.id ? 'bg-blue-600' : 'bg-blue-100'}`}>
<UsersRound className={`h-5 w-5 ${selectedId === g.id ? 'text-white' : 'text-blue-600'}`} />
</div>
<div>
<h3 className="font-bold text-gray-900">{g.nameAr || g.name}</h3>
<p className="text-xs text-gray-600">{g.name}</p>
</div>
</div> </div>
<div className="flex items-center justify-between">
<div className="flex gap-1"> <span className="text-sm text-gray-600">
<Users className="h-4 w-4 inline mr-1" />
{g._count?.userRoles ?? 0} مستخدم
</span>
<button <button
onClick={() => openEdit(g)} onClick={(e) => {
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg" e.stopPropagation();
title="تعديل" setSelectedId(g.id);
setShowEditModal(true);
}}
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded"
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</button> </button>
<button
onClick={() => remove(g)}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg"
title="حذف"
>
<Trash2 className="h-4 w-4" />
</button>
</div> </div>
</div> </div>
))}
</div>
<div className="mt-4 flex flex-wrap gap-2"> <div className="lg:col-span-2">
{g.modules.map(id => { {currentGroup ? (
const m = MODULES.find(x => x.id === id) <div className="bg-white rounded-xl shadow-lg border p-6">
return ( <div className="flex justify-between mb-6">
<span key={id} className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded-lg"> <div>
{m?.name || id} <h2 className="text-2xl font-bold text-gray-900">{currentGroup.nameAr || currentGroup.name}</h2>
</span> <p className="text-gray-600">{currentGroup.name}</p>
) </div>
})} <button
onClick={() => setShowEditModal(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
تعديل الصلاحيات
</button>
</div>
<h3 className="text-lg font-bold mb-4">مصفوفة الصلاحيات</h3>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b-2 border-gray-200">
<th className="px-4 py-3 text-right text-sm font-bold min-w-[200px]">الوحدة</th>
{ACTIONS.map((a) => (
<th key={a.id} className="px-4 py-3 text-center text-sm font-bold">{a.name}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{MODULES.map((m) => (
<tr key={m.id} className="hover:bg-gray-50">
<td className="px-4 py-4">
<p className="font-semibold">{m.name}</p>
<p className="text-xs text-gray-600">{m.nameEn}</p>
</td>
{ACTIONS.map((a) => {
const has = permissionMatrix[m.id]?.[a.id];
return (
<td key={a.id} className="px-4 py-4 text-center">
<div
className={`w-10 h-10 rounded-lg flex items-center justify-center mx-auto ${
has ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-500'
}`}
>
{has ? <Check className="h-6 w-6" /> : <X className="h-6 w-6" />}
</div>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div> </div>
</div> ) : (
))} <div className="bg-white rounded-xl shadow-lg border p-12 text-center">
<UsersRound className="h-16 w-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-xl font-bold text-gray-900 mb-2">اختر مجموعة</h3>
<p className="text-gray-600">أو أنشئ مجموعة جديدة لإضافة صلاحيات اختيارية للمستخدمين</p>
</div>
)}
</div>
</div> </div>
)} )}
<Modal <Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title="إضافة مجموعة صلاحيات" size="md">
isOpen={showModal} <form onSubmit={handleCreate} className="space-y-4">
onClose={() => setShowModal(false)}
title={editing ? 'تعديل مجموعة الصلاحيات' : 'إضافة مجموعة صلاحيات'}
size="lg"
>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">الاسم (English)</label>
<input
value={name}
onChange={e => setName(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
placeholder="e.g. Sales Group"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">الاسم (عربي)</label>
<input
value={nameAr}
onChange={e => setNameAr(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
placeholder="مثال: مجموعة المبيعات"
/>
</div>
</div>
<div> <div>
<label className="block text-sm font-semibold text-gray-700 mb-2">الوحدات ضمن المجموعة</label> <label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2"> <input
{MODULES.map(m => ( type="text"
<button value={createForm.name}
key={m.id} onChange={(e) => setCreateForm((p) => ({ ...p, name: e.target.value }))}
type="button" className="w-full px-3 py-2 border border-gray-300 rounded-lg"
onClick={() => toggleModule(m.id)} placeholder="e.g. Campaign Approver"
className={`flex items-center justify-between px-3 py-2 rounded-lg border ${ />
selectedModules[m.id] </div>
? 'border-green-500 bg-green-50' <div>
: 'border-gray-200 bg-white hover:bg-gray-50' <label className="block text-sm font-medium text-gray-700 mb-1">Name (Arabic)</label>
}`} <input
> type="text"
<span className="text-sm font-medium text-gray-800">{m.name}</span> value={createForm.nameAr}
<span onChange={(e) => setCreateForm((p) => ({ ...p, nameAr: e.target.value }))}
className={`text-xs px-2 py-0.5 rounded ${ className="w-full px-3 py-2 border border-gray-300 rounded-lg"
selectedModules[m.id] ? 'bg-green-600 text-white' : 'bg-gray-200 text-gray-700' />
}`} </div>
> <div>
{selectedModules[m.id] ? 'ضمن المجموعة' : 'غير محدد'} <label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
</span> <textarea
</button> value={createForm.description}
))} onChange={(e) => setCreateForm((p) => ({ ...p, description: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
rows={2}
/>
</div>
<div className="flex gap-3 justify-end pt-4">
<button type="button" onClick={() => setShowCreateModal(false)} className="px-6 py-3 border rounded-lg">
Cancel
</button>
<button type="submit" disabled={saving} className="px-6 py-3 bg-blue-600 text-white rounded-lg disabled:opacity-50">
{saving ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</Modal>
<Modal
isOpen={showEditModal}
onClose={() => setShowEditModal(false)}
title={`تعديل صلاحيات: ${currentGroup?.nameAr || currentGroup?.name || ''}`}
size="2xl"
>
{currentGroup && (
<div>
<div className="overflow-x-auto mb-6">
<table className="w-full">
<thead>
<tr className="border-b-2 border-gray-200">
<th className="px-4 py-3 text-right text-sm font-bold">الوحدة</th>
{ACTIONS.map((a) => (
<th key={a.id} className="px-4 py-3 text-center text-sm font-bold">{a.name}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{MODULES.map((m) => (
<tr key={m.id}>
<td className="px-4 py-4 font-semibold">{m.name}</td>
{ACTIONS.map((a) => (
<td key={a.id} className="px-4 py-4 text-center">
<button
type="button"
onClick={() => handleTogglePermission(m.id, a.id)}
className={`w-10 h-10 rounded-lg flex items-center justify-center mx-auto ${
permissionMatrix[m.id]?.[a.id] ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-500'
}`}
>
{permissionMatrix[m.id]?.[a.id] ? <Check className="h-6 w-6" /> : <X className="h-6 w-6" />}
</button>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="flex gap-3 justify-end">
<button onClick={() => setShowEditModal(false)} className="px-6 py-3 border rounded-lg">
إلغاء
</button>
<button onClick={handleSavePermissions} disabled={saving} className="px-6 py-3 bg-blue-600 text-white rounded-lg disabled:opacity-50">
{saving ? 'جاري الحفظ...' : 'حفظ'}
</button>
</div> </div>
</div> </div>
)}
<div className="flex justify-end gap-3 pt-2">
<button
onClick={() => setShowModal(false)}
className="px-5 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
>
إلغاء
</button>
<button
onClick={save}
className="px-5 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-semibold"
>
حفظ
</button>
</div>
</div>
</Modal> </Modal>
</div> </div>
) );
} }

View File

@@ -1,9 +1,10 @@
'use client'; 'use client';
import { useState, useEffect, useCallback, useMemo } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Shield, Edit, Trash2, Users, Check, X, Loader2, Plus } from 'lucide-react'; import { Shield, Edit, Users, Check, X, Plus } from 'lucide-react';
import { positionsAPI } from '@/lib/api/admin'; import { positionsAPI } from '@/lib/api/admin';
import type { PositionRole, PositionPermission } from '@/lib/api/admin'; import { departmentsAPI } from '@/lib/api/employees';
import type { PositionRole, PositionPermission, CreatePositionData } from '@/lib/api/admin';
import Modal from '@/components/Modal'; import Modal from '@/components/Modal';
import LoadingSpinner from '@/components/LoadingSpinner'; import LoadingSpinner from '@/components/LoadingSpinner';
@@ -49,11 +50,9 @@ function buildMatrixFromPermissions(permissions: PositionPermission[]): Record<s
for (const m of MODULES) { for (const m of MODULES) {
matrix[m.id] = {}; matrix[m.id] = {};
const perm = permissions.find((p) => p.module === m.id && (p.resource === '*' || p.resource === m.id)); const perm = permissions.find((p) => p.module === m.id && (p.resource === '*' || p.resource === m.id));
const hasAll = const hasAll = perm && (Array.isArray(perm.actions)
perm && ? (perm.actions as string[]).includes('*') || (perm.actions as string[]).includes('all')
(Array.isArray(perm.actions) : false);
? (perm.actions as string[]).includes('*') || (perm.actions as string[]).includes('all')
: false);
for (const a of ACTIONS) { for (const a of ACTIONS) {
matrix[m.id][a.id] = hasAll || hasAction(perm, a.id); matrix[m.id][a.id] = hasAll || hasAction(perm, a.id);
} }
@@ -61,33 +60,27 @@ function buildMatrixFromPermissions(permissions: PositionPermission[]): Record<s
return matrix; return matrix;
} }
const initialCreateForm: CreatePositionData & { description?: string } = {
title: '',
titleAr: '',
code: '',
departmentId: '',
level: 5,
description: '',
};
export default function RolesManagement() { export default function RolesManagement() {
const [roles, setRoles] = useState<PositionRole[]>([]); const [roles, setRoles] = useState<PositionRole[]>([]);
const [departments, setDepartments] = useState<{ id: string; name: string; nameAr?: string | null }[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null); const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
// Edit modal (name + permissions)
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const [permissionMatrix, setPermissionMatrix] = useState<Record<string, Record<string, boolean>>>({});
const [editTitle, setEditTitle] = useState('');
const [editTitleAr, setEditTitleAr] = useState('');
const [saving, setSaving] = useState(false);
// Delete dialog
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleting, setDeleting] = useState(false);
const [roleToDelete, setRoleToDelete] = useState<PositionRole | null>(null);
// Create modal
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [creating, setCreating] = useState(false); const [createForm, setCreateForm] = useState(initialCreateForm);
const [newTitle, setNewTitle] = useState(''); const [createErrors, setCreateErrors] = useState<Record<string, string>>({});
const [newTitleAr, setNewTitleAr] = useState(''); const [permissionMatrix, setPermissionMatrix] = useState<Record<string, Record<string, boolean>>>({});
const [newDepartmentId, setNewDepartmentId] = useState(''); const [saving, setSaving] = useState(false);
const [newLevel, setNewLevel] = useState<number>(1);
const [newCode, setNewCode] = useState('');
const fetchRoles = useCallback(async () => { const fetchRoles = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -109,40 +102,49 @@ export default function RolesManagement() {
fetchRoles(); fetchRoles();
}, [fetchRoles]); }, [fetchRoles]);
useEffect(() => {
departmentsAPI.getAll().then((depts) => setDepartments(depts)).catch(() => {});
}, []);
const currentRole = roles.find((r) => r.id === selectedRoleId); const currentRole = roles.find((r) => r.id === selectedRoleId);
// build departments options from existing roles const handleCreateRole = async (e: React.FormEvent) => {
const departmentOptions = useMemo(() => { e.preventDefault();
const map = new Map<string, { id: string; label: string }>(); const errs: Record<string, string> = {};
roles.forEach((r) => { if (!createForm.title?.trim()) errs.title = 'Required';
if (!r.departmentId) return; if (!createForm.code?.trim()) errs.code = 'Required';
const label = r.department?.nameAr || r.department?.name || r.departmentId; if (!createForm.departmentId) errs.departmentId = 'Required';
if (!map.has(r.departmentId)) map.set(r.departmentId, { id: r.departmentId, label }); setCreateErrors(errs);
}); if (Object.keys(errs).length > 0) return;
return Array.from(map.values());
}, [roles]); setSaving(true);
try {
const position = await positionsAPI.create({
title: createForm.title.trim(),
titleAr: createForm.titleAr?.trim() || undefined,
code: createForm.code.trim(),
departmentId: createForm.departmentId,
level: createForm.level ?? 5,
description: createForm.description?.trim() || undefined,
});
setShowCreateModal(false);
setCreateForm(initialCreateForm);
setCreateErrors({});
await fetchRoles();
setSelectedRoleId(position.id);
setShowEditModal(true);
} catch (err: unknown) {
setCreateErrors({ form: err instanceof Error ? err.message : 'Failed to create role' });
} finally {
setSaving(false);
}
};
useEffect(() => { useEffect(() => {
if (currentRole) { if (currentRole) {
setPermissionMatrix(buildMatrixFromPermissions(currentRole.permissions || [])); setPermissionMatrix(buildMatrixFromPermissions(currentRole.permissions || []));
setEditTitle(currentRole.title || '');
setEditTitleAr(currentRole.titleAr || '');
} }
}, [currentRole?.id]); }, [currentRole?.id, currentRole?.permissions]);
useEffect(() => {
// default department when opening create
if (!showCreateModal) return;
const fallback =
(selectedRoleId && roles.find(r => r.id === selectedRoleId)?.departmentId) ||
departmentOptions[0]?.id ||
'';
setNewDepartmentId(fallback);
setNewLevel(1);
setNewCode('');
setNewTitle('');
setNewTitleAr('');
}, [showCreateModal, selectedRoleId, roles, departmentOptions]);
const handleTogglePermission = (moduleId: string, actionId: string) => { const handleTogglePermission = (moduleId: string, actionId: string) => {
setPermissionMatrix((prev) => ({ setPermissionMatrix((prev) => ({
@@ -154,112 +156,21 @@ export default function RolesManagement() {
})); }));
}; };
const handleSaveRole = async () => { const handleSavePermissions = async () => {
if (!selectedRoleId) return; if (!selectedRoleId) return;
const titleFinal = (editTitle || '').trim() || (editTitleAr || '').trim();
if (!titleFinal) {
alert('الرجاء إدخال اسم الدور');
return;
}
setSaving(true); setSaving(true);
try { try {
// 1) update name (only if changed)
if (currentRole && (titleFinal !== currentRole.title || (editTitleAr || '') !== (currentRole.titleAr || ''))) {
await positionsAPI.update(selectedRoleId, {
title: titleFinal,
titleAr: (editTitleAr || '').trim() || null,
});
}
// 2) update permissions
const permissions = buildPermissionsFromMatrix(permissionMatrix); const permissions = buildPermissionsFromMatrix(permissionMatrix);
await positionsAPI.updatePermissions(selectedRoleId, permissions); await positionsAPI.updatePermissions(selectedRoleId, permissions);
setShowEditModal(false); setShowEditModal(false);
await fetchRoles(); fetchRoles();
} catch (err: unknown) { } catch (err: unknown) {
const msg = alert(err instanceof Error ? err.message : 'فشل حفظ الصلاحيات');
(err as any)?.response?.data?.message ||
(err as any)?.response?.data?.error ||
(err as any)?.message ||
'فشل حفظ التغييرات';
alert(msg);
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
const openDeleteDialog = (role: PositionRole) => {
setRoleToDelete(role);
setShowDeleteDialog(true);
};
const handleDeleteRole = async () => {
if (!roleToDelete) return;
setDeleting(true);
try {
await positionsAPI.delete(roleToDelete.id);
if (selectedRoleId === roleToDelete.id) {
setSelectedRoleId(null);
}
setShowDeleteDialog(false);
setRoleToDelete(null);
await fetchRoles();
} catch (err: unknown) {
const msg =
(err as any)?.response?.data?.message ||
(err as any)?.response?.data?.error ||
(err as any)?.message ||
'فشل حذف الدور';
alert(msg);
} finally {
setDeleting(false);
}
};
const handleCreateRole = async () => {
const titleFinal = (newTitle || '').trim() || (newTitleAr || '').trim();
if (!titleFinal) {
alert('الرجاء إدخال اسم الدور');
return;
}
if (!newDepartmentId) {
alert('الرجاء اختيار قسم (Department)');
return;
}
setCreating(true);
try {
const created = await positionsAPI.create({
title: titleFinal,
titleAr: (newTitleAr || '').trim() || null,
departmentId: newDepartmentId,
level: Number.isFinite(newLevel) ? newLevel : 1,
code: (newCode || '').trim() || undefined,
});
setShowCreateModal(false);
await fetchRoles();
// select new role + open edit modal directly
setSelectedRoleId(created.id);
setShowEditModal(true);
} catch (err: unknown) {
const msg =
(err as any)?.response?.data?.message ||
(err as any)?.response?.data?.error ||
(err as any)?.message ||
'فشل إنشاء الدور';
alert(msg);
} finally {
setCreating(false);
}
};
const handleSelectRole = (id: string) => { const handleSelectRole = (id: string) => {
setSelectedRoleId(id); setSelectedRoleId(id);
setShowEditModal(false); setShowEditModal(false);
@@ -270,15 +181,14 @@ export default function RolesManagement() {
<div className="mb-8 flex items-center justify-between"> <div className="mb-8 flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">الأدوار والصلاحيات</h1> <h1 className="text-3xl font-bold text-gray-900 mb-2">الأدوار والصلاحيات</h1>
<p className="text-gray-600">إدارة أدوار المستخدمين و الصلاحيات</p> <p className="text-gray-600">إدارة أدوار المستخدمين ومصفوفة الصلاحيات</p>
</div> </div>
<button <button
onClick={() => setShowCreateModal(true)} onClick={() => setShowCreateModal(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-semibold" className="flex items-center gap-2 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-all shadow-md hover:shadow-lg"
> >
<Plus className="h-5 w-5" /> <Plus className="h-5 w-5" />
إنشاء دور <span className="font-semibold">إضافة دور</span>
</button> </button>
</div> </div>
@@ -306,8 +216,12 @@ export default function RolesManagement() {
> >
<div className="flex items-start justify-between mb-2"> <div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${selectedRoleId === role.id ? 'bg-purple-600' : 'bg-purple-100'}`}> <div
<Shield className={`h-5 w-5 ${selectedRoleId === role.id ? 'text-white' : 'text-purple-600'}`} /> className={`p-2 rounded-lg ${selectedRoleId === role.id ? 'bg-purple-600' : 'bg-purple-100'}`}
>
<Shield
className={`h-5 w-5 ${selectedRoleId === role.id ? 'text-white' : 'text-purple-600'}`}
/>
</div> </div>
<div> <div>
<h3 className="font-bold text-gray-900">{role.titleAr || role.title}</h3> <h3 className="font-bold text-gray-900">{role.titleAr || role.title}</h3>
@@ -315,37 +229,21 @@ export default function RolesManagement() {
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-gray-600"> <div className="flex items-center gap-2 text-sm text-gray-600">
<Users className="h-4 w-4" /> <Users className="h-4 w-4" />
<span>{role.usersCount ?? role._count?.employees ?? 0} مستخدم</span> <span>{role.usersCount ?? role._count?.employees ?? 0} مستخدم</span>
</div> </div>
<button
<div className="flex items-center gap-1"> onClick={(e) => {
<button e.stopPropagation();
onClick={(e) => { setSelectedRoleId(role.id);
e.stopPropagation(); setShowEditModal(true);
openDeleteDialog(role); }}
}} className="p-1.5 text-blue-600 hover:bg-blue-50 rounded transition-colors"
className="p-1.5 text-red-600 hover:bg-red-50 rounded transition-colors" >
title="حذف" <Edit className="h-4 w-4" />
> </button>
<Trash2 className="h-4 w-4" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setSelectedRoleId(role.id);
setShowEditModal(true);
}}
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded transition-colors"
title="تعديل"
>
<Edit className="h-4 w-4" />
</button>
</div>
</div> </div>
</div> </div>
))} ))}
@@ -365,11 +263,10 @@ export default function RolesManagement() {
onClick={() => setShowEditModal(true)} onClick={() => setShowEditModal(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium" className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium"
> >
تعديل تعديل الصلاحيات
</button> </button>
</div> </div>
</div> </div>
<div className="p-6"> <div className="p-6">
<h3 className="text-lg font-bold text-gray-900 mb-4">مصفوفة الصلاحيات</h3> <h3 className="text-lg font-bold text-gray-900 mb-4">مصفوفة الصلاحيات</h3>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -386,7 +283,6 @@ export default function RolesManagement() {
))} ))}
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200"> <tbody className="divide-y divide-gray-200">
{MODULES.map((module) => ( {MODULES.map((module) => (
<tr key={module.id} className="hover:bg-gray-50 transition-colors"> <tr key={module.id} className="hover:bg-gray-50 transition-colors">
@@ -396,14 +292,15 @@ export default function RolesManagement() {
<p className="text-xs text-gray-600">{module.nameEn}</p> <p className="text-xs text-gray-600">{module.nameEn}</p>
</div> </div>
</td> </td>
{ACTIONS.map((action) => { {ACTIONS.map((action) => {
const hasPermission = permissionMatrix[module.id]?.[action.id]; const hasPermission = permissionMatrix[module.id]?.[action.id];
return ( return (
<td key={action.id} className="px-4 py-4 text-center"> <td key={action.id} className="px-4 py-4 text-center">
<div <div
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-all mx-auto ${ className={`w-10 h-10 rounded-lg flex items-center justify-center transition-all mx-auto ${
hasPermission ? 'bg-green-500 text-white shadow-md' : 'bg-gray-200 text-gray-500' hasPermission
? 'bg-green-500 text-white shadow-md'
: 'bg-gray-200 text-gray-500'
}`} }`}
> >
{hasPermission ? <Check className="h-6 w-6" /> : <X className="h-6 w-6" />} {hasPermission ? <Check className="h-6 w-6" /> : <X className="h-6 w-6" />}
@@ -432,189 +329,110 @@ export default function RolesManagement() {
{/* Create Role Modal */} {/* Create Role Modal */}
<Modal <Modal
isOpen={showCreateModal} isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)} onClose={() => {
setShowCreateModal(false);
setCreateForm(initialCreateForm);
setCreateErrors({});
}}
title="إضافة دور جديد" title="إضافة دور جديد"
size="lg" size="md"
> >
<div className="space-y-4"> <form onSubmit={handleCreateRole} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">الاسم (English)</label>
<input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
placeholder="e.g. Sales representative"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">الاسم (عربي)</label>
<input
value={newTitleAr}
onChange={(e) => setNewTitleAr(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
placeholder="مثال: مستخدم عادي"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="md:col-span-2">
<label className="block text-sm font-semibold text-gray-700 mb-1">القسم (Department)</label>
<select
value={newDepartmentId}
onChange={(e) => setNewDepartmentId(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 bg-white"
>
{departmentOptions.length === 0 ? (
<option value="">لا يوجد أقسام متاحة</option>
) : (
departmentOptions.map((d) => (
<option key={d.id} value={d.id}>
{d.label}
</option>
))
)}
</select>
{departmentOptions.length === 0 && (
<p className="text-xs text-red-600 mt-1">
لا يوجد أقسام ضمن البيانات الحالية. (DepartmentId مطلوب لإنشاء الدور)
</p>
)}
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">Level</label>
<input
type="number"
value={newLevel}
onChange={(e) => setNewLevel(parseInt(e.target.value || '1', 10))}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
min={1}
/>
</div>
</div>
<div> <div>
<label className="block text-sm font-semibold text-gray-700 mb-1">Code (اختياري)</label> <label className="block text-sm font-medium text-gray-700 mb-1">Title (English) *</label>
<input <input
value={newCode} type="text"
onChange={(e) => setNewCode(e.target.value)} value={createForm.title}
className="w-full border border-gray-300 rounded-lg px-3 py-2" onChange={(e) => setCreateForm((p) => ({ ...p, title: e.target.value }))}
placeholder="e.g. SALES_REP (في حال كان فارغاً سيقوم النظام بتوليده تلقائياً)" className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
placeholder="e.g. Sales Representative"
/>
{createErrors.title && <p className="text-red-500 text-xs mt-1">{createErrors.title}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Title (Arabic)</label>
<input
type="text"
value={createForm.titleAr || ''}
onChange={(e) => setCreateForm((p) => ({ ...p, titleAr: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
placeholder="مندوب مبيعات"
/> />
</div> </div>
<div>
<div className="flex justify-end gap-3 pt-2"> <label className="block text-sm font-medium text-gray-700 mb-1">Code *</label>
<button <input
onClick={() => setShowCreateModal(false)} type="text"
className="px-5 py-2 border border-gray-300 rounded-lg hover:bg-gray-50" value={createForm.code}
disabled={creating} onChange={(e) => setCreateForm((p) => ({ ...p, code: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
placeholder="SALES_REP"
/>
{createErrors.code && <p className="text-red-500 text-xs mt-1">{createErrors.code}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Department *</label>
<select
value={createForm.departmentId}
onChange={(e) => setCreateForm((p) => ({ ...p, departmentId: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
> >
إلغاء <option value="">Select department</option>
{departments.map((d) => (
<option key={d.id} value={d.id}>{d.nameAr || d.name}</option>
))}
</select>
{createErrors.departmentId && <p className="text-red-500 text-xs mt-1">{createErrors.departmentId}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Level</label>
<input
type="number"
min={1}
value={createForm.level ?? 5}
onChange={(e) => setCreateForm((p) => ({ ...p, level: parseInt(e.target.value, 10) || 5 }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea
value={createForm.description || ''}
onChange={(e) => setCreateForm((p) => ({ ...p, description: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
rows={2}
placeholder="Optional description"
/>
</div>
{createErrors.form && <p className="text-red-500 text-sm">{createErrors.form}</p>}
<div className="flex gap-3 justify-end pt-4">
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 font-medium"
>
Cancel
</button> </button>
<button <button
onClick={handleCreateRole} type="submit"
className="px-5 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-semibold disabled:opacity-50 inline-flex items-center gap-2" disabled={saving}
disabled={creating} className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-semibold disabled:opacity-50"
> >
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : null} {saving ? 'Creating...' : 'Create Role'}
{creating ? 'جاري الإنشاء...' : 'إنشاء'}
</button> </button>
</div> </div>
</div> </form>
</Modal> </Modal>
{/* Delete Confirmation Dialog */} {/* Edit Permissions Modal */}
{showDeleteDialog && roleToDelete && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div
className="fixed inset-0 bg-black bg-opacity-50"
onClick={() => {
if (!deleting) {
setShowDeleteDialog(false);
setRoleToDelete(null);
}
}}
/>
<div className="flex min-h-screen items-center justify-center p-4">
<div className="relative bg-white rounded-xl shadow-2xl p-6 max-w-md w-full">
<div className="flex items-center gap-4 mb-4">
<div className="bg-red-100 p-3 rounded-full">
<Trash2 className="h-6 w-6 text-red-600" />
</div>
<div>
<h3 className="text-lg font-bold text-gray-900">حذف الدور</h3>
<p className="text-sm text-gray-600">هذا الإجراء لا يمكن التراجع عنه</p>
</div>
</div>
<p className="text-gray-700 mb-6">
هل أنت متأكد أنك تريد حذف دور{' '}
<span className="font-semibold">{roleToDelete.titleAr || roleToDelete.title}</span>؟
</p>
<div className="flex items-center justify-end gap-3">
<button
onClick={() => {
setShowDeleteDialog(false);
setRoleToDelete(null);
}}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
disabled={deleting}
>
إلغاء
</button>
<button
onClick={handleDeleteRole}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
disabled={deleting}
>
{deleting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
جاري الحذف...
</>
) : (
'حذف'
)}
</button>
</div>
</div>
</div>
</div>
)}
{/* Edit Modal: name + permissions */}
<Modal <Modal
isOpen={showEditModal} isOpen={showEditModal}
onClose={() => setShowEditModal(false)} onClose={() => setShowEditModal(false)}
title={`تعديل: ${currentRole?.titleAr || currentRole?.title || ''}`} title={`تعديل صلاحيات: ${currentRole?.titleAr || currentRole?.title || ''}`}
size="2xl" size="2xl"
> >
{currentRole && ( {currentRole && (
<div> <div>
{/* Name edit */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-6">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">الاسم (English)</label>
<input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">الاسم (عربي)</label>
<input
value={editTitleAr}
onChange={(e) => setEditTitleAr(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
</div>
{/* Permissions */}
<div className="overflow-x-auto mb-6"> <div className="overflow-x-auto mb-6">
<table className="w-full"> <table className="w-full">
<thead> <thead>
@@ -627,14 +445,12 @@ export default function RolesManagement() {
))} ))}
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200"> <tbody className="divide-y divide-gray-200">
{MODULES.map((module) => ( {MODULES.map((module) => (
<tr key={module.id}> <tr key={module.id}>
<td className="px-4 py-4"> <td className="px-4 py-4">
<p className="font-semibold text-gray-900">{module.name}</p> <p className="font-semibold text-gray-900">{module.name}</p>
</td> </td>
{ACTIONS.map((action) => { {ACTIONS.map((action) => {
const hasPermission = permissionMatrix[module.id]?.[action.id]; const hasPermission = permissionMatrix[module.id]?.[action.id];
return ( return (
@@ -656,21 +472,18 @@ export default function RolesManagement() {
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="flex gap-3 justify-end"> <div className="flex gap-3 justify-end">
<button <button
onClick={() => setShowEditModal(false)} onClick={() => setShowEditModal(false)}
className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 font-medium" className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 font-medium"
disabled={saving}
> >
إلغاء إلغاء
</button> </button>
<button <button
onClick={handleSaveRole} onClick={handleSavePermissions}
disabled={saving} disabled={saving}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-semibold disabled:opacity-50 inline-flex items-center gap-2" className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-semibold disabled:opacity-50"
> >
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{saving ? 'جاري الحفظ...' : 'حفظ التغييرات'} {saving ? 'جاري الحفظ...' : 'حفظ التغييرات'}
</button> </button>
</div> </div>

View File

@@ -13,7 +13,7 @@ import {
Shield, Shield,
Calendar, Calendar,
} from 'lucide-react'; } from 'lucide-react';
import { usersAPI, statsAPI, positionsAPI } from '@/lib/api/admin'; import { usersAPI, statsAPI, positionsAPI, userRolesAPI, permissionGroupsAPI } from '@/lib/api/admin';
import { employeesAPI } from '@/lib/api/employees'; import { employeesAPI } from '@/lib/api/employees';
import type { User, CreateUserData, UpdateUserData } from '@/lib/api/admin'; import type { User, CreateUserData, UpdateUserData } from '@/lib/api/admin';
import type { Employee } from '@/lib/api/employees'; import type { Employee } from '@/lib/api/employees';
@@ -567,6 +567,10 @@ function EditUserModal({
employeeId: null, employeeId: null,
isActive: true, isActive: true,
}); });
const [userRoles, setUserRoles] = useState<{ id: string; role: { id: string; name: string; nameAr?: string | null } }[]>([]);
const [permissionGroups, setPermissionGroups] = useState<{ id: string; name: string; nameAr?: string | null }[]>([]);
const [assignRoleId, setAssignRoleId] = useState('');
const [rolesLoading, setRolesLoading] = useState(false);
useEffect(() => { useEffect(() => {
if (user) { if (user) {
@@ -580,6 +584,41 @@ function EditUserModal({
} }
}, [user]); }, [user]);
useEffect(() => {
if (isOpen && user) {
userRolesAPI.getAll(user.id).then((r) => setUserRoles(r)).catch(() => setUserRoles([]));
permissionGroupsAPI.getAll().then((g) => setPermissionGroups(g)).catch(() => setPermissionGroups([]));
}
}, [isOpen, user?.id]);
const handleAssignRole = async () => {
if (!user || !assignRoleId) return;
setRolesLoading(true);
try {
await userRolesAPI.assign(user.id, assignRoleId);
const updated = await userRolesAPI.getAll(user.id);
setUserRoles(updated);
setAssignRoleId('');
} catch (err: unknown) {
alert(err instanceof Error ? err.message : 'فشل الإضافة');
} finally {
setRolesLoading(false);
}
};
const handleRemoveRole = async (roleId: string) => {
if (!user) return;
setRolesLoading(true);
try {
await userRolesAPI.remove(user.id, roleId);
setUserRoles((prev) => prev.filter((ur) => ur.role.id !== roleId));
} catch (err: unknown) {
alert(err instanceof Error ? err.message : 'فشل الإزالة');
} finally {
setRolesLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!user) return; if (!user) return;
@@ -670,6 +709,50 @@ function EditUserModal({
الحساب نشط الحساب نشط
</label> </label>
</div> </div>
<div className="border-t pt-4 mt-4">
<label className="block text-sm font-semibold text-gray-700 mb-2">مجموعات الصلاحيات الإضافية</label>
<p className="text-xs text-gray-600 mb-2">صلاحيات اختيارية تضاف إلى صلاحيات الوظيفة</p>
<div className="flex gap-2 mb-3">
<select
value={assignRoleId}
onChange={(e) => setAssignRoleId(e.target.value)}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg"
>
<option value="">إضافة مجموعة...</option>
{permissionGroups
.filter((g) => !userRoles.some((ur) => ur.role.id === g.id))
.map((g) => (
<option key={g.id} value={g.id}>{g.nameAr || g.name}</option>
))}
</select>
<button
type="button"
onClick={handleAssignRole}
disabled={!assignRoleId || rolesLoading}
className="px-4 py-2 bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 disabled:opacity-50"
>
إضافة
</button>
</div>
<div className="space-y-2">
{userRoles.map((ur) => (
<div key={ur.id} className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded-lg">
<span className="font-medium">{ur.role.nameAr || ur.role.name}</span>
<button
type="button"
onClick={() => handleRemoveRole(ur.role.id)}
disabled={rolesLoading}
className="text-red-600 hover:text-red-700 text-sm disabled:opacity-50"
>
إزالة
</button>
</div>
))}
{userRoles.length === 0 && (
<p className="text-sm text-gray-500 py-2">لا توجد مجموعات إضافية</p>
)}
</div>
</div>
<div className="flex gap-3 justify-end pt-4"> <div className="flex gap-3 justify-end pt-4">
<button type="button" onClick={onClose} className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 font-medium"> <button type="button" onClick={onClose} className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 font-medium">
إلغاء إلغاء

View File

@@ -902,11 +902,11 @@ function CRMContent() {
<td className="px-6 py-4"> <td className="px-6 py-4">
<div> <div>
<span className="text-sm font-semibold text-gray-900"> <span className="text-sm font-semibold text-gray-900">
{deal.estimatedValue.toLocaleString()} SAR {(deal.estimatedValue ?? 0).toLocaleString()} SAR
</span> </span>
{deal.actualValue && ( {(deal.actualValue ?? 0) > 0 && (
<p className="text-xs text-green-600"> <p className="text-xs text-green-600">
Actual: {deal.actualValue.toLocaleString()} Actual: {(deal.actualValue ?? 0).toLocaleString()}
</p> </p>
)} )}
</div> </div>

View File

@@ -1,5 +1,6 @@
'use client' 'use client'
import { useState, useEffect } from 'react'
import ProtectedRoute from '@/components/ProtectedRoute' import ProtectedRoute from '@/components/ProtectedRoute'
import { useAuth } from '@/contexts/AuthContext' import { useAuth } from '@/contexts/AuthContext'
import { useLanguage } from '@/contexts/LanguageContext' import { useLanguage } from '@/contexts/LanguageContext'
@@ -7,6 +8,7 @@ import LanguageSwitcher from '@/components/LanguageSwitcher'
import Link from 'next/link' import Link from 'next/link'
import { import {
Users, Users,
User,
TrendingUp, TrendingUp,
Package, Package,
CheckSquare, CheckSquare,
@@ -18,10 +20,20 @@ import {
Bell, Bell,
Shield Shield
} from 'lucide-react' } from 'lucide-react'
import { dashboardAPI } from '@/lib/api'
function DashboardContent() { function DashboardContent() {
const { user, logout, hasPermission } = useAuth() const { user, logout, hasPermission } = useAuth()
const { t, language, dir } = useLanguage() const { t, language, dir } = useLanguage()
const [stats, setStats] = useState({ contacts: 0, activeTasks: 0, notifications: 0 })
useEffect(() => {
dashboardAPI.getStats()
.then((res) => {
if (res.data?.data) setStats(res.data.data)
})
.catch(() => {})
}, [])
const allModules = [ const allModules = [
{ {
@@ -74,6 +86,16 @@ function DashboardContent() {
description: 'الموظفين والإجازات والرواتب', description: 'الموظفين والإجازات والرواتب',
permission: 'hr' permission: 'hr'
}, },
{
id: 'portal',
name: 'البوابة الذاتية',
nameEn: 'My Portal',
icon: User,
color: 'bg-cyan-500',
href: '/portal',
description: 'قروضي، إجازاتي، طلبات الشراء والرواتب',
permission: 'hr'
},
{ {
id: 'marketing', id: 'marketing',
name: 'التسويق', name: 'التسويق',
@@ -128,7 +150,7 @@ function DashboardContent() {
</div> </div>
{/* Admin Panel Link - Only for admins */} {/* Admin Panel Link - Only for admins */}
{(hasPermission('admin', 'view') || user?.role?.name === 'المدير العام' || user?.role?.nameEn === 'General Manager') && ( {hasPermission('admin', 'view') && (
<Link <Link
href="/admin" href="/admin"
className="p-2 hover:bg-red-50 rounded-lg transition-colors relative group" className="p-2 hover:bg-red-50 rounded-lg transition-colors relative group"
@@ -144,7 +166,9 @@ function DashboardContent() {
{/* Notifications */} {/* Notifications */}
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors relative"> <button className="p-2 hover:bg-gray-100 rounded-lg transition-colors relative">
<Bell className="h-5 w-5 text-gray-600" /> <Bell className="h-5 w-5 text-gray-600" />
<span className="absolute top-1 right-1 h-2 w-2 bg-red-500 rounded-full"></span> {stats.notifications > 0 && (
<span className="absolute top-1 right-1 h-2 w-2 bg-red-500 rounded-full"></span>
)}
</button> </button>
{/* Settings */} {/* Settings */}
@@ -193,7 +217,7 @@ function DashboardContent() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-gray-600">المهام النشطة</p> <p className="text-sm text-gray-600">المهام النشطة</p>
<p className="text-3xl font-bold text-gray-900 mt-1">12</p> <p className="text-3xl font-bold text-gray-900 mt-1">{stats.activeTasks}</p>
</div> </div>
<div className="bg-green-100 p-3 rounded-lg"> <div className="bg-green-100 p-3 rounded-lg">
<CheckSquare className="h-8 w-8 text-green-600" /> <CheckSquare className="h-8 w-8 text-green-600" />
@@ -205,7 +229,7 @@ function DashboardContent() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-gray-600">الإشعارات</p> <p className="text-sm text-gray-600">الإشعارات</p>
<p className="text-3xl font-bold text-gray-900 mt-1">5</p> <p className="text-3xl font-bold text-gray-900 mt-1">{stats.notifications}</p>
</div> </div>
<div className="bg-orange-100 p-3 rounded-lg"> <div className="bg-orange-100 p-3 rounded-lg">
<Bell className="h-8 w-8 text-orange-600" /> <Bell className="h-8 w-8 text-orange-600" />
@@ -217,7 +241,7 @@ function DashboardContent() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-gray-600">جهات الاتصال</p> <p className="text-sm text-gray-600">جهات الاتصال</p>
<p className="text-3xl font-bold text-gray-900 mt-1">248</p> <p className="text-3xl font-bold text-gray-900 mt-1">{stats.contacts}</p>
</div> </div>
<div className="bg-purple-100 p-3 rounded-lg"> <div className="bg-purple-100 p-3 rounded-lg">
<Users className="h-8 w-8 text-purple-600" /> <Users className="h-8 w-8 text-purple-600" />
@@ -268,37 +292,7 @@ function DashboardContent() {
{/* Recent Activity */} {/* Recent Activity */}
<div className="bg-white rounded-xl shadow-md p-6 border border-gray-100"> <div className="bg-white rounded-xl shadow-md p-6 border border-gray-100">
<h3 className="text-xl font-bold text-gray-900 mb-4">النشاط الأخير</h3> <h3 className="text-xl font-bold text-gray-900 mb-4">النشاط الأخير</h3>
<div className="space-y-4"> <p className="text-gray-500 text-center py-6">لا يوجد نشاط حديث</p>
<div className="flex items-start gap-3 p-3 hover:bg-gray-50 rounded-lg transition-colors">
<div className="bg-blue-100 p-2 rounded-lg">
<Users className="h-5 w-5 text-blue-600" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-gray-900">تم إضافة عميل جديد</p>
<p className="text-xs text-gray-600">منذ ساعتين</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 hover:bg-gray-50 rounded-lg transition-colors">
<div className="bg-green-100 p-2 rounded-lg">
<TrendingUp className="h-5 w-5 text-green-600" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-gray-900">تم إغلاق صفقة جديدة</p>
<p className="text-xs text-gray-600">منذ 4 ساعات</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 hover:bg-gray-50 rounded-lg transition-colors">
<div className="bg-orange-100 p-2 rounded-lg">
<CheckSquare className="h-5 w-5 text-orange-600" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-gray-900">تم إكمال مهمة</p>
<p className="text-xs text-gray-600">منذ يوم واحد</p>
</div>
</div>
</div>
</div> </div>
</main> </main>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="128" height="128" rx="24" fill="#2563EB"/> <rect width="32" height="32" rx="4" fill="#2563eb"/>
<text x="64" y="82" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="72" font-weight="800" fill="#FFFFFF">Z</text> <text x="16" y="22" text-anchor="middle" fill="white" font-size="14" font-family="sans-serif" font-weight="bold">Z</text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 270 B

After

Width:  |  Height:  |  Size: 247 B

View File

@@ -110,13 +110,11 @@ export default function LoginPage() {
</button> </button>
</form> </form>
{/* Demo Credentials */} {/* System Administrator */}
<div className="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200"> <div className="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
<h3 className="text-sm font-semibold text-blue-900 mb-2">الحسابات التجريبية:</h3> <h3 className="text-sm font-semibold text-blue-900 mb-2">مدير النظام:</h3>
<div className="text-sm text-blue-800 space-y-1"> <div className="text-sm text-blue-800">
<p> <strong>المدير العام:</strong> gm@atmata.com / Admin@123</p> <p><strong>admin@system.local</strong> / Admin@123</p>
<p> <strong>مدير المبيعات:</strong> sales.manager@atmata.com / Admin@123</p>
<p> <strong>مندوب مبيعات:</strong> sales.rep@atmata.com / Admin@123</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -628,9 +628,9 @@ function MarketingContent() {
<span className="text-sm font-semibold text-gray-900"> <span className="text-sm font-semibold text-gray-900">
{(campaign.budget || 0).toLocaleString()} SAR {(campaign.budget || 0).toLocaleString()} SAR
</span> </span>
{campaign.actualCost && ( {(campaign.actualCost ?? 0) > 0 && (
<p className="text-xs text-gray-600"> <p className="text-xs text-gray-600">
Spent: {campaign.actualCost.toLocaleString()} Spent: {(campaign.actualCost ?? 0).toLocaleString()}
</p> </p>
)} )}
</div> </div>

View File

@@ -0,0 +1,94 @@
'use client'
import { useState, useEffect } from 'react'
import { portalAPI, type Attendance } from '@/lib/api/portal'
import LoadingSpinner from '@/components/LoadingSpinner'
import { Clock } from 'lucide-react'
export default function PortalAttendancePage() {
const [attendance, setAttendance] = useState<Attendance[]>([])
const [loading, setLoading] = useState(true)
const [month, setMonth] = useState(new Date().getMonth() + 1)
const [year, setYear] = useState(new Date().getFullYear())
useEffect(() => {
setLoading(true)
portalAPI.getAttendance(month, year)
.then(setAttendance)
.catch(() => setAttendance([]))
.finally(() => setLoading(false))
}, [month, year])
const months = Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleDateString('ar-SA', { month: 'long' }) }))
const years = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i)
if (loading) return <LoadingSpinner />
return (
<div className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<h1 className="text-2xl font-bold text-gray-900">حضوري</h1>
<div className="flex gap-2">
<select
value={month}
onChange={(e) => setMonth(parseInt(e.target.value))}
className="px-3 py-2 border border-gray-300 rounded-lg"
>
{months.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
<select
value={year}
onChange={(e) => setYear(parseInt(e.target.value))}
className="px-3 py-2 border border-gray-300 rounded-lg"
>
{years.map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
</div>
{attendance.length === 0 ? (
<div className="bg-white rounded-xl shadow p-12 text-center text-gray-500">
<Clock className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>لا توجد سجلات حضور لهذا الشهر</p>
</div>
) : (
<div className="bg-white rounded-xl shadow overflow-hidden border border-gray-100">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="text-right py-3 px-4">التاريخ</th>
<th className="text-right py-3 px-4">دخول</th>
<th className="text-right py-3 px-4">خروج</th>
<th className="text-right py-3 px-4">ساعات العمل</th>
<th className="text-right py-3 px-4">الحالة</th>
</tr>
</thead>
<tbody>
{attendance.map((a) => (
<tr key={a.id} className="border-t">
<td className="py-3 px-4">{new Date(a.date).toLocaleDateString('ar-SA')}</td>
<td className="py-3 px-4">{a.checkIn ? new Date(a.checkIn).toLocaleTimeString('ar-SA', { hour: '2-digit', minute: '2-digit' }) : '-'}</td>
<td className="py-3 px-4">{a.checkOut ? new Date(a.checkOut).toLocaleTimeString('ar-SA', { hour: '2-digit', minute: '2-digit' }) : '-'}</td>
<td className="py-3 px-4">{a.workHours != null ? Number(a.workHours).toFixed(1) : '-'}</td>
<td className="py-3 px-4">
<span className={`px-2 py-1 rounded text-xs ${
a.status === 'PRESENT' ? 'bg-green-100 text-green-800' :
a.status === 'ABSENT' ? 'bg-red-100 text-red-800' :
a.status === 'LATE' ? 'bg-amber-100 text-amber-800' : 'bg-gray-100'
}`}>
{a.status === 'PRESENT' ? 'حاضر' : a.status === 'ABSENT' ? 'غائب' : a.status === 'LATE' ? 'متأخر' : a.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,107 @@
'use client'
import ProtectedRoute from '@/components/ProtectedRoute'
import { useAuth } from '@/contexts/AuthContext'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import {
LayoutDashboard,
Banknote,
Calendar,
ShoppingCart,
Clock,
DollarSign,
Building2,
LogOut,
User
} from 'lucide-react'
function PortalLayoutContent({ children }: { children: React.ReactNode }) {
const { user, logout } = useAuth()
const pathname = usePathname()
const menuItems = [
{ icon: LayoutDashboard, label: 'لوحة التحكم', labelEn: 'Dashboard', href: '/portal', exact: true },
{ icon: Banknote, label: 'قروضي', labelEn: 'My Loans', href: '/portal/loans' },
{ icon: Calendar, label: 'إجازاتي', labelEn: 'My Leave', href: '/portal/leave' },
{ icon: ShoppingCart, label: 'طلبات الشراء', labelEn: 'Purchase Requests', href: '/portal/purchase-requests' },
{ icon: Clock, label: 'حضوري', labelEn: 'My Attendance', href: '/portal/attendance' },
{ icon: DollarSign, label: 'رواتبي', labelEn: 'My Salaries', href: '/portal/salaries' },
]
const isActive = (href: string, exact?: boolean) => {
if (exact) return pathname === href
return pathname.startsWith(href)
}
return (
<div className="min-h-screen bg-gray-50 flex" dir="rtl">
<aside className="w-64 bg-white border-l shadow-lg fixed h-full overflow-y-auto">
<div className="p-6 border-b">
<div className="flex items-center gap-3 mb-4">
<div className="bg-teal-600 p-2 rounded-lg">
<User className="h-6 w-6 text-white" />
</div>
<div>
<h2 className="text-lg font-bold text-gray-900">البوابة الذاتية</h2>
<p className="text-xs text-gray-600">Employee Portal</p>
</div>
</div>
<div className="bg-teal-50 border border-teal-200 rounded-lg p-3">
<p className="text-xs font-semibold text-teal-900">{user?.username}</p>
<p className="text-xs text-teal-700">{user?.role?.name || 'موظف'}</p>
</div>
</div>
<nav className="p-4">
{menuItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href, item.exact)
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-4 py-3 rounded-lg mb-2 transition-all ${
active ? 'bg-teal-600 text-white shadow-md' : 'text-gray-700 hover:bg-gray-100'
}`}
>
<Icon className="h-5 w-5" />
<span className="font-medium">{item.label}</span>
</Link>
)
})}
<hr className="my-4 border-gray-200" />
<Link
href="/dashboard"
className="flex items-center gap-3 px-4 py-3 rounded-lg mb-2 text-gray-700 hover:bg-gray-100 transition-all"
>
<Building2 className="h-5 w-5" />
<span className="font-medium">العودة للنظام</span>
</Link>
<button
onClick={logout}
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg text-red-600 hover:bg-red-50 transition-all"
>
<LogOut className="h-5 w-5" />
<span className="font-medium">تسجيل الخروج</span>
</button>
</nav>
</aside>
<main className="mr-64 flex-1 p-8">
{children}
</main>
</div>
)
}
export default function PortalLayout({ children }: { children: React.ReactNode }) {
return (
<ProtectedRoute>
<PortalLayoutContent>{children}</PortalLayoutContent>
</ProtectedRoute>
)
}

View File

@@ -0,0 +1,189 @@
'use client'
import { useState, useEffect } from 'react'
import { portalAPI } from '@/lib/api/portal'
import Modal from '@/components/Modal'
import LoadingSpinner from '@/components/LoadingSpinner'
import { toast } from 'react-hot-toast'
import { Calendar, Plus } from 'lucide-react'
const LEAVE_TYPES = [
{ value: 'ANNUAL', label: 'إجازة سنوية' },
{ value: 'SICK', label: 'إجازة مرضية' },
{ value: 'EMERGENCY', label: 'طوارئ' },
{ value: 'UNPAID', label: 'بدون راتب' },
]
const STATUS_MAP: Record<string, { label: string; color: string }> = {
PENDING: { label: 'قيد المراجعة', color: 'bg-amber-100 text-amber-800' },
APPROVED: { label: 'معتمدة', color: 'bg-green-100 text-green-800' },
REJECTED: { label: 'مرفوضة', color: 'bg-red-100 text-red-800' },
}
export default function PortalLeavePage() {
const [leaveBalance, setLeaveBalance] = useState<any[]>([])
const [leaves, setLeaves] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [form, setForm] = useState({ leaveType: 'ANNUAL', startDate: '', endDate: '', reason: '' })
const load = () => {
setLoading(true)
Promise.all([portalAPI.getLeaveBalance(), portalAPI.getLeaves()])
.then(([balance, list]) => {
setLeaveBalance(balance)
setLeaves(list)
})
.catch(() => toast.error('فشل تحميل البيانات'))
.finally(() => setLoading(false))
}
useEffect(() => load(), [])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!form.startDate || !form.endDate) {
toast.error('أدخل تاريخ البداية والنهاية')
return
}
if (new Date(form.endDate) < new Date(form.startDate)) {
toast.error('تاريخ النهاية يجب أن يكون بعد تاريخ البداية')
return
}
setSubmitting(true)
portalAPI.submitLeaveRequest({
leaveType: form.leaveType,
startDate: form.startDate,
endDate: form.endDate,
reason: form.reason || undefined,
})
.then(() => {
setShowModal(false)
setForm({ leaveType: 'ANNUAL', startDate: '', endDate: '', reason: '' })
toast.success('تم إرسال طلب الإجازة')
load()
})
.catch(() => toast.error('فشل إرسال الطلب'))
.finally(() => setSubmitting(false))
}
if (loading) return <LoadingSpinner />
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">إجازاتي</h1>
<button
onClick={() => setShowModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
>
<Plus className="h-4 w-4" />
طلب إجازة
</button>
</div>
{leaveBalance.length > 0 && (
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
<h2 className="text-lg font-semibold text-gray-900 mb-4">رصيد الإجازات</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{leaveBalance.map((b) => (
<div key={b.leaveType} className="border rounded-lg p-4">
<p className="text-sm text-gray-600">
{b.leaveType === 'ANNUAL' ? 'سنوية' : b.leaveType === 'SICK' ? 'مرضية' : b.leaveType}
</p>
<p className="text-2xl font-bold text-teal-600 mt-1">{b.available} يوم</p>
<p className="text-xs text-gray-500">من {b.totalDays + b.carriedOver} (مستخدم: {b.usedDays})</p>
</div>
))}
</div>
</div>
)}
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
<h2 className="text-lg font-semibold text-gray-900 mb-4">طلباتي</h2>
{leaves.length === 0 ? (
<p className="text-gray-500 text-center py-8">لا توجد طلبات إجازة</p>
) : (
<div className="space-y-3">
{leaves.map((l) => {
const statusInfo = STATUS_MAP[l.status] || { label: l.status, color: 'bg-gray-100' }
return (
<div key={l.id} className="flex justify-between items-center py-3 border-b last:border-0">
<div>
<p className="font-medium">
{l.leaveType === 'ANNUAL' ? 'سنوية' : l.leaveType === 'SICK' ? 'مرضية' : l.leaveType} - {l.days} يوم
</p>
<p className="text-sm text-gray-600">
{new Date(l.startDate).toLocaleDateString('ar-SA')} - {new Date(l.endDate).toLocaleDateString('ar-SA')}
</p>
{l.rejectedReason && <p className="text-sm text-red-600">سبب الرفض: {l.rejectedReason}</p>}
</div>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
</span>
</div>
)
})}
</div>
)}
</div>
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب إجازة جديد">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">نوع الإجازة</label>
<select
value={form.leaveType}
onChange={(e) => setForm((p) => ({ ...p, leaveType: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
>
{LEAVE_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">من تاريخ</label>
<input
type="date"
value={form.startDate}
onChange={(e) => setForm((p) => ({ ...p, startDate: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">إلى تاريخ</label>
<input
type="date"
value={form.endDate}
onChange={(e) => setForm((p) => ({ ...p, endDate: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">السبب</label>
<textarea
value={form.reason}
onChange={(e) => setForm((p) => ({ ...p, reason: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
rows={3}
/>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setShowModal(false)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">
إلغاء
</button>
<button type="submit" disabled={submitting} className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50">
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
</button>
</div>
</form>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,179 @@
'use client'
import { useState, useEffect } from 'react'
import { portalAPI, type Loan } from '@/lib/api/portal'
import Modal from '@/components/Modal'
import LoadingSpinner from '@/components/LoadingSpinner'
import { toast } from 'react-hot-toast'
import { Banknote, Plus, ChevronLeft } from 'lucide-react'
const STATUS_MAP: Record<string, { label: string; color: string }> = {
PENDING: { label: 'قيد المراجعة', color: 'bg-amber-100 text-amber-800' },
APPROVED: { label: 'معتمد', color: 'bg-green-100 text-green-800' },
REJECTED: { label: 'مرفوض', color: 'bg-red-100 text-red-800' },
ACTIVE: { label: 'نشط', color: 'bg-blue-100 text-blue-800' },
PAID_OFF: { label: 'مسدد', color: 'bg-gray-100 text-gray-800' },
}
export default function PortalLoansPage() {
const [loans, setLoans] = useState<Loan[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [form, setForm] = useState({ type: 'SALARY_ADVANCE', amount: '', installments: '1', reason: '' })
useEffect(() => {
portalAPI.getLoans()
.then(setLoans)
.catch(() => toast.error('فشل تحميل القروض'))
.finally(() => setLoading(false))
}, [])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const amount = parseFloat(form.amount)
if (!amount || amount <= 0) {
toast.error('أدخل مبلغاً صالحاً')
return
}
setSubmitting(true)
portalAPI.submitLoanRequest({
type: form.type,
amount,
installments: parseInt(form.installments) || 1,
reason: form.reason || undefined,
})
.then((loan) => {
setLoans((prev) => [loan, ...prev])
setShowModal(false)
setForm({ type: 'SALARY_ADVANCE', amount: '', installments: '1', reason: '' })
toast.success('تم إرسال طلب القرض')
})
.catch(() => toast.error('فشل إرسال الطلب'))
.finally(() => setSubmitting(false))
}
if (loading) return <LoadingSpinner />
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">قروضي</h1>
<button
onClick={() => setShowModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
>
<Plus className="h-4 w-4" />
طلب قرض
</button>
</div>
{loans.length === 0 ? (
<div className="bg-white rounded-xl shadow p-12 text-center text-gray-500">
<Banknote className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>لا توجد قروض</p>
<button onClick={() => setShowModal(true)} className="mt-4 text-teal-600 hover:underline">
تقديم طلب قرض
</button>
</div>
) : (
<div className="space-y-4">
{loans.map((loan) => {
const statusInfo = STATUS_MAP[loan.status] || { label: loan.status, color: 'bg-gray-100 text-gray-800' }
return (
<div key={loan.id} className="bg-white rounded-xl shadow p-6 border border-gray-100">
<div className="flex justify-between items-start">
<div>
<p className="font-semibold text-gray-900">{loan.loanNumber}</p>
<p className="text-sm text-gray-600 mt-1">
{loan.type === 'SALARY_ADVANCE' ? 'سلفة راتب' : loan.type} - {Number(loan.amount).toLocaleString()} ر.س
</p>
<p className="text-xs text-gray-500 mt-1">
{loan.installments} أقساط × {loan.monthlyAmount ? Number(loan.monthlyAmount).toLocaleString() : '-'} ر.س
</p>
{loan.installmentsList && loan.installmentsList.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{loan.installmentsList.map((i) => (
<span
key={i.id}
className={`text-xs px-2 py-1 rounded ${
i.status === 'PAID' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'
}`}
>
{i.installmentNumber}: {i.status === 'PAID' ? 'مسدد' : 'معلق'}
</span>
))}
</div>
)}
</div>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
</span>
</div>
{loan.rejectedReason && (
<p className="mt-2 text-sm text-red-600">سبب الرفض: {loan.rejectedReason}</p>
)}
</div>
)
})}
</div>
)}
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب قرض جديد">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">نوع القرض</label>
<select
value={form.type}
onChange={(e) => setForm((p) => ({ ...p, type: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
>
<option value="SALARY_ADVANCE">سلفة راتب</option>
<option value="EQUIPMENT">معدات</option>
<option value="PERSONAL">شخصي</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">المبلغ (ر.س) *</label>
<input
type="number"
min="1"
step="0.01"
value={form.amount}
onChange={(e) => setForm((p) => ({ ...p, amount: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">عدد الأقساط</label>
<input
type="number"
min="1"
value={form.installments}
onChange={(e) => setForm((p) => ({ ...p, installments: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">السبب</label>
<textarea
value={form.reason}
onChange={(e) => setForm((p) => ({ ...p, reason: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
rows={3}
/>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setShowModal(false)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">
إلغاء
</button>
<button type="submit" disabled={submitting} className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50">
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
</button>
</div>
</form>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,158 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { portalAPI, type PortalProfile } from '@/lib/api/portal'
import LoadingSpinner from '@/components/LoadingSpinner'
import { Banknote, Calendar, ShoppingCart, Clock, Plus, ArrowLeft } from 'lucide-react'
import { toast } from 'react-hot-toast'
export default function PortalDashboardPage() {
const [data, setData] = useState<PortalProfile | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
portalAPI.getMe()
.then(setData)
.catch(() => toast.error('فشل تحميل البيانات'))
.finally(() => setLoading(false))
}, [])
if (loading) return <LoadingSpinner />
if (!data) return <div className="text-center text-gray-500 py-12">لا توجد بيانات</div>
const { employee, stats } = data
const name = employee.firstNameAr && employee.lastNameAr
? `${employee.firstNameAr} ${employee.lastNameAr}`
: `${employee.firstName} ${employee.lastName}`
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">مرحباً، {name}</h1>
<p className="text-gray-600 mt-1">
{employee.department?.nameAr || employee.department?.name} - {employee.position?.titleAr || employee.position?.title}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">رصيد الإجازات</p>
<p className="text-2xl font-bold text-teal-600 mt-1">
{stats.leaveBalance.reduce((s, b) => s + b.available, 0)} يوم
</p>
</div>
<div className="bg-teal-100 p-3 rounded-lg">
<Calendar className="h-6 w-6 text-teal-600" />
</div>
</div>
<Link href="/portal/leave" className="mt-4 text-sm text-teal-600 hover:underline flex items-center gap-1">
عرض التفاصيل <ArrowLeft className="h-4 w-4" />
</Link>
</div>
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">القروض النشطة</p>
<p className="text-2xl font-bold text-amber-600 mt-1">{stats.activeLoansCount}</p>
</div>
<div className="bg-amber-100 p-3 rounded-lg">
<Banknote className="h-6 w-6 text-amber-600" />
</div>
</div>
<Link href="/portal/loans" className="mt-4 text-sm text-amber-600 hover:underline flex items-center gap-1">
عرض القروض <ArrowLeft className="h-4 w-4" />
</Link>
</div>
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">طلبات قيد المراجعة</p>
<p className="text-2xl font-bold text-blue-600 mt-1">
{stats.pendingLeavesCount + stats.pendingPurchaseRequestsCount}
</p>
</div>
<div className="bg-blue-100 p-3 rounded-lg">
<ShoppingCart className="h-6 w-6 text-blue-600" />
</div>
</div>
<div className="mt-4 flex gap-4">
<Link href="/portal/leave" className="text-sm text-blue-600 hover:underline">الإجازات</Link>
<Link href="/portal/purchase-requests" className="text-sm text-blue-600 hover:underline">الشراء</Link>
</div>
</div>
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">حضوري</p>
<p className="text-sm text-gray-500 mt-1">هذا الشهر</p>
</div>
<div className="bg-gray-100 p-3 rounded-lg">
<Clock className="h-6 w-6 text-gray-600" />
</div>
</div>
<Link href="/portal/attendance" className="mt-4 text-sm text-gray-600 hover:underline flex items-center gap-1">
عرض الحضور <ArrowLeft className="h-4 w-4" />
</Link>
</div>
</div>
{stats.leaveBalance.length > 0 && (
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
<h2 className="text-lg font-semibold text-gray-900 mb-4">تفاصيل رصيد الإجازات</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-right py-2">نوع الإجازة</th>
<th className="text-right py-2">الإجمالي</th>
<th className="text-right py-2">المستخدم</th>
<th className="text-right py-2">المتبقي</th>
</tr>
</thead>
<tbody>
{stats.leaveBalance.map((b) => (
<tr key={b.leaveType} className="border-b last:border-0">
<td className="py-2">{b.leaveType === 'ANNUAL' ? 'سنوية' : b.leaveType === 'SICK' ? 'مرضية' : b.leaveType}</td>
<td className="py-2">{b.totalDays + b.carriedOver}</td>
<td className="py-2">{b.usedDays}</td>
<td className="py-2 font-semibold text-teal-600">{b.available}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="flex flex-wrap gap-4">
<Link
href="/portal/loans"
className="inline-flex items-center gap-2 px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700"
>
<Plus className="h-4 w-4" />
طلب قرض
</Link>
<Link
href="/portal/leave"
className="inline-flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
>
<Plus className="h-4 w-4" />
طلب إجازة
</Link>
<Link
href="/portal/purchase-requests"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus className="h-4 w-4" />
طلب شراء
</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,210 @@
'use client'
import { useState, useEffect } from 'react'
import { portalAPI, type PurchaseRequest } from '@/lib/api/portal'
import Modal from '@/components/Modal'
import LoadingSpinner from '@/components/LoadingSpinner'
import { toast } from 'react-hot-toast'
import { ShoppingCart, Plus } from 'lucide-react'
const STATUS_MAP: Record<string, { label: string; color: string }> = {
PENDING: { label: 'قيد المراجعة', color: 'bg-amber-100 text-amber-800' },
APPROVED: { label: 'معتمد', color: 'bg-green-100 text-green-800' },
REJECTED: { label: 'مرفوض', color: 'bg-red-100 text-red-800' },
ORDERED: { label: 'تم الطلب', color: 'bg-blue-100 text-blue-800' },
}
export default function PortalPurchaseRequestsPage() {
const [requests, setRequests] = useState<PurchaseRequest[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [form, setForm] = useState({
items: [{ description: '', quantity: 1, estimatedPrice: '' }],
reason: '',
priority: 'NORMAL',
})
useEffect(() => {
portalAPI.getPurchaseRequests()
.then(setRequests)
.catch(() => toast.error('فشل تحميل الطلبات'))
.finally(() => setLoading(false))
}, [])
const addItem = () => setForm((p) => ({ ...p, items: [...p.items, { description: '', quantity: 1, estimatedPrice: '' }] }))
const removeItem = (i: number) =>
setForm((p) => ({ ...p, items: p.items.filter((_, idx) => idx !== i) }))
const updateItem = (i: number, key: string, value: string | number) =>
setForm((p) => ({
...p,
items: p.items.map((it, idx) => (idx === i ? { ...it, [key]: value } : it)),
}))
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const items = form.items
.filter((it) => it.description.trim())
.map((it) => ({
description: it.description,
quantity: it.quantity || 1,
estimatedPrice: parseFloat(String(it.estimatedPrice)) || 0,
}))
if (items.length === 0) {
toast.error('أضف صنفاً واحداً على الأقل')
return
}
setSubmitting(true)
portalAPI.submitPurchaseRequest({
items,
reason: form.reason || undefined,
priority: form.priority,
})
.then((pr) => {
setRequests((prev) => [pr, ...prev])
setShowModal(false)
setForm({ items: [{ description: '', quantity: 1, estimatedPrice: '' }], reason: '', priority: 'NORMAL' })
toast.success('تم إرسال طلب الشراء')
})
.catch(() => toast.error('فشل إرسال الطلب'))
.finally(() => setSubmitting(false))
}
if (loading) return <LoadingSpinner />
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">طلبات الشراء</h1>
<button
onClick={() => setShowModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
>
<Plus className="h-4 w-4" />
طلب شراء جديد
</button>
</div>
{requests.length === 0 ? (
<div className="bg-white rounded-xl shadow p-12 text-center text-gray-500">
<ShoppingCart className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>لا توجد طلبات شراء</p>
<button onClick={() => setShowModal(true)} className="mt-4 text-teal-600 hover:underline">
تقديم طلب شراء
</button>
</div>
) : (
<div className="space-y-4">
{requests.map((pr) => {
const statusInfo = STATUS_MAP[pr.status] || { label: pr.status, color: 'bg-gray-100 text-gray-800' }
const items = Array.isArray(pr.items) ? pr.items : []
return (
<div key={pr.id} className="bg-white rounded-xl shadow p-6 border border-gray-100">
<div className="flex justify-between items-start">
<div>
<p className="font-semibold text-gray-900">{pr.requestNumber}</p>
<p className="text-sm text-gray-600 mt-1">
{pr.totalAmount != null ? `${Number(pr.totalAmount).toLocaleString()} ر.س` : '-'}
</p>
{items.length > 0 && (
<ul className="mt-2 text-sm text-gray-600 list-disc list-inside">
{items.slice(0, 3).map((it: any, i: number) => (
<li key={i}>
{it.description} × {it.quantity || 1}
{it.estimatedPrice ? ` (${Number(it.estimatedPrice).toLocaleString()} ر.س)` : ''}
</li>
))}
{items.length > 3 && <li>... و {items.length - 3} أصناف أخرى</li>}
</ul>
)}
{pr.rejectedReason && (
<p className="mt-2 text-sm text-red-600">سبب الرفض: {pr.rejectedReason}</p>
)}
</div>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
</span>
</div>
</div>
)
})}
</div>
)}
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب شراء جديد">
<form onSubmit={handleSubmit} className="space-y-4 max-h-[70vh] overflow-y-auto">
<div>
<div className="flex justify-between items-center mb-2">
<label className="text-sm font-medium text-gray-700">الأصناف</label>
<button type="button" onClick={addItem} className="text-teal-600 text-sm hover:underline">
+ إضافة صنف
</button>
</div>
<div className="space-y-3">
{form.items.map((it, i) => (
<div key={i} className="flex gap-2 items-start border p-2 rounded">
<input
placeholder="الوصف"
value={it.description}
onChange={(e) => updateItem(i, 'description', e.target.value)}
className="flex-1 px-2 py-1 border rounded text-sm"
/>
<input
type="number"
min="1"
placeholder="الكمية"
value={it.quantity}
onChange={(e) => updateItem(i, 'quantity', parseInt(e.target.value) || 1)}
className="w-20 px-2 py-1 border rounded text-sm"
/>
<input
type="number"
min="0"
step="0.01"
placeholder="السعر"
value={it.estimatedPrice}
onChange={(e) => updateItem(i, 'estimatedPrice', e.target.value)}
className="w-24 px-2 py-1 border rounded text-sm"
/>
<button type="button" onClick={() => removeItem(i)} className="text-red-600 text-sm">
حذف
</button>
</div>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">الأولوية</label>
<select
value={form.priority}
onChange={(e) => setForm((p) => ({ ...p, priority: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
>
<option value="LOW">منخفضة</option>
<option value="NORMAL">عادية</option>
<option value="HIGH">عالية</option>
<option value="URGENT">عاجلة</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">السبب / التوضيح</label>
<textarea
value={form.reason}
onChange={(e) => setForm((p) => ({ ...p, reason: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
rows={2}
/>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setShowModal(false)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">
إلغاء
</button>
<button type="submit" disabled={submitting} className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50">
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
</button>
</div>
</form>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,65 @@
'use client'
import { useState, useEffect } from 'react'
import { portalAPI, type Salary } from '@/lib/api/portal'
import LoadingSpinner from '@/components/LoadingSpinner'
import { DollarSign } from 'lucide-react'
const MONTHS_AR = ['يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر']
export default function PortalSalariesPage() {
const [salaries, setSalaries] = useState<Salary[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
portalAPI.getSalaries()
.then(setSalaries)
.catch(() => setSalaries([]))
.finally(() => setLoading(false))
}, [])
if (loading) return <LoadingSpinner />
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900">رواتبي</h1>
{salaries.length === 0 ? (
<div className="bg-white rounded-xl shadow p-12 text-center text-gray-500">
<DollarSign className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>لا توجد سجلات رواتب</p>
</div>
) : (
<div className="space-y-4">
{salaries.map((s) => (
<div key={s.id} className="bg-white rounded-xl shadow p-6 border border-gray-100">
<div className="flex justify-between items-start">
<div>
<p className="font-semibold text-gray-900">
{MONTHS_AR[s.month - 1]} {s.year}
</p>
<p className="text-2xl font-bold text-teal-600 mt-1">
{Number(s.netSalary).toLocaleString()} ر.س
</p>
<div className="mt-2 text-sm text-gray-600 space-y-1">
<p>الأساس: {Number(s.basicSalary).toLocaleString()} | البدلات: {Number(s.allowances).toLocaleString()} | الخصومات: {Number(s.deductions).toLocaleString()}</p>
<p>عمولة: {Number(s.commissions).toLocaleString()} | إضافي: {Number(s.overtimePay).toLocaleString()}</p>
</div>
</div>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
s.status === 'PAID' ? 'bg-green-100 text-green-800' :
s.status === 'APPROVED' ? 'bg-blue-100 text-blue-800' : 'bg-amber-100 text-amber-800'
}`}>
{s.status === 'PAID' ? 'مدفوع' : s.status === 'APPROVED' ? 'معتمد' : 'قيد المعالجة'}
</span>
</div>
{s.paidDate && (
<p className="text-xs text-gray-500 mt-2">تاريخ الدفع: {new Date(s.paidDate).toLocaleDateString('ar-SA')}</p>
)}
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,116 @@
'use client'
import { Tree, TreeNode } from 'react-organizational-chart'
import type { Department } from '@/lib/api/employees'
import { Building2, Users } from 'lucide-react'
// Force LTR for org chart - RTL breaks the tree layout and connecting lines
function DeptNode({ dept }: { dept: Department }) {
const empCount = dept._count?.employees ?? dept.employees?.length ?? 0
const childCount = dept.children?.length ?? 0
return (
<div className="inline-flex flex-col items-center" dir="ltr">
<div className="px-4 py-3 bg-white border-2 border-blue-200 rounded-lg shadow-md hover:shadow-lg transition-shadow min-w-[180px] max-w-[220px]">
<div className="flex items-center gap-2 mb-1">
<Building2 className="h-5 w-5 text-blue-600 flex-shrink-0" />
<span className="font-semibold text-gray-900 truncate">{dept.nameAr || dept.name}</span>
</div>
<p className="text-xs text-gray-500 mb-1">{dept.code}</p>
<div className="flex gap-2 text-xs text-gray-600">
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{empCount} موظف
</span>
{childCount > 0 && (
<span> {childCount} أقسام فرعية</span>
)}
</div>
{dept.employees && dept.employees.length > 0 && (
<div className="mt-2 pt-2 border-t border-gray-100 space-y-0.5">
{dept.employees.slice(0, 3).map((emp: any) => (
<p key={emp.id} className="text-xs text-gray-600 truncate">
{emp.firstNameAr || emp.firstName} {emp.lastNameAr || emp.lastName}
{emp.position && ` - ${emp.position.titleAr || emp.position.title}`}
</p>
))}
{dept.employees.length > 3 && (
<p className="text-xs text-gray-500">+{dept.employees.length - 3} أكثر</p>
)}
</div>
)}
</div>
</div>
)
}
export function OrgChartTree({ dept }: { dept: Department }) {
if (!dept.children?.length) {
return <TreeNode label={<DeptNode dept={dept} />} />
}
return (
<TreeNode label={<DeptNode dept={dept} />}>
{dept.children.map((child) => (
<OrgChartTree key={child.id} dept={child} />
))}
</TreeNode>
)
}
interface OrgChartProps {
hierarchy: Department[]
}
export default function OrgChart({ hierarchy }: OrgChartProps) {
if (hierarchy.length === 0) {
return (
<div className="p-12 text-center text-gray-500">
<Building2 className="h-16 w-16 mx-auto mb-4 text-gray-300" />
<p>لا توجد أقسام. أضف أقساماً من تبويب الأقسام.</p>
</div>
)
}
if (hierarchy.length === 1) {
const root = hierarchy[0]
return (
<div className="p-6 overflow-auto min-h-[400px]" dir="ltr" style={{ direction: 'ltr' }}>
<div className="inline-block">
<Tree
label={<DeptNode dept={root} />}
lineWidth="2px"
lineColor="#93c5fd"
lineBorderRadius="4px"
lineHeight="24px"
nodePadding="16px"
>
{root.children?.map((child) => (
<OrgChartTree key={child.id} dept={child} />
))}
</Tree>
</div>
</div>
)
}
return (
<div className="p-6 overflow-auto min-h-[400px]" dir="ltr" style={{ direction: 'ltr' }}>
<div className="inline-block">
<Tree
label={
<div className="px-4 py-3 bg-blue-50 border-2 border-blue-200 rounded-lg min-w-[200px] text-center">
<p className="font-bold text-gray-900">الشركة</p>
<p className="text-xs text-gray-500">الجذر التنظيمي</p>
</div>
}
lineWidth="2px"
lineColor="#93c5fd"
lineBorderRadius="4px"
lineHeight="24px"
nodePadding="16px"
>
{hierarchy.map((dept) => (
<OrgChartTree key={dept.id} dept={dept} />
))}
</Tree>
</div>
</div>
)
}

View File

@@ -77,6 +77,10 @@ export const contactsAPI = {
api.post('/contacts/merge', { sourceId, targetId, reason }), api.post('/contacts/merge', { sourceId, targetId, reason }),
} }
export const dashboardAPI = {
getStats: () => api.get<{ success: boolean; data: { contacts: number; activeTasks: number; notifications: number } }>('/dashboard/stats'),
}
export const crmAPI = { export const crmAPI = {
// Deals // Deals
getDeals: (params?: any) => api.get('/crm/deals', { params }), getDeals: (params?: any) => api.get('/crm/deals', { params }),

View File

@@ -113,7 +113,7 @@ export const statsAPI = {
}, },
}; };
// Positions (Roles) API // Positions (Roles) API - maps to HR positions with permissions
export interface PositionPermission { export interface PositionPermission {
id: string; id: string;
module: string; module: string;
@@ -126,25 +126,19 @@ export interface PositionRole {
title: string; title: string;
titleAr?: string | null; titleAr?: string | null;
code: string; code: string;
level: number; department?: { name: string; nameAr?: string | null };
departmentId: string;
department?: { id?: string; name: string; nameAr?: string | null };
permissions: PositionPermission[]; permissions: PositionPermission[];
usersCount: number; usersCount: number;
_count?: { employees: number }; _count?: { employees: number };
} }
export interface CreatePositionPayload { export interface CreatePositionData {
title: string; title: string;
titleAr?: string | null; titleAr?: string;
code: string;
departmentId: string; departmentId: string;
level?: number; level?: number;
code?: string; description?: string;
}
export interface UpdatePositionPayload {
title?: string;
titleAr?: string | null;
} }
export const positionsAPI = { export const positionsAPI = {
@@ -153,20 +147,19 @@ export const positionsAPI = {
return response.data.data || []; return response.data.data || [];
}, },
create: async (payload: CreatePositionPayload): Promise<PositionRole> => { create: async (data: CreatePositionData): Promise<PositionRole> => {
const response = await api.post('/admin/positions', payload); const response = await api.post('/admin/positions', data);
return response.data.data; return response.data.data;
}, },
update: async (positionId: string, payload: UpdatePositionPayload): Promise<PositionRole> => { update: async (
const response = await api.put(`/admin/positions/${positionId}`, payload); id: string,
data: Partial<CreatePositionData & { isActive?: boolean }>
): Promise<PositionRole> => {
const response = await api.put(`/admin/positions/${id}`, data);
return response.data.data; return response.data.data;
}, },
delete: async (positionId: string): Promise<void> => {
await api.delete(`/admin/positions/${positionId}`);
},
updatePermissions: async ( updatePermissions: async (
positionId: string, positionId: string,
permissions: Array<{ module: string; resource: string; actions: string[] }> permissions: Array<{ module: string; resource: string; actions: string[] }>
@@ -178,7 +171,7 @@ export const positionsAPI = {
}, },
}; };
// Roles API - alias for positions // Roles API - alias for positions (for compatibility with existing frontend)
export interface Role { export interface Role {
id: string; id: string;
name: string; name: string;
@@ -218,6 +211,53 @@ export const rolesAPI = {
}, },
}; };
// Permission Groups API (Phase 3 - multi-group)
export interface PermissionGroup {
id: string;
name: string;
nameAr?: string | null;
description?: string | null;
isActive: boolean;
permissions: { id: string; module: string; resource: string; actions: string[] }[];
_count?: { userRoles: number };
}
export const permissionGroupsAPI = {
getAll: async (): Promise<PermissionGroup[]> => {
const response = await api.get('/admin/permission-groups');
return response.data.data || [];
},
create: async (data: { name: string; nameAr?: string; description?: string }) => {
const response = await api.post('/admin/permission-groups', data);
return response.data.data;
},
update: async (id: string, data: Partial<{ name: string; nameAr: string; description: string; isActive: boolean }>) => {
const response = await api.put(`/admin/permission-groups/${id}`, data);
return response.data.data;
},
updatePermissions: async (
id: string,
permissions: Array<{ module: string; resource: string; actions: string[] }>
) => {
const response = await api.put(`/admin/permission-groups/${id}/permissions`, { permissions });
return response.data.data;
},
};
export const userRolesAPI = {
getAll: async (userId: string) => {
const response = await api.get(`/admin/users/${userId}/roles`);
return response.data.data || [];
},
assign: async (userId: string, roleId: string) => {
const response = await api.post(`/admin/users/${userId}/roles`, { roleId });
return response.data.data;
},
remove: async (userId: string, roleId: string) => {
await api.delete(`/admin/users/${userId}/roles/${roleId}`);
},
};
// Audit Logs API // Audit Logs API
export interface AuditLog { export interface AuditLog {
id: string; id: string;
@@ -260,7 +300,7 @@ export const auditLogsAPI = {
}, },
}; };
// System Settings API (placeholder) // System Settings API (placeholder - out of scope)
export interface SystemSetting { export interface SystemSetting {
key: string; key: string;
value: unknown; value: unknown;
@@ -279,7 +319,7 @@ export const settingsAPI = {
}, },
}; };
// System Health API (placeholder) // System Health API (placeholder - optional)
export interface SystemHealth { export interface SystemHealth {
status: string; status: string;
database: string; database: string;

View File

@@ -1,149 +1,5 @@
import { api } from '../api' import { api } from '../api'
const toNumber = (v: any): number => {
if (v === null || v === undefined || v === '') return 0
if (typeof v === 'number') return Number.isFinite(v) ? v : 0
const s = typeof v === 'string' ? v : (v?.toString?.() ?? String(v))
const cleaned = s.replace(/[^0-9.-]/g, '')
const n = Number(cleaned)
return Number.isFinite(n) ? n : 0
}
const cleanStr = (v: any) => {
if (v === null || v === undefined) return undefined
const s = String(v).trim()
return s === '' ? undefined : s
}
const cleanEmail = (v: any) => {
const s = cleanStr(v)
if (!s) return undefined
return s.replace(/\s+/g, '').replace(/\++$/g, '')
}
const parseDateToISO = (v: any) => {
const s = cleanStr(v)
if (!s) return undefined
// DD/MM/YYYY
const m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/)
if (m) {
const dd = m[1]
const mm = m[2]
const yyyy = m[3]
const d = new Date(`${yyyy}-${mm}-${dd}T00:00:00.000Z`)
return isNaN(d.getTime()) ? undefined : d.toISOString()
}
const d = new Date(s)
return isNaN(d.getTime()) ? undefined : d.toISOString()
}
const normalizeEmployeeFromApi = (e: any) => {
const basic = toNumber(e?.basicSalary ?? e?.baseSalary ?? e?.salary)
return {
...e,
baseSalary: basic,
salary: basic,
}
}
const normalizeEmployeeToApi = (data: any) => {
const d: any = { ...(data || {}) }
if (d.department?.id && !d.departmentId) d.departmentId = d.department.id
if (d.position?.id && !d.positionId) d.positionId = d.position.id
if (d.reportingTo?.id && !d.reportingToId) d.reportingToId = d.reportingTo.id
if (d.baseSalary !== undefined) {
d.basicSalary = toNumber(d.baseSalary)
} else if (d.salary !== undefined) {
d.basicSalary = toNumber(d.salary)
}
d.firstName = cleanStr(d.firstName)
d.lastName = cleanStr(d.lastName)
d.firstNameAr = cleanStr(d.firstNameAr)
d.lastNameAr = cleanStr(d.lastNameAr)
d.email = cleanEmail(d.email)
d.phone = cleanStr(d.phone)
d.mobile = cleanStr(d.mobile)
d.gender = cleanStr(d.gender)
d.nationality = cleanStr(d.nationality)
d.nationalId = cleanStr(d.nationalId)
d.passportNumber = cleanStr(d.passportNumber)
d.employmentType = cleanStr(d.employmentType)
d.contractType = cleanStr(d.contractType)
d.departmentId = cleanStr(d.departmentId)
d.positionId = cleanStr(d.positionId)
d.reportingToId = cleanStr(d.reportingToId)
d.currency = cleanStr(d.currency) ?? 'SAR'
d.status = cleanStr(d.status)
d.address = cleanStr(d.address)
d.city = cleanStr(d.city)
d.country = cleanStr(d.country)
d.terminationReason = cleanStr(d.terminationReason)
d.emergencyContactName = cleanStr(d.emergencyContactName)
d.emergencyContactPhone = cleanStr(d.emergencyContactPhone)
d.emergencyContactRelation = cleanStr(d.emergencyContactRelation)
d.hireDate = parseDateToISO(d.hireDate)
d.dateOfBirth = parseDateToISO(d.dateOfBirth)
d.endDate = parseDateToISO(d.endDate)
d.probationEndDate = parseDateToISO(d.probationEndDate)
d.terminationDate = parseDateToISO(d.terminationDate)
const allowed = [
'firstName',
'lastName',
'firstNameAr',
'lastNameAr',
'email',
'phone',
'mobile',
'dateOfBirth',
'gender',
'nationality',
'nationalId',
'passportNumber',
'employmentType',
'contractType',
'hireDate',
'endDate',
'probationEndDate',
'departmentId',
'positionId',
'reportingToId',
'basicSalary',
'currency',
'status',
'terminationDate',
'terminationReason',
'emergencyContactName',
'emergencyContactPhone',
'emergencyContactRelation',
'address',
'city',
'country',
'documents',
]
const payload: any = {}
for (const k of allowed) {
if (d[k] !== undefined) payload[k] = d[k]
}
return payload
}
export interface Employee { export interface Employee {
id: string id: string
uniqueEmployeeId: string uniqueEmployeeId: string
@@ -169,11 +25,7 @@ export interface Employee {
position?: any position?: any
reportingToId?: string reportingToId?: string
reportingTo?: any reportingTo?: any
baseSalary: number baseSalary: number
basicSalary?: any
currency?: string
status: string status: string
createdAt: string createdAt: string
updatedAt: string updatedAt: string
@@ -197,7 +49,6 @@ export interface CreateEmployeeData {
departmentId: string departmentId: string
positionId: string positionId: string
reportingToId?: string reportingToId?: string
baseSalary: number baseSalary: number
} }
@@ -221,6 +72,7 @@ export interface EmployeesResponse {
} }
export const employeesAPI = { export const employeesAPI = {
// Get all employees with filters and pagination
getAll: async (filters: EmployeeFilters = {}): Promise<EmployeesResponse> => { getAll: async (filters: EmployeeFilters = {}): Promise<EmployeesResponse> => {
const params = new URLSearchParams() const params = new URLSearchParams()
if (filters.search) params.append('search', filters.search) if (filters.search) params.append('search', filters.search)
@@ -232,11 +84,12 @@ export const employeesAPI = {
const response = await api.get(`/hr/employees?${params.toString()}`) const response = await api.get(`/hr/employees?${params.toString()}`)
const { data, pagination } = response.data const { data, pagination } = response.data
const employees = (data || []).map((e: any) => ({
const normalized = Array.isArray(data) ? data.map(normalizeEmployeeFromApi) : [] ...e,
baseSalary: e.baseSalary ?? e.basicSalary ?? 0,
}))
return { return {
employees: normalized, employees,
total: pagination?.total || 0, total: pagination?.total || 0,
page: pagination?.page || 1, page: pagination?.page || 1,
pageSize: pagination?.pageSize || 20, pageSize: pagination?.pageSize || 20,
@@ -244,35 +97,70 @@ export const employeesAPI = {
} }
}, },
// Get single employee by ID
getById: async (id: string): Promise<Employee> => { getById: async (id: string): Promise<Employee> => {
const response = await api.get(`/hr/employees/${id}`) const response = await api.get(`/hr/employees/${id}`)
return normalizeEmployeeFromApi(response.data.data) const e = response.data.data
return e ? { ...e, baseSalary: e.baseSalary ?? e.basicSalary ?? 0 } : e
}, },
// Create new employee
create: async (data: CreateEmployeeData): Promise<Employee> => { create: async (data: CreateEmployeeData): Promise<Employee> => {
const payload = normalizeEmployeeToApi(data) const response = await api.post('/hr/employees', data)
const response = await api.post('/hr/employees', payload) return response.data.data
return normalizeEmployeeFromApi(response.data.data)
}, },
// Update existing employee
update: async (id: string, data: UpdateEmployeeData): Promise<Employee> => { update: async (id: string, data: UpdateEmployeeData): Promise<Employee> => {
const payload = normalizeEmployeeToApi(data) const response = await api.put(`/hr/employees/${id}`, data)
const response = await api.put(`/hr/employees/${id}`, payload) return response.data.data
return normalizeEmployeeFromApi(response.data.data)
}, },
// Delete employee
delete: async (id: string): Promise<void> => { delete: async (id: string): Promise<void> => {
await api.delete(`/hr/employees/${id}`) await api.delete(`/hr/employees/${id}`)
} }
} }
// Departments API
export interface Department {
id: string
name: string
nameAr?: string | null
code: string
parentId?: string | null
parent?: { id: string; name: string; nameAr?: string | null }
description?: string | null
isActive?: boolean
children?: Department[]
employees?: any[]
positions?: any[]
_count?: { children: number; employees: number }
}
export const departmentsAPI = { export const departmentsAPI = {
getAll: async (): Promise<any[]> => { getAll: async (): Promise<any[]> => {
const response = await api.get('/hr/departments') const response = await api.get('/hr/departments')
return response.data.data return response.data.data
},
getHierarchy: async (): Promise<Department[]> => {
const response = await api.get('/hr/departments/hierarchy')
return response.data.data
},
create: async (data: { name: string; nameAr?: string; code: string; parentId?: string; description?: string }) => {
const response = await api.post('/hr/departments', data)
return response.data.data
},
update: async (id: string, data: Partial<{ name: string; nameAr: string; code: string; parentId: string | null; description: string; isActive: boolean }>) => {
const response = await api.put(`/hr/departments/${id}`, data)
return response.data.data
},
delete: async (id: string) => {
await api.delete(`/hr/departments/${id}`)
} }
} }
// Positions API
export const positionsAPI = { export const positionsAPI = {
getAll: async (): Promise<any[]> => { getAll: async (): Promise<any[]> => {
const response = await api.get('/hr/positions') const response = await api.get('/hr/positions')

View File

@@ -0,0 +1,76 @@
import { api } from '../api'
export const hrAdminAPI = {
// Leaves
getLeaves: async (params?: { employeeId?: string; status?: string; page?: number; pageSize?: number }) => {
const q = new URLSearchParams()
if (params?.employeeId) q.append('employeeId', params.employeeId)
if (params?.status) q.append('status', params.status)
if (params?.page) q.append('page', String(params.page))
if (params?.pageSize) q.append('pageSize', String(params.pageSize))
const res = await api.get(`/hr/leaves?${q}`)
return { leaves: res.data.data || [], pagination: res.data.pagination }
},
approveLeave: (id: string) => api.post(`/hr/leaves/${id}/approve`),
rejectLeave: (id: string, rejectedReason: string) => api.post(`/hr/leaves/${id}/reject`, { rejectedReason }),
// Loans
getLoans: async (params?: { employeeId?: string; status?: string; page?: number; pageSize?: number }) => {
const q = new URLSearchParams()
if (params?.employeeId) q.append('employeeId', params.employeeId)
if (params?.status) q.append('status', params.status)
if (params?.page) q.append('page', String(params.page))
if (params?.pageSize) q.append('pageSize', String(params.pageSize))
const res = await api.get(`/hr/loans?${q}`)
return { loans: res.data.data || [], pagination: res.data.pagination }
},
getLoanById: (id: string) => api.get(`/hr/loans/${id}`),
createLoan: (data: { employeeId: string; type: string; amount: number; installments?: number; reason?: string }) =>
api.post('/hr/loans', data),
approveLoan: (id: string, startDate?: string) => api.post(`/hr/loans/${id}/approve`, { startDate: startDate || new Date().toISOString().split('T')[0] }),
rejectLoan: (id: string, rejectedReason: string) => api.post(`/hr/loans/${id}/reject`, { rejectedReason }),
payInstallment: (loanId: string, installmentId: string, paidDate?: string) =>
api.post(`/hr/loans/${loanId}/pay-installment`, { installmentId, paidDate: paidDate || new Date().toISOString().split('T')[0] }),
// Purchase Requests
getPurchaseRequests: async (params?: { employeeId?: string; status?: string; page?: number; pageSize?: number }) => {
const q = new URLSearchParams()
if (params?.employeeId) q.append('employeeId', params.employeeId)
if (params?.status) q.append('status', params.status)
if (params?.page) q.append('page', String(params.page))
if (params?.pageSize) q.append('pageSize', String(params.pageSize))
const res = await api.get(`/hr/purchase-requests?${q}`)
return { purchaseRequests: res.data.data || [], pagination: res.data.pagination }
},
approvePurchaseRequest: (id: string) => api.post(`/hr/purchase-requests/${id}/approve`),
rejectPurchaseRequest: (id: string, rejectedReason: string) => api.post(`/hr/purchase-requests/${id}/reject`, { rejectedReason }),
// Leave Entitlements
getLeaveBalance: (employeeId: string, year?: number) => {
const q = year ? `?year=${year}` : ''
return api.get(`/hr/leave-balance/${employeeId}${q}`).then((r) => r.data.data)
},
getLeaveEntitlements: (params?: { employeeId?: string; year?: number }) => {
const q = new URLSearchParams()
if (params?.employeeId) q.append('employeeId', params.employeeId)
if (params?.year) q.append('year', String(params.year))
return api.get(`/hr/leave-entitlements?${q}`).then((r) => r.data.data)
},
upsertLeaveEntitlement: (data: { employeeId: string; year: number; leaveType: string; totalDays: number; carriedOver?: number; notes?: string }) =>
api.post('/hr/leave-entitlements', data),
// Employee Contracts
getContracts: async (params?: { employeeId?: string; status?: string; page?: number; pageSize?: number }) => {
const q = new URLSearchParams()
if (params?.employeeId) q.append('employeeId', params.employeeId)
if (params?.status) q.append('status', params.status)
if (params?.page) q.append('page', String(params.page))
if (params?.pageSize) q.append('pageSize', String(params.pageSize))
const res = await api.get(`/hr/contracts?${q}`)
return { contracts: res.data.data || [], pagination: res.data.pagination }
},
createContract: (data: { employeeId: string; type: string; startDate: string; endDate?: string; salary: number; documentUrl?: string; notes?: string }) =>
api.post('/hr/contracts', data),
updateContract: (id: string, data: Partial<{ type: string; endDate: string; salary: number; status: string; notes: string }>) =>
api.put(`/hr/contracts/${id}`, data),
}

View File

@@ -0,0 +1,163 @@
import { api } from '../api'
export interface PortalProfile {
employee: {
id: string
uniqueEmployeeId: string
firstName: string
lastName: string
firstNameAr?: string | null
lastNameAr?: string | null
email: string
department?: { name: string; nameAr?: string | null }
position?: { title: string; titleAr?: string | null }
}
stats: {
activeLoansCount: number
pendingLeavesCount: number
pendingPurchaseRequestsCount: number
leaveBalance: Array<{
leaveType: string
totalDays: number
carriedOver: number
usedDays: number
available: number
}>
}
}
export interface Loan {
id: string
loanNumber: string
type: string
amount: number
currency: string
installments: number
monthlyAmount?: number
reason?: string | null
status: string
approvedBy?: string | null
approvedAt?: string | null
rejectedReason?: string | null
startDate?: string | null
endDate?: string | null
createdAt: string
installmentsList?: Array<{
id: string
installmentNumber: number
dueDate: string
amount: number
paidDate?: string | null
status: string
}>
}
export interface Leave {
id: string
leaveType: string
startDate: string
endDate: string
days: number
reason?: string | null
status: string
approvedBy?: string | null
approvedAt?: string | null
rejectedReason?: string | null
createdAt: string
}
export interface PurchaseRequest {
id: string
requestNumber: string
items: any[]
totalAmount?: number | null
reason?: string | null
priority: string
status: string
approvedBy?: string | null
approvedAt?: string | null
rejectedReason?: string | null
createdAt: string
}
export interface Attendance {
id: string
date: string
checkIn?: string | null
checkOut?: string | null
workHours?: number | null
overtimeHours?: number | null
status: string
}
export interface Salary {
id: string
month: number
year: number
basicSalary: number
allowances: number
deductions: number
commissions: number
overtimePay: number
netSalary: number
status: string
paidDate?: string | null
createdAt: string
}
export const portalAPI = {
getMe: async (): Promise<PortalProfile> => {
const response = await api.get('/hr/portal/me')
return response.data.data
},
getLoans: async (): Promise<Loan[]> => {
const response = await api.get('/hr/portal/loans')
return response.data.data || []
},
submitLoanRequest: async (data: { type: string; amount: number; installments?: number; reason?: string }): Promise<Loan> => {
const response = await api.post('/hr/portal/loans', data)
return response.data.data
},
getLeaveBalance: async (year?: number): Promise<PortalProfile['stats']['leaveBalance']> => {
const params = year ? `?year=${year}` : ''
const response = await api.get(`/hr/portal/leave-balance${params}`)
return response.data.data || []
},
getLeaves: async (): Promise<Leave[]> => {
const response = await api.get('/hr/portal/leaves')
return response.data.data || []
},
submitLeaveRequest: async (data: { leaveType: string; startDate: string; endDate: string; reason?: string }): Promise<Leave> => {
const response = await api.post('/hr/portal/leaves', data)
return response.data.data
},
getPurchaseRequests: async (): Promise<PurchaseRequest[]> => {
const response = await api.get('/hr/portal/purchase-requests')
return response.data.data || []
},
submitPurchaseRequest: async (data: { items: Array<{ description: string; quantity?: number; estimatedPrice?: number }>; reason?: string; priority?: string }): Promise<PurchaseRequest> => {
const response = await api.post('/hr/portal/purchase-requests', data)
return response.data.data
},
getAttendance: async (month?: number, year?: number): Promise<Attendance[]> => {
const params = new URLSearchParams()
if (month) params.append('month', String(month))
if (year) params.append('year', String(year))
const query = params.toString() ? `?${params.toString()}` : ''
const response = await api.get(`/hr/portal/attendance${query}`)
return response.data.data || []
},
getSalaries: async (): Promise<Salary[]> => {
const response = await api.get('/hr/portal/salaries')
return response.data.data || []
},
}

68
package-lock.json generated
View File

@@ -1,14 +1,15 @@
{ {
"name": "mind14-crm", "name": "z-crm",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mind14-crm", "name": "z-crm",
"version": "1.0.0", "version": "1.0.0",
"license": "PROPRIETARY", "license": "PROPRIETARY",
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.58.2",
"concurrently": "^8.2.2" "concurrently": "^8.2.2"
} }
}, },
@@ -22,6 +23,22 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -175,6 +192,21 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-caller-file": { "node_modules/get-caller-file": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -212,6 +244,38 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",

View File

@@ -3,6 +3,9 @@
"version": "1.0.0", "version": "1.0.0",
"description": "Z.CRM - Enterprise CRM System - Contact Management, Sales, Inventory, Projects, HR, Marketing", "description": "Z.CRM - Enterprise CRM System - Contact Management, Sales, Inventory, Projects, HR, Marketing",
"scripts": { "scripts": {
"capture": "node scripts/capture-page.mjs",
"capture:admin": "node scripts/capture-page.mjs /admin",
"capture:contacts": "node scripts/capture-page.mjs /contacts",
"install-all": "npm install && cd backend && npm install && cd ../frontend && npm install", "install-all": "npm install && cd backend && npm install && cd ../frontend && npm install",
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"", "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
"dev:backend": "cd backend && npm run dev", "dev:backend": "cd backend && npm run dev",
@@ -12,11 +15,18 @@
"start:backend": "cd backend && npm start", "start:backend": "cd backend && npm start",
"start:frontend": "cd frontend && npm start" "start:frontend": "cd frontend && npm start"
}, },
"keywords": ["crm", "erp", "contact-management", "hr", "inventory", "projects"], "keywords": [
"crm",
"erp",
"contact-management",
"hr",
"inventory",
"projects"
],
"author": "مجموعة أتمتة", "author": "مجموعة أتمتة",
"license": "PROPRIETARY", "license": "PROPRIETARY",
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.58.2",
"concurrently": "^8.2.2" "concurrently": "^8.2.2"
} }
} }

29
scripts/backup-staging.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Backup staging database to /root/z_crm/backups/ on the server.
# Usage: SSHPASS=yourpassword ./scripts/backup-staging.sh
# Or with SSH keys: ./scripts/backup-staging.sh
#
# Creates backups/backup_YYYYMMDD_HHMMSS.sql on the staging server.
set -e
STAGING_HOST="${STAGING_HOST:-root@37.60.249.71}"
BACKUP_DIR="/root/z_crm/backups"
BACKUP_FILE="backup_$(date +%Y%m%d_%H%M%S).sql"
echo "Backing up staging database..."
echo "Host: $STAGING_HOST"
echo "Target: $BACKUP_DIR/$BACKUP_FILE"
echo ""
CMD="mkdir -p $BACKUP_DIR && cd /root/z_crm && docker compose exec -T postgres pg_dump -U postgres mind14_crm > $BACKUP_DIR/$BACKUP_FILE"
if [ -n "$SSHPASS" ]; then
sshpass -e ssh -o StrictHostKeyChecking=no "$STAGING_HOST" "$CMD"
else
ssh -o StrictHostKeyChecking=no "$STAGING_HOST" "$CMD"
fi
echo "Backup complete."
echo "File on server: $BACKUP_DIR/$BACKUP_FILE"
echo "To restore: docker compose exec -T postgres psql -U postgres mind14_crm < $BACKUP_DIR/$BACKUP_FILE"

76
scripts/capture-page.mjs Normal file
View File

@@ -0,0 +1,76 @@
#!/usr/bin/env node
/**
* Capture page screenshot using Playwright.
* Logs in and captures the dashboard (or specified path).
*
* Usage: node scripts/capture-page.mjs [path]
* path: optional, e.g. /dashboard, /admin, /contacts (default: /dashboard)
*
* Requires: npm install -D @playwright/test && npx playwright install chromium
*/
import { chromium } from 'playwright';
import { writeFileSync, mkdirSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BASE_URL = process.env.CAPTURE_BASE_URL || 'https://zerp.atmata-group.com';
const LOGIN_EMAIL = process.env.CAPTURE_EMAIL || 'admin@system.local';
const LOGIN_PASSWORD = process.env.CAPTURE_PASSWORD || 'Admin@123';
const OUTPUT_DIR = join(__dirname, '..', 'assets');
const DEFAULT_PATH = process.argv[2] || '/dashboard';
async function capture() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
ignoreHTTPSErrors: true,
});
try {
const page = await context.newPage();
// Navigate to login
console.log(`Navigating to ${BASE_URL}/login...`);
await page.goto(`${BASE_URL}/login`, { waitUntil: 'networkidle', timeout: 30000 });
// Login
console.log('Logging in...');
await page.fill('input[type="email"], input[name="email"]', LOGIN_EMAIL);
await page.fill('input[type="password"], input[name="password"]', LOGIN_PASSWORD);
await page.click('button[type="submit"], button:has-text("تسجيل الدخول"), button:has-text("Login")');
// Wait for redirect to dashboard
await page.waitForURL(/\/(dashboard|admin|$)/, { timeout: 15000 }).catch(() => {});
// Navigate to target path if not already there
const targetUrl = `${BASE_URL}${DEFAULT_PATH.startsWith('/') ? '' : '/'}${DEFAULT_PATH}`;
if (!page.url().includes(DEFAULT_PATH)) {
console.log(`Navigating to ${targetUrl}...`);
await page.goto(targetUrl, { waitUntil: 'networkidle', timeout: 15000 });
}
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000); // Let any async content render
mkdirSync(OUTPUT_DIR, { recursive: true });
const filename = `capture-${DEFAULT_PATH.replace(/\//g, '-') || 'dashboard'}-${Date.now()}.png`;
const filepath = join(OUTPUT_DIR, filename);
await page.screenshot({ path: filepath, fullPage: true });
console.log(`Screenshot saved: ${filepath}`);
// Also save a fixed name for easy reference
const latestPath = join(OUTPUT_DIR, 'capture-latest.png');
await page.screenshot({ path: latestPath, fullPage: true });
console.log(`Latest screenshot: ${latestPath}`);
} finally {
await browser.close();
}
}
capture().catch((err) => {
console.error('Capture failed:', err);
process.exit(1);
});