Merge branch 'master' of https://git.atmata-group.com/ATMATA/zerp
BIN
assets/capture--admin-1771758081055.png
Normal file
|
After Width: | Height: | Size: 233 KiB |
BIN
assets/capture--dashboard-1771758051783.png
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
assets/capture--dashboard-1771758179639.png
Normal file
|
After Width: | Height: | Size: 328 KiB |
BIN
assets/capture--dashboard-1771759025109.png
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
assets/capture-latest.png
Normal file
|
After Width: | Height: | Size: 300 KiB |
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
12
backend/prisma/ensure-gm-permissions.sql
Normal 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 = '*'
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -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();
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
51
backend/scripts/ensure-gm-permissions.ts
Normal 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());
|
||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
backend/src/modules/dashboard/dashboard.controller.ts
Normal 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();
|
||||||
9
backend/src/modules/dashboard/dashboard.routes.ts
Normal 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;
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
106
backend/src/modules/hr/portal.controller.ts
Normal 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();
|
||||||
114
backend/src/modules/hr/portal.service.ts
Normal 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();
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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> = {
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
إلغاء
|
إلغاء
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 |
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
94
frontend/src/app/portal/attendance/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
frontend/src/app/portal/layout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
189
frontend/src/app/portal/leave/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
179
frontend/src/app/portal/loans/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
158
frontend/src/app/portal/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
210
frontend/src/app/portal/purchase-requests/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
65
frontend/src/app/portal/salaries/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
116
frontend/src/components/hr/OrgChart.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
76
frontend/src/lib/api/hrAdmin.ts
Normal 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),
|
||||||
|
}
|
||||||
163
frontend/src/lib/api/portal.ts
Normal 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
@@ -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",
|
||||||
|
|||||||
14
package.json
@@ -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
@@ -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
@@ -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);
|
||||||
|
});
|
||||||