Compare commits

..

31 Commits

Author SHA1 Message Date
Aya
bda70feb18 edit contact form 2026-04-12 16:52:44 +03:00
Aya
13f2214df5 Rebuild lock file clean 2026-04-07 11:16:12 +03:00
Aya
04cc054be1 Fix swc helpers mismatch 2026-04-07 11:06:22 +03:00
Aya
8954e9c1dd Fix package lock sync for deploy 2026-04-07 10:38:01 +03:00
yotakii
7b73053d80 Merge branch 'master' of https://git.atmata-group.com/ATMATA/zerp 2026-04-01 15:52:41 +03:00
yotakii
f101989047 updates for contacts & tenders Modules 2026-04-01 15:50:21 +03:00
Talal Sharabi
03312c3769 docs: align developer staging guide with rsync + Docker workflow
Made-with: Cursor
2026-04-01 11:26:29 +04:00
Talal Sharabi
1014d88313 docs: add staging Docker deploy guide for remote developers
Made-with: Cursor
2026-04-01 11:23:35 +04:00
yotakii
278d8f6982 update HR modules 2026-04-01 10:17:38 +03:00
yotakii
94d651c29e Merge branch 'master' of https://git.atmata-group.com/ATMATA/zerp 2026-03-30 13:23:23 +03:00
yotakii
3e8985ffe0 expand contant types 2026-03-30 13:22:14 +03:00
Talal Sharabi
3fbe607ed7 fix(dashboard): bundle header logo with next/image for reliable staging
Made-with: Cursor
2026-03-30 11:58:07 +04:00
Talal Sharabi
14d2597722 fix(frontend): use logo.png filename so dashboard header image loads
Made-with: Cursor
2026-03-30 11:50:40 +04:00
Talal Sharabi
45c43ab526 fix(docker): default DATABASE_URL when unset so Compose works without .env
Made-with: Cursor
2026-03-30 11:45:44 +04:00
yotakii
78aa7c0fb5 add logo png 2026-03-29 16:15:56 +03:00
yotakii
005edf2b69 Merge branch 'master' of https://git.atmata-group.com/ATMATA/zerp 2026-03-26 15:10:51 +03:00
yotakii
5f7e9e517f solve prisma connection 2026-03-26 15:07:06 +03:00
Talal Sharabi
4043f3bd6c chore(db): add idempotent SQL to backfill tenders position_permissions
Made-with: Cursor
2026-03-26 11:07:03 +04:00
yotakii
3fd62ba0ad some editing 2026-03-25 12:04:27 +03:00
yotakii
ba33072d95 rebrand 2026-03-16 13:01:30 +03:00
Talal Sharabi
03dea2e52b fix: rename map callback param to avoid shadowing translation t
Made-with: Cursor
2026-03-12 15:36:44 +04:00
Talal Sharabi
854a42980d fix: CORS for localhost:3000, login with username or email, favicon
Made-with: Cursor
2026-03-12 15:35:36 +04:00
Talal Sharabi
4c139429e2 feat(tenders): add Tender Management module (SRS, backend, frontend)
- SRS document: docs/SRS_TENDER_MANAGEMENT.md
- Prisma: Tender, TenderDirective models; Deal.sourceTenderId; Attachment.tenderId/tenderDirectiveId
- Backend: tenders module (CRUD, duplicate check, directives, notifications, file upload, convert-to-deal)
- Frontend: tenders list, detail, create/edit forms, directives, convert to deal, i18n (en/ar), dashboard card
- Seed: tenders permissions for admin and sales positions
- Auth: admin.service findFirst for email check (Prisma compatibility)

Made-with: Cursor
2026-03-11 16:57:40 +04:00
Talal Sharabi
18c13cdf7c feat(crm): add contracts, cost sheets, invoices modules and API clients
Made-with: Cursor
2026-03-11 16:40:25 +04:00
yotakii
8a20927044 add atmata brand 2026-03-10 11:54:46 +03:00
yotakii
6d82c5007c fix login 2026-03-05 12:16:29 +03:00
yotakii
625bc26a05 Merge branch 'master' of https://git.atmata-group.com/ATMATA/zerp 2026-03-05 11:57:04 +03:00
yotakii
8365f4da2d fix login error 2026-03-05 11:54:14 +03:00
Talal Sharabi
72ed9a2ff5 feat(hr): Complete HR module with Employee Portal, Loans, Leave, Purchase Requests, Contracts
- Database: Add Loan, LoanInstallment, PurchaseRequest, LeaveEntitlement, EmployeeContract models
- Database: Extend Attendance with ZK Tico fields (sourceDeviceId, externalId, rawData)
- Database: Add Employee.attendancePin for device mapping
- Backend: HR admin - Loans, Purchase Requests, Leave entitlements, Employee contracts CRUD
- Backend: Leave reject, bulk attendance sync (ZK Tico ready)
- Backend: Employee Portal API - scoped by employeeId (loans, leaves, purchase-requests, attendance, salaries)
- Frontend: Employee Portal - dashboard, loans, leave, purchase-requests, attendance, salaries
- Frontend: HR Admin - new tabs for Leaves, Loans, Purchase Requests, Contracts (approve/reject)
- Dashboard: Add My Portal link
- No destructive schema changes; additive migrations only

Made-with: Cursor
2026-03-04 19:44:09 +04:00
Talal Sharabi
ae890ca1c5 Merge origin/master: resolve conflicts, keep RBAC and fixes
Made-with: Cursor
2026-03-04 19:31:34 +04:00
Talal Sharabi
8edeaf10f5 RBAC: Phase 1-3, Total Salary fix, employee creation fix, permission groups, backup script
Made-with: Cursor
2026-03-04 19:31:08 +04:00
103 changed files with 13134 additions and 2487 deletions

View File

@@ -6,3 +6,6 @@ JWT_SECRET=your-super-secure-jwt-secret-change-this-now-2024
# Domain
DOMAIN=zerp.atmata-group.com
# Prisma / Database pooling
DATABASE_URL=postgresql://postgres:SecurePassword123!ChangeMe@postgres:5432/mind14_crm?schema=public&connection_limit=5&pool_timeout=20&connect_timeout=20

174
DEVELOPER_STAGING_DEPLOY.md Normal file
View File

@@ -0,0 +1,174 @@
# Staging deploy guide (for developers)
This is the **same workflow** the team uses for staging: work locally, push to Git, **sync your project folder to the server with rsync**, then **rebuild Docker on the server**. Pushing to Git alone does not update the live staging site.
**Staging URL:** https://zerp.atmata-group.com/
**Server project path:** `/root/z_crm` (confirm with your lead if different)
---
## 1. What you need from your lead
| Item | Purpose |
|------|---------|
| **Staging server address** (IP or hostname) | For SSH and rsync |
| **SSH login** (e.g. `root` or another user) | Remote shell and file sync |
| **SSH key or password** | Prefer **SSH keys** (`ssh-copy-id`) so you are not typing a password on every deploy |
You do **not** need Node.js **on the server**. Docker runs `npm ci` and `npm run build` inside the containers.
---
## 2. One-time setup on your laptop
1. **Clone** the same Git repo you commit to, and use that folder as your working copy.
2. **Test SSH:**
```bash
ssh YOUR_USER@YOUR_SERVER_HOST
```
Exit with `exit` once you see a shell.
3. **Install rsync** if needed (macOS and most Linux distros include it).
---
## 3. Standard deploy (every time you want staging updated)
Run these from your **project root** (the folder that contains `frontend/`, `backend/`, and `docker-compose.yml`).
### Step 1 — Get the code you want on Git
```bash
git pull origin master
```
Resolve any conflicts, then make sure your changes are **committed and pushed**:
```bash
git push origin master
```
(Use your teams branch name if staging tracks something other than `master`.)
### Step 2 — Build locally (recommended)
Catches errors before you touch the server:
```bash
cd frontend && npm run build && cd ../backend && npm run build && cd ..
```
If either build fails, fix the problem and repeat before continuing.
### Step 3 — Sync files to the server (rsync)
From the **project root**:
```bash
rsync -avz --delete \
--exclude 'node_modules' \
--exclude '.git' \
--exclude '.next' \
--exclude '.env' \
./ YOUR_USER@YOUR_SERVER_HOST:/root/z_crm/
```
- **`./`** — trailing slash means “contents of this folder”; destination is `/root/z_crm/`.
- **`--delete`** — removes files on the server that no longer exist in your tree (keeps server tree aligned with yours). **Excluded paths are not deleted** from the server by default when you use `--exclude` (rsync does not remove excluded remote files unless you add `--delete-excluded`, which you should **not** use here).
- **`.env` must stay excluded** so you do **not** overwrite the servers database URL and secrets. The server keeps its existing `/root/z_crm/.env`.
Replace `YOUR_USER` and `YOUR_SERVER_HOST` with what your lead gave you.
### Step 4 — Rebuild Docker on the server
Either open an SSH session and run the commands, or run them in one shot:
**Option A — SSH in, then commands**
```bash
ssh YOUR_USER@YOUR_SERVER_HOST
cd /root/z_crm
docker-compose down
docker-compose build --no-cache frontend backend
docker-compose up -d
docker-compose ps
```
**Option B — One line from your laptop**
```bash
ssh YOUR_USER@YOUR_SERVER_HOST 'cd /root/z_crm && docker-compose down && docker-compose build --no-cache frontend backend && docker-compose up -d && docker-compose ps'
```
You should see `zerp_postgres` (healthy), `zerp_backend`, and `zerp_frontend` up.
### Step 5 — Quick check
Open https://zerp.atmata-group.com/ and do a hard refresh if the UI looks cached (`Cmd+Shift+R` / `Ctrl+Shift+R`).
If the API or login fails:
```bash
ssh YOUR_USER@YOUR_SERVER_HOST 'cd /root/z_crm && docker-compose logs backend --tail 80'
```
---
## 4. If the backend will not start (database / Prisma)
The server relies on **`/root/z_crm/.env`** (not in Git) for `DATABASE_URL` and related settings. If that file is missing or wrong, ask your lead to fix or restore it **once**; your normal deploy should **never** sync your local `.env` over it (keep the `--exclude '.env'` on rsync).
---
## 5. Database migrations
The backend container runs **`npx prisma migrate deploy`** when it starts. After you deploy code that includes new Prisma migrations, a normal **`docker-compose up -d`** after rebuild applies them automatically.
---
## 6. Optional: rebuild all services (slower)
Usually you only need **frontend** and **backend** images. To rebuild everything defined in `docker-compose.yml`:
```bash
ssh YOUR_USER@YOUR_SERVER_HOST 'cd /root/z_crm && docker-compose down && docker-compose build --no-cache && docker-compose up -d'
```
Postgres **data** stays on the Docker volume unless someone removes volumes on purpose.
---
## 7. Alternative: Git pull **on** the server (only if `.git` exists there)
Some teams keep a full clone on the server. If `/root/z_crm/.git` exists **and** the server can reach your Git remote, you can skip rsync and run:
```bash
ssh YOUR_USER@YOUR_SERVER_HOST
cd /root/z_crm
git pull origin master
docker-compose down
docker-compose build --no-cache frontend backend
docker-compose up -d
```
If there is **no** `.git` on the server (typical when deploys have always been rsync), use **section 3** only.
---
## 8. Copy-paste cheat sheet (replace user/host)
```bash
# From project root, after git push:
cd frontend && npm run build && cd ../backend && npm run build && cd ..
rsync -avz --delete \
--exclude 'node_modules' \
--exclude '.git' \
--exclude '.next' \
--exclude '.env' \
./ YOUR_USER@YOUR_SERVER_HOST:/root/z_crm/
ssh YOUR_USER@YOUR_SERVER_HOST 'cd /root/z_crm && docker-compose down && docker-compose build --no-cache frontend backend && docker-compose up -d && docker-compose ps'
```
This matches the workflow: **local code → Git → rsync to server → Docker rebuild**.

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

BIN
assets/capture-latest.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

View File

@@ -2909,7 +2909,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,

View File

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

View File

@@ -0,0 +1,28 @@
-- Add tenders module permissions to all positions that have crm or admin access.
-- Safe to run multiple times (skips if already exists).
-- Run on server: docker-compose exec -T postgres psql -U postgres -d mind14_crm -f - < backend/prisma/add-tenders-permissions.sql
-- Or from backend: npx prisma db execute --file prisma/add-tenders-permissions.sql
-- Tenders resource: read, create, update, delete
INSERT INTO position_permissions (id, "positionId", module, resource, actions, "createdAt", "updatedAt")
SELECT gen_random_uuid(), sub."positionId", 'tenders', 'tenders', '["read","create","update","delete"]'::jsonb, NOW(), NOW()
FROM (
SELECT DISTINCT pp."positionId" FROM position_permissions pp
WHERE pp.module IN ('crm', 'admin')
) sub
WHERE NOT EXISTS (
SELECT 1 FROM position_permissions pp2
WHERE pp2."positionId" = sub."positionId" AND pp2.module = 'tenders' AND pp2.resource = 'tenders'
);
-- Directives resource: read, create, update
INSERT INTO position_permissions (id, "positionId", module, resource, actions, "createdAt", "updatedAt")
SELECT gen_random_uuid(), sub."positionId", 'tenders', 'directives', '["read","create","update"]'::jsonb, NOW(), NOW()
FROM (
SELECT DISTINCT pp."positionId" FROM position_permissions pp
WHERE pp.module IN ('crm', 'admin')
) sub
WHERE NOT EXISTS (
SELECT 1 FROM position_permissions pp2
WHERE pp2."positionId" = sub."positionId" AND pp2.module = 'tenders' AND pp2.resource = 'directives'
);

View File

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

View File

@@ -0,0 +1,12 @@
-- Ensure GM has all module permissions
-- Run: npx prisma db execute --file prisma/ensure-gm-permissions.sql
INSERT INTO position_permissions (id, "positionId", module, resource, actions, "createdAt", "updatedAt")
SELECT gen_random_uuid(), p.id, m.module, '*', '["*"]', NOW(), NOW()
FROM positions p
CROSS JOIN (VALUES ('contacts'), ('crm'), ('inventory'), ('projects'), ('hr'), ('marketing'), ('admin')) AS m(module)
WHERE p.code = 'GM'
AND NOT EXISTS (
SELECT 1 FROM position_permissions pp
WHERE pp."positionId" = p.id AND pp.module = m.module AND pp.resource = '*'
);

View File

@@ -0,0 +1,87 @@
-- CreateTable
CREATE TABLE "tenders" (
"id" TEXT NOT NULL,
"tenderNumber" TEXT NOT NULL,
"issuingBodyName" TEXT NOT NULL,
"title" TEXT NOT NULL,
"termsValue" DECIMAL(15,2) NOT NULL,
"bondValue" DECIMAL(15,2) NOT NULL,
"announcementDate" DATE NOT NULL,
"closingDate" DATE NOT NULL,
"announcementLink" TEXT,
"source" TEXT NOT NULL,
"sourceOther" TEXT,
"announcementType" TEXT NOT NULL,
"notes" TEXT,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"contactId" TEXT,
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tenders_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tender_directives" (
"id" TEXT NOT NULL,
"tenderId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"notes" TEXT,
"assignedToEmployeeId" TEXT NOT NULL,
"issuedById" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"completedAt" TIMESTAMP(3),
"completionNotes" TEXT,
"completedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tender_directives_pkey" PRIMARY KEY ("id")
);
-- AlterTable
ALTER TABLE "attachments" ADD COLUMN "tenderId" TEXT;
ALTER TABLE "attachments" ADD COLUMN "tenderDirectiveId" TEXT;
-- AlterTable
ALTER TABLE "deals" ADD COLUMN "sourceTenderId" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "tenders_tenderNumber_key" ON "tenders"("tenderNumber");
-- CreateIndex
CREATE INDEX "tenders_tenderNumber_idx" ON "tenders"("tenderNumber");
CREATE INDEX "tenders_status_idx" ON "tenders"("status");
CREATE INDEX "tenders_createdById_idx" ON "tenders"("createdById");
CREATE INDEX "tenders_announcementDate_idx" ON "tenders"("announcementDate");
CREATE INDEX "tenders_closingDate_idx" ON "tenders"("closingDate");
-- CreateIndex
CREATE INDEX "tender_directives_tenderId_idx" ON "tender_directives"("tenderId");
CREATE INDEX "tender_directives_assignedToEmployeeId_idx" ON "tender_directives"("assignedToEmployeeId");
CREATE INDEX "tender_directives_status_idx" ON "tender_directives"("status");
-- CreateIndex
CREATE UNIQUE INDEX "deals_sourceTenderId_key" ON "deals"("sourceTenderId");
-- CreateIndex
CREATE INDEX "attachments_tenderId_idx" ON "attachments"("tenderId");
CREATE INDEX "attachments_tenderDirectiveId_idx" ON "attachments"("tenderDirectiveId");
-- AddForeignKey
ALTER TABLE "tenders" ADD CONSTRAINT "tenders_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "contacts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "tenders" ADD CONSTRAINT "tenders_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tender_directives" ADD CONSTRAINT "tender_directives_tenderId_fkey" FOREIGN KEY ("tenderId") REFERENCES "tenders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "tender_directives" ADD CONSTRAINT "tender_directives_assignedToEmployeeId_fkey" FOREIGN KEY ("assignedToEmployeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "tender_directives" ADD CONSTRAINT "tender_directives_issuedById_fkey" FOREIGN KEY ("issuedById") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "tender_directives" ADD CONSTRAINT "tender_directives_completedById_fkey" FOREIGN KEY ("completedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "deals" ADD CONSTRAINT "deals_sourceTenderId_fkey" FOREIGN KEY ("sourceTenderId") REFERENCES "tenders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_tenderId_fkey" FOREIGN KEY ("tenderId") REFERENCES "tenders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_tenderDirectiveId_fkey" FOREIGN KEY ("tenderDirectiveId") REFERENCES "tender_directives"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,131 @@
-- AlterTable: Add attendancePin to employees
ALTER TABLE "employees" ADD COLUMN IF NOT EXISTS "attendancePin" TEXT;
-- CreateIndex (unique) on attendancePin - only if column added
CREATE UNIQUE INDEX IF NOT EXISTS "employees_attendancePin_key" ON "employees"("attendancePin");
-- AlterTable: Add ZK Tico fields to attendances
ALTER TABLE "attendances" ADD COLUMN IF NOT EXISTS "sourceDeviceId" TEXT;
ALTER TABLE "attendances" ADD COLUMN IF NOT EXISTS "externalId" TEXT;
ALTER TABLE "attendances" ADD COLUMN IF NOT EXISTS "rawData" JSONB;
-- CreateIndex on sourceDeviceId
CREATE INDEX IF NOT EXISTS "attendances_sourceDeviceId_idx" ON "attendances"("sourceDeviceId");
-- CreateTable: loans
CREATE TABLE "loans" (
"id" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"loanNumber" TEXT NOT NULL,
"type" TEXT NOT NULL,
"amount" DECIMAL(12,2) NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'SAR',
"installments" INTEGER NOT NULL DEFAULT 1,
"monthlyAmount" DECIMAL(12,2),
"reason" TEXT,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"approvedBy" TEXT,
"approvedAt" TIMESTAMP(3),
"rejectedReason" TEXT,
"startDate" DATE,
"endDate" DATE,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "loans_pkey" PRIMARY KEY ("id")
);
-- CreateTable: loan_installments
CREATE TABLE "loan_installments" (
"id" TEXT NOT NULL,
"loanId" TEXT NOT NULL,
"installmentNumber" INTEGER NOT NULL,
"dueDate" DATE NOT NULL,
"amount" DECIMAL(12,2) NOT NULL,
"paidDate" DATE,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "loan_installments_pkey" PRIMARY KEY ("id")
);
-- CreateTable: purchase_requests
CREATE TABLE "purchase_requests" (
"id" TEXT NOT NULL,
"requestNumber" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"items" JSONB NOT NULL,
"totalAmount" DECIMAL(12,2),
"reason" TEXT,
"priority" TEXT NOT NULL DEFAULT 'NORMAL',
"status" TEXT NOT NULL DEFAULT 'PENDING',
"approvedBy" TEXT,
"approvedAt" TIMESTAMP(3),
"rejectedReason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "purchase_requests_pkey" PRIMARY KEY ("id")
);
-- CreateTable: leave_entitlements
CREATE TABLE "leave_entitlements" (
"id" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"year" INTEGER NOT NULL,
"leaveType" TEXT NOT NULL,
"totalDays" INTEGER NOT NULL DEFAULT 0,
"usedDays" INTEGER NOT NULL DEFAULT 0,
"carriedOver" INTEGER NOT NULL DEFAULT 0,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "leave_entitlements_pkey" PRIMARY KEY ("id")
);
-- CreateTable: employee_contracts
CREATE TABLE "employee_contracts" (
"id" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"contractNumber" TEXT NOT NULL,
"type" TEXT NOT NULL,
"startDate" DATE NOT NULL,
"endDate" DATE,
"salary" DECIMAL(12,2) NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'SAR',
"documentUrl" TEXT,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "employee_contracts_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "loans_loanNumber_key" ON "loans"("loanNumber");
CREATE INDEX "loans_employeeId_idx" ON "loans"("employeeId");
CREATE INDEX "loans_status_idx" ON "loans"("status");
CREATE UNIQUE INDEX "loan_installments_loanId_installmentNumber_key" ON "loan_installments"("loanId", "installmentNumber");
CREATE INDEX "loan_installments_loanId_idx" ON "loan_installments"("loanId");
CREATE UNIQUE INDEX "purchase_requests_requestNumber_key" ON "purchase_requests"("requestNumber");
CREATE INDEX "purchase_requests_employeeId_idx" ON "purchase_requests"("employeeId");
CREATE INDEX "purchase_requests_status_idx" ON "purchase_requests"("status");
CREATE UNIQUE INDEX "leave_entitlements_employeeId_year_leaveType_key" ON "leave_entitlements"("employeeId", "year", "leaveType");
CREATE INDEX "leave_entitlements_employeeId_idx" ON "leave_entitlements"("employeeId");
CREATE UNIQUE INDEX "employee_contracts_contractNumber_key" ON "employee_contracts"("contractNumber");
CREATE INDEX "employee_contracts_employeeId_idx" ON "employee_contracts"("employeeId");
CREATE INDEX "employee_contracts_status_idx" ON "employee_contracts"("status");
-- AddForeignKey
ALTER TABLE "loans" ADD CONSTRAINT "loans_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "loan_installments" ADD CONSTRAINT "loan_installments_loanId_fkey" FOREIGN KEY ("loanId") REFERENCES "loans"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "purchase_requests" ADD CONSTRAINT "purchase_requests_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "leave_entitlements" ADD CONSTRAINT "leave_entitlements_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "employee_contracts" ADD CONSTRAINT "employee_contracts_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,59 @@
-- CreateTable
CREATE TABLE "roles" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"nameAr" TEXT,
"description" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "role_permissions" (
"id" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"module" TEXT NOT NULL,
"resource" TEXT NOT NULL,
"actions" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_roles" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_roles_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "roles_name_key" ON "roles"("name");
-- CreateIndex
CREATE UNIQUE INDEX "role_permissions_roleId_module_resource_key" ON "role_permissions"("roleId", "module", "resource");
-- CreateIndex
CREATE INDEX "user_roles_userId_idx" ON "user_roles"("userId");
-- CreateIndex
CREATE INDEX "user_roles_roleId_idx" ON "user_roles"("roleId");
-- CreateIndex
CREATE UNIQUE INDEX "user_roles_userId_roleId_key" ON "user_roles"("userId", "roleId");
-- AddForeignKey
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -42,7 +42,7 @@ model AuditLog {
model User {
id String @id @default(uuid())
email String @unique
email String
username String @unique
password String
isActive Boolean @default(true)
@@ -69,10 +69,62 @@ model User {
assignedTasks Task[]
projectMembers ProjectMember[]
campaigns Campaign[]
userRoles UserRole[]
tendersCreated Tender[]
tenderDirectivesIssued TenderDirective[]
tenderDirectivesCompleted TenderDirective[] @relation("TenderDirectiveCompletedBy")
@@map("users")
}
// Optional roles - user can belong to multiple permission groups (Phase 3 multi-group)
model Role {
id String @id @default(uuid())
name String @unique
nameAr String?
description String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
permissions RolePermission[]
userRoles UserRole[]
@@map("roles")
}
model RolePermission {
id String @id @default(uuid())
roleId String
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
module String
resource String
actions Json // ["read", "create", "update", "delete", ...]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([roleId, module, resource])
@@map("role_permissions")
}
model UserRole {
id String @id @default(uuid())
userId String
roleId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([userId, roleId])
@@index([userId])
@@index([roleId])
@@map("user_roles")
}
model Employee {
id String @id @default(uuid())
uniqueEmployeeId String @unique // رقم الموظف الموحد
@@ -129,6 +181,9 @@ model Employee {
// Documents
documents Json? // Array of document references
// ZK Tico / Attendance device - maps to employee pin on device
attendancePin String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -143,6 +198,11 @@ model Employee {
disciplinaryActions DisciplinaryAction[]
allowances Allowance[]
commissions Commission[]
loans Loan[]
purchaseRequests PurchaseRequest[]
leaveEntitlements LeaveEntitlement[]
employeeContracts EmployeeContract[]
tenderDirectivesAssigned TenderDirective[]
@@index([departmentId])
@@index([positionId])
@@ -221,12 +281,18 @@ model Attendance {
status String // PRESENT, ABSENT, LATE, HALF_DAY, etc.
notes String?
// ZK Tico / External device sync
sourceDeviceId String?
externalId String?
rawData Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([employeeId, date])
@@index([employeeId])
@@index([date])
@@index([sourceDeviceId])
@@map("attendances")
}
@@ -369,6 +435,115 @@ model DisciplinaryAction {
@@map("disciplinary_actions")
}
model Loan {
id String @id @default(uuid())
employeeId String
employee Employee @relation(fields: [employeeId], references: [id])
loanNumber String @unique
type String // SALARY_ADVANCE, EQUIPMENT, PERSONAL, etc.
amount Decimal @db.Decimal(12, 2)
currency String @default("SAR")
installments Int @default(1)
monthlyAmount Decimal? @db.Decimal(12, 2)
reason String?
status String @default("PENDING") // PENDING, APPROVED, REJECTED, ACTIVE, PAID_OFF
approvedBy String?
approvedAt DateTime?
rejectedReason String?
startDate DateTime? @db.Date
endDate DateTime? @db.Date
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
installmentsList LoanInstallment[]
@@index([employeeId])
@@index([status])
@@map("loans")
}
model LoanInstallment {
id String @id @default(uuid())
loanId String
loan Loan @relation(fields: [loanId], references: [id], onDelete: Cascade)
installmentNumber Int
dueDate DateTime @db.Date
amount Decimal @db.Decimal(12, 2)
paidDate DateTime? @db.Date
status String @default("PENDING") // PENDING, PAID, OVERDUE
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([loanId, installmentNumber])
@@index([loanId])
@@map("loan_installments")
}
model PurchaseRequest {
id String @id @default(uuid())
requestNumber String @unique
employeeId String
employee Employee @relation(fields: [employeeId], references: [id])
items Json // Array of { description, quantity, estimatedPrice, etc. }
totalAmount Decimal? @db.Decimal(12, 2)
reason String?
priority String @default("NORMAL") // LOW, NORMAL, HIGH, URGENT
status String @default("PENDING") // PENDING, APPROVED, REJECTED, ORDERED
approvedBy String?
approvedAt DateTime?
rejectedReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([employeeId])
@@index([status])
@@map("purchase_requests")
}
model LeaveEntitlement {
id String @id @default(uuid())
employeeId String
employee Employee @relation(fields: [employeeId], references: [id])
year Int
leaveType String // ANNUAL, SICK, EMERGENCY, etc.
totalDays Int @default(0)
usedDays Int @default(0)
carriedOver Int @default(0)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([employeeId, year, leaveType])
@@index([employeeId])
@@map("leave_entitlements")
}
model EmployeeContract {
id String @id @default(uuid())
employeeId String
employee Employee @relation(fields: [employeeId], references: [id])
contractNumber String @unique
type String // FIXED, UNLIMITED, PROBATION, etc.
startDate DateTime @db.Date
endDate DateTime? @db.Date
salary Decimal @db.Decimal(12, 2)
currency String @default("SAR")
documentUrl String?
status String @default("ACTIVE") // DRAFT, ACTIVE, EXPIRED, TERMINATED
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([employeeId])
@@index([status])
@@map("employee_contracts")
}
// ============================================
// MODULE 1: CONTACT MANAGEMENT
// ============================================
@@ -439,6 +614,7 @@ model Contact {
deals Deal[]
attachments Attachment[]
notes Note[]
tenders Tender[]
@@index([type])
@@index([status])
@@ -535,6 +711,10 @@ model Deal {
// Status
status String @default("ACTIVE") // ACTIVE, WON, LOST, CANCELLED, ARCHIVED
// Source (when converted from Tender)
sourceTenderId String? @unique
sourceTender Tender? @relation(fields: [sourceTenderId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -702,6 +882,66 @@ model Invoice {
@@map("invoices")
}
// ============================================
// TENDER MANAGEMENT - إدارة المناقصات
// ============================================
model Tender {
id String @id @default(uuid())
tenderNumber String @unique
issuingBodyName String
title String
termsValue Decimal @db.Decimal(15, 2)
bondValue Decimal @db.Decimal(15, 2)
announcementDate DateTime @db.Date
closingDate DateTime @db.Date
announcementLink String?
source String // GOVERNMENT_SITE, OFFICIAL_GAZETTE, PERSONAL, PARTNER, WHATSAPP_TELEGRAM, PORTAL, EMAIL, MANUAL
sourceOther String? // Free text when source is MANUAL or other
announcementType String // FIRST, RE_ANNOUNCEMENT_2, RE_ANNOUNCEMENT_3, RE_ANNOUNCEMENT_4
notes String?
status String @default("ACTIVE") // ACTIVE, CONVERTED_TO_DEAL, CANCELLED
contactId String? // Optional link to Contact (issuing body)
contact Contact? @relation(fields: [contactId], references: [id])
createdById String
createdBy User @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
directives TenderDirective[]
attachments Attachment[]
convertedDeal Deal?
@@index([tenderNumber])
@@index([status])
@@index([createdById])
@@index([announcementDate])
@@index([closingDate])
@@map("tenders")
}
model TenderDirective {
id String @id @default(uuid())
tenderId String
tender Tender @relation(fields: [tenderId], references: [id], onDelete: Cascade)
type String // BUY_TERMS, VISIT_CLIENT, MEET_COMMITTEE, PREPARE_TO_BID
notes String?
assignedToEmployeeId String
assignedToEmployee Employee @relation(fields: [assignedToEmployeeId], references: [id])
issuedById String
issuedBy User @relation(fields: [issuedById], references: [id])
status String @default("PENDING") // PENDING, IN_PROGRESS, COMPLETED, CANCELLED
completedAt DateTime?
completionNotes String?
completedById String?
completedBy User? @relation("TenderDirectiveCompletedBy", fields: [completedById], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
attachments Attachment[]
@@index([tenderId])
@@index([assignedToEmployeeId])
@@index([status])
@@map("tender_directives")
}
// ============================================
// MODULE 3: INVENTORY & ASSETS
// ============================================
@@ -1263,6 +1503,10 @@ model Attachment {
project Project? @relation(fields: [projectId], references: [id])
taskId String?
task Task? @relation(fields: [taskId], references: [id])
tenderId String?
tender Tender? @relation(fields: [tenderId], references: [id], onDelete: Cascade)
tenderDirectiveId String?
tenderDirective TenderDirective? @relation(fields: [tenderDirectiveId], references: [id], onDelete: Cascade)
// File Info
fileName String
@@ -1284,6 +1528,8 @@ model Attachment {
@@index([dealId])
@@index([projectId])
@@index([taskId])
@@index([tenderId])
@@index([tenderDirectiveId])
@@map("attachments")
}

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

@@ -0,0 +1,148 @@
/**
* 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', 'tenders', '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'] },
{ positionId: salesRepPosition.id, module: 'tenders', resource: 'tenders', actions: ['read', 'create', 'update'] },
{ positionId: salesRepPosition.id, module: 'tenders', resource: 'directives', actions: ['read', 'create', 'update'] },
],
});
const accountantPosition = await prisma.position.create({
data: {
title: 'Accountant',
titleAr: 'محاسب',
code: 'ACCOUNTANT',
departmentId: adminDept.id,
level: 2,
description: 'HR read, inventory read, contacts read',
},
});
await prisma.positionPermission.createMany({
data: [
{ positionId: accountantPosition.id, module: 'contacts', resource: '*', actions: ['read'] },
{ positionId: accountantPosition.id, module: 'crm', resource: '*', actions: ['read'] },
{ positionId: accountantPosition.id, module: 'inventory', resource: '*', actions: ['read'] },
{ positionId: accountantPosition.id, module: 'hr', resource: '*', actions: ['read'] },
],
});
console.log('✅ Created position and permissions');
const sysAdminEmployee = await prisma.employee.create({
data: {
uniqueEmployeeId: 'SYS-001',
firstName: 'System',
lastName: 'Administrator',
firstNameAr: 'مدير',
lastNameAr: 'النظام',
email: 'admin@system.local',
mobile: '+966500000000',
dateOfBirth: new Date('1990-01-01'),
gender: 'MALE',
nationality: 'Saudi',
employmentType: 'Full-time',
contractType: 'Unlimited',
hireDate: new Date(),
departmentId: adminDept.id,
positionId: sysAdminPosition.id,
basicSalary: 0,
status: 'ACTIVE',
},
});
const hashedPassword = await bcrypt.hash('Admin@123', 10);
await prisma.user.create({
data: {
email: 'admin@system.local',
username: 'admin',
password: hashedPassword,
employeeId: sysAdminEmployee.id,
isActive: true,
},
});
console.log('✅ Created System Administrator');
console.log('\n🎉 Database seeding completed!\n');
console.log('📋 System Administrator:');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(' Email: admin@system.local');
console.log(' Username: admin');
console.log(' Password: Admin@123');
console.log(' Access: Full system access (all modules)');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
}
main()
.catch((e) => {
console.error('❌ Error seeding database:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -4,58 +4,50 @@ import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
console.log('🌱 Starting database seeding...');
console.log('🌱 Starting database seeding (minimal - System Administrator only)...');
// Create Departments
// Create Administration Department
const adminDept = await prisma.department.create({
data: {
name: 'Administration',
nameAr: 'الإدارة',
code: 'ADMIN',
description: 'System administration and configuration',
},
});
// Create System Administrator Position
const sysAdminPosition = await prisma.position.create({
data: {
title: 'System Administrator',
titleAr: 'مدير النظام',
code: 'SYS_ADMIN',
departmentId: adminDept.id,
level: 1,
description: 'Full system access - configure and manage all modules',
},
});
// Create full permissions for all modules
const modules = ['contacts', 'crm', 'inventory', 'projects', 'hr', 'marketing', 'admin'];
for (const module of modules) {
await prisma.positionPermission.create({
data: {
positionId: sysAdminPosition.id,
module,
resource: '*',
actions: ['*'],
},
});
}
// Create Sales Department and restricted positions
const salesDept = await prisma.department.create({
data: {
name: 'Sales Department',
nameAr: 'قسم المبيعات',
name: 'Sales',
nameAr: 'المبيعات',
code: 'SALES',
description: 'Sales and Business Development',
},
});
const itDept = await prisma.department.create({
data: {
name: 'IT Department',
nameAr: 'قسم تقنية المعلومات',
code: 'IT',
description: 'Information Technology',
},
});
const hrDept = await prisma.department.create({
data: {
name: 'HR Department',
nameAr: 'قسم الموارد البشرية',
code: 'HR',
description: 'Human Resources',
},
});
console.log('✅ Created departments');
// Create Positions
const gmPosition = await prisma.position.create({
data: {
title: 'General Manager',
titleAr: 'المدير العام',
code: 'GM',
departmentId: salesDept.id,
level: 1,
description: 'Chief Executive - Full Access',
},
});
const salesManagerPosition = await prisma.position.create({
data: {
title: 'Sales Manager',
titleAr: 'مدير المبيعات',
code: 'SALES_MGR',
departmentId: salesDept.id,
level: 2,
description: 'Sales Department Manager',
description: 'Sales and business development',
},
});
@@ -66,342 +58,83 @@ async function main() {
code: 'SALES_REP',
departmentId: salesDept.id,
level: 3,
description: 'Sales Representative',
description: 'Limited access - Contacts and CRM deals',
},
});
console.log('✅ Created positions');
const itSupportPosition = await prisma.position.create({
data: {
title: 'IT Support',
titleAr: 'دعم فني',
code: 'IT_SUPPORT',
departmentId: itDept.id,
level: 4,
description: 'IT Support Technician',
},
});
const itDeveloperPosition = await prisma.position.create({
data: {
title: 'Developer',
titleAr: 'مطور',
code: 'IT_DEV',
departmentId: itDept.id,
level: 4,
description: 'Software Developer',
},
});
// Employee position for ALL departments (added)
const salesEmployeePosition = await prisma.position.create({
data: {
title: 'Employee',
titleAr: 'موظف',
code: 'SALES_EMPLOYEE',
departmentId: salesDept.id,
level: 5,
description: 'General employee - Sales Department',
},
});
const itEmployeePosition = await prisma.position.create({
data: {
title: 'Employee',
titleAr: 'موظف',
code: 'IT_EMPLOYEE',
departmentId: itDept.id,
level: 5,
description: 'General employee - IT Department',
},
});
const hrEmployeePosition = await prisma.position.create({
data: {
title: 'Employee',
titleAr: 'موظف',
code: 'HR_EMPLOYEE',
departmentId: hrDept.id,
level: 5,
description: 'General employee - HR Department',
},
});
// Create Permissions for GM (Full Access)
const modules = ['contacts', 'crm', 'inventory', 'projects', 'hr', 'marketing'];
const resources = ['*'];
const actions = ['*'];
for (const module of modules) {
await prisma.positionPermission.create({
data: {
positionId: gmPosition.id,
module,
resource: resources[0],
actions,
},
});
}
// Admin permission for GM
await prisma.positionPermission.create({
data: {
positionId: gmPosition.id,
module: 'admin',
resource: '*',
actions: ['*'],
},
});
// Create Permissions for Sales Manager
await prisma.positionPermission.createMany({
data: [
{
positionId: salesManagerPosition.id,
module: 'contacts',
resource: 'contacts',
actions: ['create', 'read', 'update', 'merge'],
},
{
positionId: salesManagerPosition.id,
module: 'crm',
resource: 'deals',
actions: ['create', 'read', 'update', 'approve'],
},
{
positionId: salesManagerPosition.id,
module: 'crm',
resource: 'quotes',
actions: ['create', 'read', 'update', 'approve'],
},
{ positionId: salesRepPosition.id, module: 'contacts', resource: '*', actions: ['read', 'create', 'update'] },
{ positionId: salesRepPosition.id, module: 'crm', resource: 'deals', actions: ['read', 'create', 'update'] },
],
});
// Create Permissions for Sales Rep
const accountantPosition = await prisma.position.create({
data: {
title: 'Accountant',
titleAr: 'محاسب',
code: 'ACCOUNTANT',
departmentId: adminDept.id,
level: 2,
description: 'HR read, inventory read, contacts read',
},
});
await prisma.positionPermission.createMany({
data: [
{
positionId: salesRepPosition.id,
module: 'contacts',
resource: 'contacts',
actions: ['create', 'read', 'update'],
},
{
positionId: salesRepPosition.id,
module: 'crm',
resource: 'deals',
actions: ['create', 'read', 'update'],
},
{
positionId: salesRepPosition.id,
module: 'crm',
resource: 'quotes',
actions: ['create', 'read'],
},
{ positionId: accountantPosition.id, module: 'contacts', resource: '*', actions: ['read'] },
{ positionId: accountantPosition.id, module: 'crm', resource: '*', actions: ['read'] },
{ positionId: accountantPosition.id, module: 'inventory', resource: '*', actions: ['read'] },
{ positionId: accountantPosition.id, module: 'hr', resource: '*', actions: ['read'] },
],
});
console.log('✅ Created permissions');
console.log('✅ Created position and permissions');
// Create Employees
const gmEmployee = await prisma.employee.create({
// Create minimal Employee for System Administrator
const sysAdminEmployee = await prisma.employee.create({
data: {
uniqueEmployeeId: 'EMP-2024-0001',
firstName: 'Ahmed',
lastName: 'Al-Mutairi',
firstNameAr: 'أحمد',
lastNameAr: 'المطيري',
email: 'gm@atmata.com',
mobile: '+966500000001',
dateOfBirth: new Date('1980-01-01'),
uniqueEmployeeId: 'SYS-001',
firstName: 'System',
lastName: 'Administrator',
firstNameAr: 'مدير',
lastNameAr: 'النظام',
email: 'admin@system.local',
mobile: '+966500000000',
dateOfBirth: new Date('1990-01-01'),
gender: 'MALE',
nationality: 'Saudi',
employmentType: 'Full-time',
contractType: 'Unlimited',
hireDate: new Date('2020-01-01'),
departmentId: salesDept.id,
positionId: gmPosition.id,
basicSalary: 50000,
hireDate: new Date(),
departmentId: adminDept.id,
positionId: sysAdminPosition.id,
basicSalary: 0,
status: 'ACTIVE',
},
});
const salesManagerEmployee = await prisma.employee.create({
data: {
uniqueEmployeeId: 'EMP-2024-0002',
firstName: 'Fatima',
lastName: 'Al-Zahrani',
firstNameAr: 'فاطمة',
lastNameAr: 'الزهراني',
email: 'sales.manager@atmata.com',
mobile: '+966500000002',
dateOfBirth: new Date('1985-05-15'),
gender: 'FEMALE',
nationality: 'Saudi',
employmentType: 'Full-time',
contractType: 'Unlimited',
hireDate: new Date('2021-06-01'),
departmentId: salesDept.id,
positionId: salesManagerPosition.id,
reportingToId: gmEmployee.id,
basicSalary: 25000,
status: 'ACTIVE',
},
});
const salesRepEmployee = await prisma.employee.create({
data: {
uniqueEmployeeId: 'EMP-2024-0003',
firstName: 'Mohammed',
lastName: 'Al-Qahtani',
firstNameAr: 'محمد',
lastNameAr: 'القحطاني',
email: 'sales.rep@atmata.com',
mobile: '+966500000003',
dateOfBirth: new Date('1992-08-20'),
gender: 'MALE',
nationality: 'Saudi',
employmentType: 'Full-time',
contractType: 'Fixed',
hireDate: new Date('2023-01-15'),
departmentId: salesDept.id,
positionId: salesRepPosition.id,
reportingToId: salesManagerEmployee.id,
basicSalary: 12000,
status: 'ACTIVE',
},
});
console.log('✅ Created employees');
// Create Users
// Create System Administrator User
const hashedPassword = await bcrypt.hash('Admin@123', 10);
const gmUser = await prisma.user.create({
await prisma.user.create({
data: {
email: 'gm@atmata.com',
email: 'admin@system.local',
username: 'admin',
password: hashedPassword,
employeeId: gmEmployee.id,
employeeId: sysAdminEmployee.id,
isActive: true,
},
});
const salesManagerUser = await prisma.user.create({
data: {
email: 'sales.manager@atmata.com',
username: 'salesmanager',
password: hashedPassword,
employeeId: salesManagerEmployee.id,
isActive: true,
},
});
console.log('✅ Created System Administrator');
const salesRepUser = await prisma.user.create({
data: {
email: 'sales.rep@atmata.com',
username: 'salesrep',
password: hashedPassword,
employeeId: salesRepEmployee.id,
isActive: true,
},
});
console.log('✅ Created users');
// Create Contact Categories
await prisma.contactCategory.createMany({
data: [
{ name: 'Customer', nameAr: 'عميل', description: 'Paying customers' },
{ name: 'Supplier', nameAr: 'مورد', description: 'Product/Service suppliers' },
{ name: 'Partner', nameAr: 'شريك', description: 'Business partners' },
{ name: 'Lead', nameAr: 'عميل محتمل', description: 'Potential customers' },
{ name: 'Company Employee', nameAr: 'موظف الشركة', description: 'Internal company staff' },
],
});
console.log('✅ Created contact categories');
// Create Product Categories
await prisma.productCategory.createMany({
data: [
{ name: 'Electronics', nameAr: 'إلكترونيات', code: 'ELEC' },
{ name: 'Software', nameAr: 'برمجيات', code: 'SOFT' },
{ name: 'Services', nameAr: 'خدمات', code: 'SERV' },
],
});
console.log('✅ Created product categories');
// Create Pipelines
await prisma.pipeline.create({
data: {
name: 'B2B Sales Pipeline',
nameAr: 'مسار مبيعات الشركات',
structure: 'B2B',
stages: [
{ name: 'OPEN', nameAr: 'مفتوحة', order: 1 },
{ name: 'QUALIFIED', nameAr: 'مؤهلة', order: 2 },
{ name: 'NEGOTIATION', nameAr: 'تفاوض', order: 3 },
{ name: 'PROPOSAL', nameAr: 'عرض سعر', order: 4 },
{ name: 'WON', nameAr: 'فازت', order: 5 },
{ name: 'LOST', nameAr: 'خسرت', order: 6 },
],
isActive: true,
},
});
await prisma.pipeline.create({
data: {
name: 'B2C Sales Pipeline',
nameAr: 'مسار مبيعات الأفراد',
structure: 'B2C',
stages: [
{ name: 'LEAD', nameAr: 'عميل محتمل', order: 1 },
{ name: 'CONTACTED', nameAr: 'تم التواصل', order: 2 },
{ name: 'QUALIFIED', nameAr: 'مؤهل', order: 3 },
{ name: 'WON', nameAr: 'بيع', order: 4 },
{ name: 'LOST', nameAr: 'خسارة', order: 5 },
],
isActive: true,
},
});
console.log('✅ Created pipelines');
// Create sample warehouse
await prisma.warehouse.create({
data: {
code: 'WH-MAIN',
name: 'Main Warehouse',
nameAr: 'المستودع الرئيسي',
type: 'MAIN',
city: 'Riyadh',
country: 'Saudi Arabia',
isActive: true,
},
});
console.log('✅ Created warehouse');
console.log('\n🎉 Database seeding completed successfully!\n');
console.log('📋 Default Users Created:');
console.log('\n🎉 Database seeding completed!\n');
console.log('📋 System Administrator:');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('1. General Manager');
console.log(' Email: gm@atmata.com');
console.log(' Email: admin@system.local');
console.log(' Username: admin');
console.log(' Password: Admin@123');
console.log(' Access: Full System Access');
console.log('');
console.log('2. Sales Manager');
console.log(' Email: sales.manager@atmata.com');
console.log(' Password: Admin@123');
console.log(' Access: Contacts, CRM with approvals');
console.log('');
console.log('3. Sales Representative');
console.log(' Email: sales.rep@atmata.com');
console.log(' Password: Admin@123');
console.log(' Access: Basic Contacts and CRM');
console.log(' Access: Full system access (all modules)');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
}
@@ -413,4 +146,3 @@ main()
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,51 @@
/**
* Ensure GM position has all module permissions.
* Adds any missing permissions for: contacts, crm, inventory, projects, hr, marketing, admin
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const GM_MODULES = ['contacts', 'crm', 'inventory', 'projects', 'hr', 'marketing', 'admin'];
async function main() {
const gmPosition = await prisma.position.findFirst({ where: { code: 'GM' } });
if (!gmPosition) {
console.log('GM position not found.');
process.exit(1);
}
const existing = await prisma.positionPermission.findMany({
where: { positionId: gmPosition.id },
select: { module: true },
});
const existingModules = new Set(existing.map((p) => p.module));
let added = 0;
for (const module of GM_MODULES) {
if (existingModules.has(module)) continue;
await prisma.positionPermission.create({
data: {
positionId: gmPosition.id,
module,
resource: '*',
actions: ['*'],
},
});
console.log(`Added permission: ${module}`);
added++;
}
if (added === 0) {
console.log('All GM permissions already exist.');
} else {
console.log(`Added ${added} permission(s).`);
}
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@@ -53,4 +53,4 @@ npm run db:clean-and-seed
echo ""
echo "✅ Done. Restart the application so it uses the cleaned database."
echo " Default logins: gm@atmata.com / sales.manager@atmata.com / sales.rep@atmata.com (Password: Admin@123)"
echo " System Administrator: admin@system.local (Password: Admin@123)"

View File

@@ -18,7 +18,18 @@ export const config = {
},
cors: {
origin: process.env.CORS_ORIGIN?.split(',') || ['http://localhost:3000'],
origin: (() => {
const envOrigins = process.env.CORS_ORIGIN?.split(',').map((o) => o.trim()).filter(Boolean);
const defaults = ['http://localhost:3000', 'http://localhost:5173'];
const origins = envOrigins?.length ? envOrigins : defaults;
// In development, always allow both common dev server origins
if (process.env.NODE_ENV !== 'production') {
['http://localhost:3000', 'http://localhost:5173'].forEach((o) => {
if (!origins.includes(o)) origins.push(o);
});
}
return origins;
})(),
},
upload: {

View File

@@ -136,17 +136,15 @@ class AdminController {
async createPosition(req: AuthRequest, res: Response, next: NextFunction) {
try {
const userId = req.user!.id;
const position = await adminService.createPosition(
{
const position = await adminService.createPosition({
title: req.body.title,
titleAr: req.body.titleAr,
code: req.body.code,
departmentId: req.body.departmentId,
level: req.body.level,
code: req.body.code,
},
userId
);
description: req.body.description,
isActive: req.body.isActive,
});
res.status(201).json(ResponseFormatter.success(position));
} catch (error) {
next(error);
@@ -155,15 +153,15 @@ class AdminController {
async updatePosition(req: AuthRequest, res: Response, next: NextFunction) {
try {
const userId = req.user!.id;
const position = await adminService.updatePosition(
req.params.id,
{
const position = await adminService.updatePosition(req.params.id, {
title: req.body.title,
titleAr: req.body.titleAr,
},
userId
);
code: req.body.code,
departmentId: req.body.departmentId,
level: req.body.level,
description: req.body.description,
isActive: req.body.isActive,
});
res.json(ResponseFormatter.success(position));
} catch (error) {
next(error);
@@ -182,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 {
const userId = req.user!.id;
await adminService.deletePosition(req.params.id, userId);
res.json(ResponseFormatter.success(null, 'Role deleted successfully'));
const groups = await adminService.getPermissionGroups();
res.json(ResponseFormatter.success(groups));
} catch (error) {
next(error);
}
}
async createPermissionGroup(req: AuthRequest, res: Response, next: NextFunction) {
try {
const group = await adminService.createPermissionGroup(req.body);
res.status(201).json(ResponseFormatter.success(group));
} catch (error) {
next(error);
}
}
async updatePermissionGroup(req: AuthRequest, res: Response, next: NextFunction) {
try {
const group = await adminService.updatePermissionGroup(req.params.id, req.body);
res.json(ResponseFormatter.success(group));
} catch (error) {
next(error);
}
}
async updatePermissionGroupPermissions(req: AuthRequest, res: Response, next: NextFunction) {
try {
const group = await adminService.updatePermissionGroupPermissions(
req.params.id,
req.body.permissions
);
res.json(ResponseFormatter.success(group));
} catch (error) {
next(error);
}
}
async getUserRoles(req: AuthRequest, res: Response, next: NextFunction) {
try {
const roles = await adminService.getUserRoles(req.params.userId);
res.json(ResponseFormatter.success(roles));
} catch (error) {
next(error);
}
}
async assignUserRole(req: AuthRequest, res: Response, next: NextFunction) {
try {
const userRole = await adminService.assignUserRole(req.params.userId, req.body.roleId);
res.status(201).json(ResponseFormatter.success(userRole));
} catch (error) {
next(error);
}
}
async removeUserRole(req: AuthRequest, res: Response, next: NextFunction) {
try {
await adminService.removeUserRole(req.params.userId, req.params.roleId);
res.json(ResponseFormatter.success({ success: true }));
} catch (error) {
next(error);
}

View File

@@ -89,43 +89,33 @@ router.get(
adminController.getPositions
);
// Create role
router.post(
'/positions',
authorize('admin', 'roles', 'create'),
[
body('title').notEmpty().trim(),
body('titleAr').optional().isString().trim(),
body('code').notEmpty().trim(),
body('departmentId').isUUID(),
body('level').optional().isInt({ min: 1 }),
body('code').optional().isString().trim(),
],
validate,
adminController.createPosition
);
// Update role name (title/titleAr)
router.put(
'/positions/:id',
authorize('admin', 'roles', 'update'),
[
param('id').isUUID(),
body('title').optional().notEmpty().trim(),
body('titleAr').optional().isString().trim(),
body('code').optional().notEmpty().trim(),
body('departmentId').optional().isUUID(),
body('level').optional().isInt({ min: 1 }),
],
validate,
adminController.updatePosition
);
// Delete (soft delete) a role/position
router.delete(
'/positions/:id',
authorize('admin', 'roles', 'delete'),
param('id').isUUID(),
validate,
adminController.deletePosition
);
router.put(
'/positions/:id/permissions',
authorize('admin', 'roles', 'update'),
@@ -137,4 +127,68 @@ router.put(
adminController.updatePositionPermissions
);
// ========== 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;

View File

@@ -39,19 +39,6 @@ export interface AuditLogFilters {
pageSize?: number;
}
export interface CreatePositionData {
title: string;
titleAr?: string;
departmentId: string;
level?: number;
code?: string;
}
export interface UpdatePositionData {
title?: string;
titleAr?: string;
}
class AdminService {
// ========== USERS ==========
@@ -107,7 +94,7 @@ class AdminService {
]);
const sanitized = users.map((u) => {
const { password: _, ...rest } = u as any;
const { password: _, ...rest } = u;
return rest;
});
@@ -137,7 +124,7 @@ class AdminService {
throw new AppError(404, 'المستخدم غير موجود - User not found');
}
const { password: _, ...rest } = user as any;
const { password: _, ...rest } = user;
return rest;
}
@@ -161,7 +148,7 @@ class AdminService {
throw new AppError(400, 'هذا الموظف مرتبط بحساب مستخدم بالفعل - Employee already has a user account');
}
const emailExists = await prisma.user.findUnique({
const emailExists = await prisma.user.findFirst({
where: { email: data.email },
});
if (emailExists) {
@@ -219,7 +206,7 @@ class AdminService {
}
if (data.email && data.email !== existing.email) {
const emailExists = await prisma.user.findUnique({ where: { email: data.email } });
const emailExists = await prisma.user.findFirst({ where: { email: data.email } });
if (emailExists) {
throw new AppError(400, 'البريد الإلكتروني مستخدم بالفعل - Email already in use');
}
@@ -236,7 +223,7 @@ class AdminService {
...(data.email && { email: data.email }),
...(data.username && { username: data.username }),
...(data.isActive !== undefined && { isActive: data.isActive }),
...(data.employeeId !== undefined && { employeeId: (data.employeeId as any) || null }),
...(data.employeeId !== undefined && { employeeId: data.employeeId || null }),
};
if (data.password && data.password.length >= 8) {
@@ -255,7 +242,7 @@ class AdminService {
},
});
const { password: _, ...sanitized } = user as any;
const { password: _, ...sanitized } = user;
await AuditLogger.log({
entityType: 'USER',
@@ -286,7 +273,7 @@ class AdminService {
},
});
const { password: _, ...sanitized } = updated as any;
const { password: _, ...sanitized } = updated;
await AuditLogger.log({
entityType: 'USER',
@@ -393,7 +380,7 @@ class AdminService {
const positions = await prisma.position.findMany({
where: { isActive: true },
include: {
department: { select: { id: true, name: true, nameAr: true } },
department: { select: { name: true, nameAr: true } },
permissions: true,
_count: {
select: {
@@ -419,118 +406,100 @@ class AdminService {
return withUserCount;
}
private async generateUniqueCode(base: string) {
const cleaned = (base || 'ROLE')
.toUpperCase()
.replace(/[^A-Z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 18) || 'ROLE';
for (let i = 0; i < 25; i++) {
const suffix = Math.floor(1000 + Math.random() * 9000);
const code = `${cleaned}_${suffix}`;
const exists = await prisma.position.findUnique({ where: { code } });
if (!exists) return code;
async createPosition(data: {
title: string;
titleAr?: string;
code: string;
departmentId: string;
level?: number;
description?: string;
isActive?: boolean;
}) {
const existing = await prisma.position.findUnique({
where: { code: data.code },
});
if (existing) {
throw new AppError(400, 'كود الدور مستخدم - Position code already exists');
}
// fallback
return `${cleaned}_${Date.now()}`;
}
async createPosition(data: CreatePositionData, createdById: string) {
const title = (data.title || '').trim();
const titleAr = (data.titleAr || '').trim();
if (!title && !titleAr) {
throw new AppError(400, 'اسم الدور مطلوب - Role name is required');
}
const department = await prisma.department.findUnique({
const dept = await prisma.department.findUnique({
where: { id: data.departmentId },
});
if (!department || !department.isActive) {
if (!dept) {
throw new AppError(400, 'القسم غير موجود - Department not found');
}
let code = (data.code || '').trim();
if (code) {
code = code.toUpperCase().replace(/[^A-Z0-9_]+/g, '_');
const exists = await prisma.position.findUnique({ where: { code } });
if (exists) {
throw new AppError(400, 'الكود مستخدم بالفعل - Code already exists');
}
} else {
code = await this.generateUniqueCode(title || titleAr || 'ROLE');
}
const level = Number.isFinite(data.level as any) ? Math.max(1, Number(data.level)) : 1;
const created = await prisma.position.create({
return prisma.position.create({
data: {
title: title || titleAr,
titleAr: titleAr || null,
code,
title: data.title,
titleAr: data.titleAr,
code: data.code.trim().toUpperCase().replace(/\s+/g, '_'),
departmentId: data.departmentId,
level,
level: data.level ?? 5,
description: data.description,
isActive: data.isActive ?? true,
},
include: {
department: { select: { name: true, nameAr: true } },
permissions: true,
},
});
await AuditLogger.log({
entityType: 'POSITION',
entityId: created.id,
action: 'CREATE',
userId: createdById,
changes: {
created: {
title: created.title,
titleAr: created.titleAr,
code: created.code,
departmentId: created.departmentId,
level: created.level,
},
},
});
const all = await this.getPositions();
return all.find((p: any) => p.id === created.id) || created;
}
async updatePosition(positionId: string, data: UpdatePositionData, updatedById: string) {
const existing = await prisma.position.findUnique({ where: { id: positionId } });
if (!existing) {
async updatePosition(
positionId: string,
data: {
title?: string;
titleAr?: string;
code?: string;
departmentId?: string;
level?: number;
description?: string;
isActive?: boolean;
}
) {
const position = await prisma.position.findUnique({
where: { id: positionId },
});
if (!position) {
throw new AppError(404, 'الدور غير موجود - Position not found');
}
const nextTitle = data.title !== undefined ? (data.title || '').trim() : existing.title;
const nextTitleAr = data.titleAr !== undefined ? (data.titleAr || '').trim() : (existing.titleAr || '');
const finalTitle = nextTitle || nextTitleAr;
if (!finalTitle) {
throw new AppError(400, 'اسم الدور مطلوب - Role name is required');
if (data.code && data.code !== position.code) {
const existing = await prisma.position.findUnique({
where: { code: data.code },
});
if (existing) {
throw new AppError(400, 'كود الدور مستخدم - Position code already exists');
}
}
const updated = await prisma.position.update({
if (data.departmentId && data.departmentId !== position.departmentId) {
const dept = await prisma.department.findUnique({
where: { id: data.departmentId },
});
if (!dept) {
throw new AppError(400, 'القسم غير موجود - Department not found');
}
}
const updateData: Record<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 },
data: {
title: finalTitle,
titleAr: nextTitleAr ? nextTitleAr : null,
data: updateData,
include: {
department: { select: { name: true, nameAr: true } },
permissions: true,
},
});
await AuditLogger.log({
entityType: 'POSITION',
entityId: positionId,
action: 'UPDATE',
userId: updatedById,
changes: {
before: { title: existing.title, titleAr: existing.titleAr },
after: { title: updated.title, titleAr: updated.titleAr },
},
});
const all = await this.getPositions();
return all.find((p: any) => p.id === positionId) || updated;
}
async updatePositionPermissions(positionId: string, permissions: Array<{ module: string; resource: string; actions: string[] }>) {
@@ -554,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);
}
/**
* Soft delete a role (Position).
* - Prevent deletion if the position is assigned to any employees.
* - Clean up position permissions.
*/
async deletePosition(positionId: string, deletedById: string) {
const position = await prisma.position.findUnique({
where: { id: positionId },
// ========== PERMISSION GROUPS (Phase 3 - optional roles for multi-group) ==========
async getPermissionGroups() {
return prisma.role.findMany({
where: { isActive: true },
include: {
_count: { select: { employees: true } },
permissions: true,
_count: { select: { userRoles: true } },
},
orderBy: { name: 'asc' },
});
if (!position) {
throw new AppError(404, 'الدور غير موجود - Position not found');
}
if (position._count.employees > 0) {
throw new AppError(
400,
'لا يمكن حذف هذا الدور لأنه مرتبط بموظفين. قم بتغيير دور الموظفين أولاً - Cannot delete: position is assigned to employees'
);
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 },
});
}
// Soft delete the position
await prisma.position.update({
where: { id: positionId },
data: { isActive: false },
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 },
});
}
// Clean up permissions linked to this position
await prisma.positionPermission.deleteMany({
where: { positionId },
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 },
});
}
await AuditLogger.log({
entityType: 'POSITION',
entityId: positionId,
action: 'DELETE',
userId: deletedById,
changes: {
softDeleted: true,
title: position.title,
titleAr: position.titleAr,
code: position.code,
async getUserRoles(userId: string) {
return prisma.userRole.findMany({
where: { userId },
include: {
role: { include: { permissions: true } },
},
});
}
async assignUserRole(userId: string, roleId: string) {
const [user, role] = await Promise.all([
prisma.user.findUnique({ where: { id: userId } }),
prisma.role.findFirst({ where: { id: roleId, isActive: true } }),
]);
if (!user) throw new AppError(404, 'المستخدم غير موجود - User not found');
if (!role) throw new AppError(404, 'المجموعة غير موجودة - Group not found');
const existing = await prisma.userRole.findUnique({
where: { userId_roleId: { userId, roleId } },
});
if (existing) {
throw new AppError(400, 'المستخدم منتمي بالفعل لهذه المجموعة - User already in group');
}
return prisma.userRole.create({
data: { userId, roleId },
include: { role: true },
});
}
async removeUserRole(userId: string, roleId: string) {
const deleted = await prisma.userRole.deleteMany({
where: { userId, roleId },
});
if (deleted.count === 0) {
throw new AppError(404, 'لم يتم العثور على الانتماء - User not in group');
}
return { success: true };
}
}

View File

@@ -1,6 +1,6 @@
import { Request, Response } from 'express'
import { authService } from './auth.service'
import { AuthRequest } from '@/shared/middleware/auth'
import { AuthRequest } from '../../shared/middleware/auth'
export const authController = {
register: async (req: Request, res: Response) => {
@@ -21,17 +21,27 @@ export const authController = {
login: async (req: Request, res: Response) => {
try {
const { email, password } = req.body
const result = await authService.login(email, password)
if (!email || !password) {
return res.status(400).json({
success: false,
message: 'الرجاء إدخال البريد/اسم المستخدم وكلمة المرور'
})
}
const result = await authService.login(String(email).trim(), String(password))
res.status(200).json({
success: true,
message: 'تم تسجيل الدخول بنجاح',
data: result
})
} catch (error: any) {
res.status(401).json({
res.status(error?.statusCode || 401).json({
success: false,
message: error.message
message: error.message || 'بيانات الدخول غير صحيحة'
})
}
},

View File

@@ -30,7 +30,7 @@ router.post(
router.post(
'/login',
[
body('email').isEmail().withMessage('البريد الإلكتروني غير صالح'),
body('email').trim().notEmpty().withMessage('البريد الإلكتروني أو اسم المستخدم مطلوب'),
body('password').notEmpty().withMessage('كلمة المرور مطلوبة'),
],
validate,

View File

@@ -1,26 +1,27 @@
import bcrypt from 'bcryptjs';
import jwt, { Secret, SignOptions } from 'jsonwebtoken';
import prisma from '../../config/database';
import { config } from '../../config';
import { AppError } from '../../shared/middleware/errorHandler';
import bcrypt from 'bcryptjs'
import jwt, { Secret, SignOptions } from 'jsonwebtoken'
import prisma from '../../config/database'
import { config } from '../../config'
import { AppError } from '../../shared/middleware/errorHandler'
class AuthService {
async register(data: {
email: string;
username: string;
password: string;
employeeId?: string;
email: string
username: string
password: string
employeeId?: string
}) {
// Hash password
const hashedPassword = await bcrypt.hash(data.password, config.security.bcryptRounds);
const hashedPassword = await bcrypt.hash(
data.password,
config.security.bcryptRounds
)
// Create user
const user = await prisma.user.create({
data: {
email: data.email,
username: data.username,
password: hashedPassword,
employeeId: data.employeeId,
employeeId: data.employeeId
},
select: {
id: true,
@@ -28,120 +29,140 @@ class AuthService {
username: true,
employeeId: true,
isActive: true,
createdAt: true,
},
});
createdAt: true
}
})
// Generate tokens
const tokens = this.generateTokens(user.id, user.email);
const tokens = this.generateTokens(user.id, user.email)
// Save refresh token
await prisma.user.update({
where: { id: user.id },
data: { refreshToken: tokens.refreshToken },
});
data: { refreshToken: tokens.refreshToken }
})
return {
user,
...tokens,
};
...tokens
}
}
async login(email: string, password: string) {
// Find user with employee info and permissions
const user = await prisma.user.findUnique({
where: { email },
async login(emailOrUsername: string, password: string) {
const identifier = (emailOrUsername || '').trim()
const isEmail = identifier.includes('@')
let user: any = null
if (isEmail) {
const matches = await prisma.user.findMany({
where: { email: identifier },
include: {
employee: {
include: {
position: {
position: { include: { permissions: true } },
department: true
}
}
}
})
if (matches.length === 0) {
throw new AppError(401, 'بيانات الدخول غير صحيحة - Invalid credentials')
}
if (matches.length > 1) {
throw new AppError(
400,
'هذا البريد مستخدم لأكثر من حساب. الرجاء تسجيل الدخول باسم المستخدم - Use username'
)
}
user = matches[0]
} else {
user = await prisma.user.findUnique({
where: { username: identifier },
include: {
permissions: true,
},
},
department: true,
},
},
},
});
employee: {
include: {
position: { include: { permissions: true } },
department: true
}
}
}
})
if (!user) {
throw new AppError(401, 'بيانات الدخول غير صحيحة - Invalid credentials');
throw new AppError(401, 'بيانات الدخول غير صحيحة - Invalid credentials')
}
}
// Check if user is active
if (!user.isActive) {
throw new AppError(403, 'الحساب غير مفعل - Account is inactive');
throw new AppError(403, 'الحساب غير مفعل - Account is inactive')
}
// Check if account is locked
if (user.lockedUntil && user.lockedUntil > new Date()) {
throw new AppError(403, 'الحساب مقفل مؤقتاً - Account is temporarily locked');
throw new AppError(403, 'الحساب مقفل مؤقتاً - Account is temporarily locked')
}
// Verify password
const isPasswordValid = await bcrypt.compare(password, user.password);
const isPasswordValid = await bcrypt.compare(password, user.password)
if (!isPasswordValid) {
// Increment failed login attempts
const failedAttempts = user.failedLoginAttempts + 1;
const updateData: any = { failedLoginAttempts: failedAttempts };
const failedAttempts = (user.failedLoginAttempts || 0) + 1
const updateData: any = { failedLoginAttempts: failedAttempts }
// Lock account after 5 failed attempts
if (failedAttempts >= 5) {
updateData.lockedUntil = new Date(Date.now() + 30 * 60 * 1000); // Lock for 30 minutes
updateData.lockedUntil = new Date(Date.now() + 30 * 60 * 1000)
}
await prisma.user.update({
where: { id: user.id },
data: updateData,
});
data: updateData
})
throw new AppError(401, 'بيانات الدخول غير صحيحة - Invalid credentials');
throw new AppError(401, 'بيانات الدخول غير صحيحة - Invalid credentials')
}
// Check HR requirement: Must have active employee record
if (!user.employee || user.employee.status !== 'ACTIVE') {
throw new AppError(403, 'الوصول مرفوض - Access denied. Active employee record required.');
throw new AppError(
403,
'الوصول مرفوض - Access denied. Active employee record required.'
)
}
// Reset failed attempts
await prisma.user.update({
where: { id: user.id },
data: {
failedLoginAttempts: 0,
lockedUntil: null,
lastLogin: new Date(),
},
});
lastLogin: new Date()
}
})
// Generate tokens
const tokens = this.generateTokens(user.id, user.email);
const tokens = this.generateTokens(user.id, user.email)
// Save refresh token
await prisma.user.update({
where: { id: user.id },
data: { refreshToken: tokens.refreshToken },
});
data: { refreshToken: tokens.refreshToken }
})
// Return user data without password, with role info
const { password: _, ...userWithoutPassword } = user;
const { password: _pw, ...userWithoutPassword } = user
// Format role and permissions
const role = user.employee?.position ? {
const role = user.employee?.position
? {
id: user.employee.position.id,
name: user.employee.position.titleAr || user.employee.position.title,
nameEn: user.employee.position.title,
permissions: user.employee.position.permissions || []
} : null;
}
: null
return {
user: {
...userWithoutPassword,
role
},
...tokens,
};
...tokens
}
}
async getUserById(userId: string) {
@@ -150,77 +171,57 @@ class AuthService {
include: {
employee: {
include: {
position: {
include: {
permissions: true,
},
},
department: true,
},
},
},
});
if (!user) {
throw new AppError(404, 'المستخدم غير موجود - User not found');
position: { include: { permissions: true } },
department: true
}
if (!user.isActive) {
throw new AppError(403, 'الحساب غير مفعل - Account is inactive');
}
}
})
// Format user data
const { password: _, ...userWithoutPassword } = user;
if (!user) throw new AppError(404, 'المستخدم غير موجود - User not found')
if (!user.isActive) throw new AppError(403, 'الحساب غير مفعل - Account is inactive')
const role = user.employee?.position ? {
const { password: _pw, ...userWithoutPassword } = user
const role = user.employee?.position
? {
id: user.employee.position.id,
name: user.employee.position.titleAr || user.employee.position.title,
nameEn: user.employee.position.title,
permissions: user.employee.position.permissions || []
} : null;
}
: null
return {
...userWithoutPassword,
role
};
return { ...userWithoutPassword, role }
}
async refreshToken(refreshToken: string) {
try {
const decoded = jwt.verify(refreshToken, config.jwt.secret) as {
id: string;
email: string;
};
// Verify refresh token matches stored token
const user = await prisma.user.findUnique({
where: { id: decoded.id },
});
const decoded = jwt.verify(refreshToken, config.jwt.secret) as { id: string; email: string }
const user = await prisma.user.findUnique({ where: { id: decoded.id } })
if (!user || user.refreshToken !== refreshToken || !user.isActive) {
throw new AppError(401, 'رمز غير صالح - Invalid token');
throw new AppError(401, 'رمز غير صالح - Invalid token')
}
// Generate new tokens
const tokens = this.generateTokens(user.id, user.email);
const tokens = this.generateTokens(user.id, user.email)
// Update refresh token
await prisma.user.update({
where: { id: user.id },
data: { refreshToken: tokens.refreshToken },
});
data: { refreshToken: tokens.refreshToken }
})
return tokens;
} catch (error) {
throw new AppError(401, 'رمز غير صالح - Invalid token');
return tokens
} catch {
throw new AppError(401, 'رمز غير صالح - Invalid token')
}
}
async logout(userId: string) {
await prisma.user.update({
where: { id: userId },
data: { refreshToken: null },
});
data: { refreshToken: null }
})
}
async getUserProfile(userId: string) {
@@ -234,47 +235,35 @@ class AuthService {
lastLogin: true,
employee: {
include: {
position: {
include: {
permissions: true,
},
},
department: true,
},
},
},
});
if (!user) {
throw new AppError(404, 'المستخدم غير موجود - User not found');
position: { include: { permissions: true } },
department: true
}
}
}
})
return user;
if (!user) throw new AppError(404, 'المستخدم غير موجود - User not found')
return user
}
private generateTokens(userId: string, email: string) {
const payload = { id: userId, email };
const secret = config.jwt.secret as Secret;
const payload = { id: userId, email }
const secret = config.jwt.secret as Secret
const accessToken = jwt.sign(
payload,
secret,
{ expiresIn: config.jwt.expiresIn } as SignOptions
);
const accessToken = jwt.sign(payload, secret, {
expiresIn: config.jwt.expiresIn
} as SignOptions)
const refreshToken = jwt.sign(
payload,
secret,
{ expiresIn: config.jwt.refreshExpiresIn } as SignOptions
);
const refreshToken = jwt.sign(payload, secret, {
expiresIn: config.jwt.refreshExpiresIn
} as SignOptions)
return {
accessToken,
refreshToken,
expiresIn: config.jwt.expiresIn,
};
expiresIn: config.jwt.expiresIn
}
}
}
export const authService = new AuthService();
export const authService = new AuthService()

View File

@@ -42,7 +42,8 @@ router.post(
'/',
authorize('contacts', 'contacts', 'create'),
[
body('type').isIn(['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT']),
body('type').isIn(['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT','ORGANIZATION','EMBASSIES',
'BANK','UNIVERSITY','SCHOOL','UN','NGO','INSTITUTION',]),
body('name').notEmpty().trim(),
body('email').optional().isEmail(),
body('source').notEmpty(),
@@ -57,7 +58,29 @@ router.put(
authorize('contacts', 'contacts', 'update'),
[
param('id').isUUID(),
body('email').optional().isEmail(),
body('type')
.optional()
.isIn([
'INDIVIDUAL',
'COMPANY',
'HOLDING',
'GOVERNMENT',
'ORGANIZATION',
'EMBASSIES',
'BANK',
'UNIVERSITY',
'SCHOOL',
'UN',
'NGO',
'INSTITUTION',
]),
body('email')
.optional({ values: 'falsy' })
.custom((value) => {
if (value === null || value === undefined || value === '') return true
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
})
.withMessage('Invalid email format'),
validate,
],
contactsController.update

View File

@@ -330,9 +330,10 @@ class ContactsService {
const contact = await prisma.contact.update({
where: { id },
data: {
type: data.type,
name: data.name,
nameAr: data.nameAr,
email: data.email,
email: data.email === '' || data.email === undefined ? null : data.email,
phone: data.phone,
mobile: data.mobile,
website: data.website,
@@ -344,11 +345,14 @@ class ContactsService {
city: data.city,
country: data.country,
postalCode: data.postalCode,
categories: data.categories ? {
set: data.categories.map(id => ({ id }))
} : undefined,
categories: data.categories
? {
set: data.categories.map((id) => ({ id })),
}
: undefined,
tags: data.tags,
employeeId: data.employeeId !== undefined ? (data.employeeId || null) : undefined,
employeeId:
data.employeeId !== undefined ? (data.employeeId || null) : undefined,
source: data.source,
status: data.status,
rating: data.rating,
@@ -679,7 +683,7 @@ class ContactsService {
}
// Validate type
if (!['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT'].includes(row.type)) {
if (!['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT', 'ORGANIZATION','EMBASSIES','BANK','UNIVERSITY','SCHOOL','UN','NGO','INSTITUTION',].includes(row.type)) {
results.errors.push({
row: rowNumber,
field: 'type',

View File

@@ -0,0 +1,65 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from '../../shared/middleware/auth';
import { contractsService } from './contracts.service';
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
export class ContractsController {
async create(req: AuthRequest, res: Response, next: NextFunction) {
try {
const contract = await contractsService.create(req.body, req.user!.id);
res.status(201).json(
ResponseFormatter.success(contract, 'تم إنشاء العقد - Contract created')
);
} catch (error) {
next(error);
}
}
async findById(req: AuthRequest, res: Response, next: NextFunction) {
try {
const contract = await contractsService.findById(req.params.id);
res.json(ResponseFormatter.success(contract));
} catch (error) {
next(error);
}
}
async findByDeal(req: AuthRequest, res: Response, next: NextFunction) {
try {
const list = await contractsService.findByDeal(req.params.dealId);
res.json(ResponseFormatter.success(list));
} catch (error) {
next(error);
}
}
async update(req: AuthRequest, res: Response, next: NextFunction) {
try {
const contract = await contractsService.update(req.params.id, req.body, req.user!.id);
res.json(ResponseFormatter.success(contract, 'تم تحديث العقد - Contract updated'));
} catch (error) {
next(error);
}
}
async updateStatus(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { status } = req.body;
const contract = await contractsService.updateStatus(req.params.id, status, req.user!.id);
res.json(ResponseFormatter.success(contract, 'تم تحديث حالة العقد - Contract status updated'));
} catch (error) {
next(error);
}
}
async markSigned(req: AuthRequest, res: Response, next: NextFunction) {
try {
const contract = await contractsService.markSigned(req.params.id, req.user!.id);
res.json(ResponseFormatter.success(contract, 'تم توقيع العقد - Contract signed'));
} catch (error) {
next(error);
}
}
}
export const contractsController = new ContractsController();

View File

@@ -0,0 +1,136 @@
import prisma from '../../config/database';
import { AppError } from '../../shared/middleware/errorHandler';
import { AuditLogger } from '../../shared/utils/auditLogger';
interface CreateContractData {
dealId: string;
title: string;
type: string;
clientInfo: any;
companyInfo: any;
startDate: Date;
endDate?: Date;
value: number;
paymentTerms: any;
deliveryTerms: any;
terms: string;
}
class ContractsService {
async create(data: CreateContractData, userId: string) {
const contractNumber = await this.generateContractNumber();
const contract = await prisma.contract.create({
data: {
contractNumber,
dealId: data.dealId,
title: data.title,
type: data.type,
clientInfo: data.clientInfo || {},
companyInfo: data.companyInfo || {},
startDate: new Date(data.startDate),
endDate: data.endDate ? new Date(data.endDate) : null,
value: data.value,
paymentTerms: data.paymentTerms || {},
deliveryTerms: data.deliveryTerms || {},
terms: data.terms,
status: 'DRAFT',
},
include: {
deal: { include: { contact: true, owner: true } },
},
});
await AuditLogger.log({
entityType: 'CONTRACT',
entityId: contract.id,
action: 'CREATE',
userId,
});
return contract;
}
async findById(id: string) {
const contract = await prisma.contract.findUnique({
where: { id },
include: {
deal: { include: { contact: true, owner: true } },
},
});
if (!contract) throw new AppError(404, 'العقد غير موجود - Contract not found');
return contract;
}
async findByDeal(dealId: string) {
return prisma.contract.findMany({
where: { dealId },
orderBy: { createdAt: 'desc' },
});
}
async updateStatus(id: string, status: string, userId: string) {
const contract = await prisma.contract.update({
where: { id },
data: { status },
include: { deal: true },
});
await AuditLogger.log({
entityType: 'CONTRACT',
entityId: id,
action: 'STATUS_CHANGE',
userId,
changes: { status },
});
return contract;
}
async markSigned(id: string, userId: string) {
const contract = await prisma.contract.update({
where: { id },
data: { status: 'ACTIVE', signedAt: new Date() },
include: { deal: true },
});
await AuditLogger.log({
entityType: 'CONTRACT',
entityId: id,
action: 'SIGN',
userId,
});
return contract;
}
async update(id: string, data: Partial<CreateContractData>, userId: string) {
const updateData: Record<string, any> = { ...data };
if (updateData.startDate) updateData.startDate = new Date(updateData.startDate);
if (updateData.endDate !== undefined) updateData.endDate = updateData.endDate ? new Date(updateData.endDate) : null;
const contract = await prisma.contract.update({
where: { id },
data: updateData,
include: { deal: { include: { contact: true, owner: true } } },
});
await AuditLogger.log({
entityType: 'CONTRACT',
entityId: id,
action: 'UPDATE',
userId,
});
return contract;
}
private async generateContractNumber(): Promise<string> {
const year = new Date().getFullYear();
const prefix = `CTR-${year}-`;
const last = await prisma.contract.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(6, '0')}`;
}
}
export const contractsService = new ContractsService();

View File

@@ -0,0 +1,55 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from '../../shared/middleware/auth';
import { costSheetsService } from './costSheets.service';
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
export class CostSheetsController {
async create(req: AuthRequest, res: Response, next: NextFunction) {
try {
const cs = await costSheetsService.create(req.body, req.user!.id);
res.status(201).json(
ResponseFormatter.success(cs, 'تم إنشاء كشف التكلفة - Cost sheet created')
);
} catch (error) {
next(error);
}
}
async findById(req: AuthRequest, res: Response, next: NextFunction) {
try {
const cs = await costSheetsService.findById(req.params.id);
res.json(ResponseFormatter.success(cs));
} catch (error) {
next(error);
}
}
async findByDeal(req: AuthRequest, res: Response, next: NextFunction) {
try {
const list = await costSheetsService.findByDeal(req.params.dealId);
res.json(ResponseFormatter.success(list));
} catch (error) {
next(error);
}
}
async approve(req: AuthRequest, res: Response, next: NextFunction) {
try {
const cs = await costSheetsService.approve(req.params.id, req.user!.id, req.user!.id);
res.json(ResponseFormatter.success(cs, 'تمت الموافقة على كشف التكلفة - Cost sheet approved'));
} catch (error) {
next(error);
}
}
async reject(req: AuthRequest, res: Response, next: NextFunction) {
try {
const cs = await costSheetsService.reject(req.params.id, req.user!.id);
res.json(ResponseFormatter.success(cs, 'تم رفض كشف التكلفة - Cost sheet rejected'));
} catch (error) {
next(error);
}
}
}
export const costSheetsController = new CostSheetsController();

View File

@@ -0,0 +1,113 @@
import prisma from '../../config/database';
import { AppError } from '../../shared/middleware/errorHandler';
import { AuditLogger } from '../../shared/utils/auditLogger';
interface CreateCostSheetData {
dealId: string;
items: any[];
totalCost: number;
suggestedPrice: number;
profitMargin: number;
}
class CostSheetsService {
async create(data: CreateCostSheetData, userId: string) {
const latest = await prisma.costSheet.findFirst({
where: { dealId: data.dealId },
orderBy: { version: 'desc' },
select: { version: true },
});
const version = latest ? latest.version + 1 : 1;
const costSheetNumber = await this.generateCostSheetNumber();
const costSheet = await prisma.costSheet.create({
data: {
costSheetNumber,
dealId: data.dealId,
version,
items: data.items,
totalCost: data.totalCost,
suggestedPrice: data.suggestedPrice,
profitMargin: data.profitMargin,
status: 'DRAFT',
},
include: {
deal: { include: { contact: true, owner: true } },
},
});
await AuditLogger.log({
entityType: 'COST_SHEET',
entityId: costSheet.id,
action: 'CREATE',
userId,
});
return costSheet;
}
async findById(id: string) {
const cs = await prisma.costSheet.findUnique({
where: { id },
include: {
deal: { include: { contact: true, owner: true } },
},
});
if (!cs) throw new AppError(404, 'كشف التكلفة غير موجود - Cost sheet not found');
return cs;
}
async findByDeal(dealId: string) {
return prisma.costSheet.findMany({
where: { dealId },
orderBy: { version: 'desc' },
});
}
async approve(id: string, approvedBy: string, userId: string) {
const cs = await prisma.costSheet.update({
where: { id },
data: { status: 'APPROVED', approvedBy, approvedAt: new Date() },
include: { deal: true },
});
await AuditLogger.log({
entityType: 'COST_SHEET',
entityId: id,
action: 'APPROVE',
userId,
});
return cs;
}
async reject(id: string, userId: string) {
const cs = await prisma.costSheet.update({
where: { id },
data: { status: 'REJECTED', approvedBy: null, approvedAt: null },
include: { deal: true },
});
await AuditLogger.log({
entityType: 'COST_SHEET',
entityId: id,
action: 'REJECT',
userId,
});
return cs;
}
private async generateCostSheetNumber(): Promise<string> {
const year = new Date().getFullYear();
const prefix = `CS-${year}-`;
const last = await prisma.costSheet.findFirst({
where: { costSheetNumber: { startsWith: prefix } },
orderBy: { createdAt: 'desc' },
select: { costSheetNumber: true },
});
let next = 1;
if (last) {
const parts = last.costSheetNumber.split('-');
next = parseInt(parts[2] || '0') + 1;
}
return `${prefix}${next.toString().padStart(6, '0')}`;
}
}
export const costSheetsService = new CostSheetsService();

View File

@@ -1,6 +1,9 @@
import { Router } from 'express';
import { body, param } from 'express-validator';
import { pipelinesController, dealsController, quotesController } from './crm.controller';
import { costSheetsController } from './costSheets.controller';
import { contractsController } from './contracts.controller';
import { invoicesController } from './invoices.controller';
import { authenticate, authorize } from '../../shared/middleware/auth';
import { validate } from '../../shared/middleware/validation';
@@ -171,5 +174,153 @@ router.post(
quotesController.send
);
// ============= COST SHEETS =============
router.get(
'/deals/:dealId/cost-sheets',
authorize('crm', 'deals', 'read'),
param('dealId').isUUID(),
validate,
costSheetsController.findByDeal
);
router.get(
'/cost-sheets/:id',
authorize('crm', 'deals', 'read'),
param('id').isUUID(),
validate,
costSheetsController.findById
);
router.post(
'/cost-sheets',
authorize('crm', 'deals', 'create'),
[
body('dealId').isUUID(),
body('items').isArray(),
body('totalCost').isNumeric(),
body('suggestedPrice').isNumeric(),
body('profitMargin').isNumeric(),
validate,
],
costSheetsController.create
);
router.post(
'/cost-sheets/:id/approve',
authorize('crm', 'deals', 'update'),
param('id').isUUID(),
validate,
costSheetsController.approve
);
router.post(
'/cost-sheets/:id/reject',
authorize('crm', 'deals', 'update'),
param('id').isUUID(),
validate,
costSheetsController.reject
);
// ============= CONTRACTS =============
router.get(
'/deals/:dealId/contracts',
authorize('crm', 'deals', 'read'),
param('dealId').isUUID(),
validate,
contractsController.findByDeal
);
router.get(
'/contracts/:id',
authorize('crm', 'deals', 'read'),
param('id').isUUID(),
validate,
contractsController.findById
);
router.post(
'/contracts',
authorize('crm', 'deals', 'create'),
[
body('dealId').isUUID(),
body('title').notEmpty().trim(),
body('type').notEmpty().trim(),
body('startDate').isISO8601(),
body('value').isNumeric(),
body('terms').notEmpty().trim(),
validate,
],
contractsController.create
);
router.put(
'/contracts/:id',
authorize('crm', 'deals', 'update'),
param('id').isUUID(),
validate,
contractsController.update
);
router.post(
'/contracts/:id/sign',
authorize('crm', 'deals', 'update'),
param('id').isUUID(),
validate,
contractsController.markSigned
);
// ============= INVOICES =============
router.get(
'/deals/:dealId/invoices',
authorize('crm', 'deals', 'read'),
param('dealId').isUUID(),
validate,
invoicesController.findByDeal
);
router.get(
'/invoices/:id',
authorize('crm', 'deals', 'read'),
param('id').isUUID(),
validate,
invoicesController.findById
);
router.post(
'/invoices',
authorize('crm', 'deals', 'create'),
[
body('items').isArray(),
body('subtotal').isNumeric(),
body('taxAmount').isNumeric(),
body('total').isNumeric(),
body('dueDate').isISO8601(),
validate,
],
invoicesController.create
);
router.put(
'/invoices/:id',
authorize('crm', 'deals', 'update'),
param('id').isUUID(),
validate,
invoicesController.update
);
router.post(
'/invoices/:id/record-payment',
authorize('crm', 'deals', 'update'),
[
param('id').isUUID(),
body('paidAmount').isNumeric(),
validate,
],
invoicesController.recordPayment
);
export default router;

View File

@@ -0,0 +1,61 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from '../../shared/middleware/auth';
import { invoicesService } from './invoices.service';
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
export class InvoicesController {
async create(req: AuthRequest, res: Response, next: NextFunction) {
try {
const invoice = await invoicesService.create(req.body, req.user!.id);
res.status(201).json(
ResponseFormatter.success(invoice, 'تم إنشاء الفاتورة - Invoice created')
);
} catch (error) {
next(error);
}
}
async findById(req: AuthRequest, res: Response, next: NextFunction) {
try {
const invoice = await invoicesService.findById(req.params.id);
res.json(ResponseFormatter.success(invoice));
} catch (error) {
next(error);
}
}
async findByDeal(req: AuthRequest, res: Response, next: NextFunction) {
try {
const list = await invoicesService.findByDeal(req.params.dealId);
res.json(ResponseFormatter.success(list));
} catch (error) {
next(error);
}
}
async update(req: AuthRequest, res: Response, next: NextFunction) {
try {
const invoice = await invoicesService.update(req.params.id, req.body, req.user!.id);
res.json(ResponseFormatter.success(invoice, 'تم تحديث الفاتورة - Invoice updated'));
} catch (error) {
next(error);
}
}
async recordPayment(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { paidAmount, paidDate } = req.body;
const invoice = await invoicesService.recordPayment(
req.params.id,
paidAmount,
paidDate ? new Date(paidDate) : new Date(),
req.user!.id
);
res.json(ResponseFormatter.success(invoice, 'تم تسجيل الدفع - Payment recorded'));
} catch (error) {
next(error);
}
}
}
export const invoicesController = new InvoicesController();

View File

@@ -0,0 +1,130 @@
import prisma from '../../config/database';
import { AppError } from '../../shared/middleware/errorHandler';
import { AuditLogger } from '../../shared/utils/auditLogger';
interface CreateInvoiceData {
dealId?: string;
items: any[];
subtotal: number;
taxAmount: number;
total: number;
dueDate: Date;
}
class InvoicesService {
async create(data: CreateInvoiceData, userId: string) {
const invoiceNumber = await this.generateInvoiceNumber();
const invoice = await prisma.invoice.create({
data: {
invoiceNumber,
dealId: data.dealId || null,
items: data.items,
subtotal: data.subtotal,
taxAmount: data.taxAmount,
total: data.total,
dueDate: new Date(data.dueDate),
status: 'DRAFT',
},
include: {
deal: { include: { contact: true, owner: true } },
},
});
await AuditLogger.log({
entityType: 'INVOICE',
entityId: invoice.id,
action: 'CREATE',
userId,
});
return invoice;
}
async findById(id: string) {
const invoice = await prisma.invoice.findUnique({
where: { id },
include: {
deal: { include: { contact: true, owner: true } },
},
});
if (!invoice) throw new AppError(404, 'الفاتورة غير موجودة - Invoice not found');
return invoice;
}
async findByDeal(dealId: string) {
return prisma.invoice.findMany({
where: { dealId },
orderBy: { createdAt: 'desc' },
});
}
async updateStatus(id: string, status: string, userId: string) {
const invoice = await prisma.invoice.update({
where: { id },
data: { status },
include: { deal: true },
});
await AuditLogger.log({
entityType: 'INVOICE',
entityId: id,
action: 'STATUS_CHANGE',
userId,
changes: { status },
});
return invoice;
}
async recordPayment(id: string, paidAmount: number, paidDate: Date, userId: string) {
const invoice = await prisma.invoice.update({
where: { id },
data: {
status: 'PAID',
paidAmount,
paidDate: new Date(paidDate),
},
include: { deal: true },
});
await AuditLogger.log({
entityType: 'INVOICE',
entityId: id,
action: 'PAYMENT_RECORDED',
userId,
changes: { paidAmount, paidDate },
});
return invoice;
}
async update(id: string, data: Partial<CreateInvoiceData>, userId: string) {
const updateData: Record<string, any> = { ...data };
if (updateData.dueDate) updateData.dueDate = new Date(updateData.dueDate);
const invoice = await prisma.invoice.update({
where: { id },
data: updateData,
include: { deal: { include: { contact: true, owner: true } } },
});
await AuditLogger.log({
entityType: 'INVOICE',
entityId: id,
action: 'UPDATE',
userId,
});
return invoice;
}
private async generateInvoiceNumber(): Promise<string> {
const year = new Date().getFullYear();
const prefix = `INV-${year}-`;
const last = await prisma.invoice.findFirst({
where: { invoiceNumber: { startsWith: prefix } },
orderBy: { createdAt: 'desc' },
select: { invoiceNumber: true },
});
let next = 1;
if (last) {
const parts = last.invoiceNumber.split('-');
next = parseInt(parts[2] || '0') + 1;
}
return `${prefix}${next.toString().padStart(6, '0')}`;
}
}
export const invoicesService = new InvoicesService();

View File

@@ -0,0 +1,39 @@
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({
where: {
archivedAt: null,
},
}),
prisma.task.count({
where: {
status: { notIn: ['COMPLETED', 'CANCELLED'] },
},
}),
prisma.notification.count({
where: {
userId,
isRead: false,
},
}),
]);
res.json(
ResponseFormatter.success({
contacts: contactsCount,
activeTasks: activeTasksCount,
notifications: unreadNotificationsCount,
})
);
}
}
export default new DashboardController();

View File

@@ -0,0 +1,9 @@
import { Router } from 'express';
import { authenticate } from '../../shared/middleware/auth';
import dashboardController from './dashboard.controller';
const router = Router();
router.get('/stats', authenticate, dashboardController.getStats.bind(dashboardController));
export default router;

View File

@@ -19,14 +19,20 @@ export class HRController {
async findAllEmployees(req: AuthRequest, res: Response, next: NextFunction) {
try {
const page = parseInt(req.query.page as string) || 1;
const pageSize = parseInt(req.query.pageSize as string) || 20;
const rawPage = parseInt(req.query.page as string, 10);
const rawPageSize = parseInt(req.query.pageSize as string, 10);
const page = Number.isNaN(rawPage) || rawPage < 1 ? 1 : rawPage;
const pageSize = Number.isNaN(rawPageSize) || rawPageSize < 1 || rawPageSize > 100 ? 20 : rawPageSize;
const filters = {
search: req.query.search,
departmentId: req.query.departmentId,
status: req.query.status,
};
const rawSearch = req.query.search as string;
const rawDepartmentId = req.query.departmentId as string;
const rawStatus = req.query.status as string;
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const filters: Record<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);
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 ==========
async createLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) {
@@ -112,6 +128,29 @@ export class HRController {
}
}
async rejectLeave(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { rejectedReason } = req.body;
const leave = await hrService.rejectLeave(req.params.id, rejectedReason || '', req.user!.id);
res.json(ResponseFormatter.success(leave, 'تم رفض طلب الإجازة - Leave rejected'));
} catch (error) {
next(error);
}
}
async findAllLeaves(req: AuthRequest, res: Response, next: NextFunction) {
try {
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20));
const employeeId = req.query.employeeId as string | undefined;
const status = req.query.status as string | undefined;
const result = await hrService.findAllLeaves({ employeeId, status }, page, pageSize);
res.json(ResponseFormatter.paginated(result.leaves, result.total, result.page, result.pageSize));
} catch (error) {
next(error);
}
}
// ========== SALARIES ==========
async processSalary(req: AuthRequest, res: Response, next: NextFunction) {
@@ -135,6 +174,42 @@ export class HRController {
}
}
async getDepartmentsHierarchy(req: AuthRequest, res: Response, next: NextFunction) {
try {
const tree = await hrService.getDepartmentsHierarchy();
res.json(ResponseFormatter.success(tree));
} catch (error) {
next(error);
}
}
async createDepartment(req: AuthRequest, res: Response, next: NextFunction) {
try {
const department = await hrService.createDepartment(req.body, req.user!.id);
res.status(201).json(ResponseFormatter.success(department, 'تم إضافة القسم بنجاح - Department created'));
} catch (error) {
next(error);
}
}
async updateDepartment(req: AuthRequest, res: Response, next: NextFunction) {
try {
const department = await hrService.updateDepartment(req.params.id, req.body, req.user!.id);
res.json(ResponseFormatter.success(department, 'تم تحديث القسم - Department updated'));
} catch (error) {
next(error);
}
}
async deleteDepartment(req: AuthRequest, res: Response, next: NextFunction) {
try {
await hrService.deleteDepartment(req.params.id, req.user!.id);
res.json(ResponseFormatter.success({ success: true }, 'تم حذف القسم - Department deleted'));
} catch (error) {
next(error);
}
}
// ========== POSITIONS ==========
async findAllPositions(req: AuthRequest, res: Response, next: NextFunction) {
@@ -145,6 +220,198 @@ export class HRController {
next(error);
}
}
// ========== LOANS ==========
async findAllLoans(req: AuthRequest, res: Response, next: NextFunction) {
try {
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20));
const employeeId = req.query.employeeId as string | undefined;
const status = req.query.status as string | undefined;
const result = await hrService.findAllLoans({ employeeId, status }, page, pageSize);
res.json(ResponseFormatter.paginated(result.loans, result.total, result.page, result.pageSize));
} catch (error) {
next(error);
}
}
async findLoanById(req: AuthRequest, res: Response, next: NextFunction) {
try {
const loan = await hrService.findLoanById(req.params.id);
res.json(ResponseFormatter.success(loan));
} catch (error) {
next(error);
}
}
async createLoan(req: AuthRequest, res: Response, next: NextFunction) {
try {
const loan = await hrService.createLoan(req.body, req.user!.id);
res.status(201).json(ResponseFormatter.success(loan, 'تم إنشاء طلب القرض - Loan request created'));
} catch (error) {
next(error);
}
}
async approveLoan(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { startDate } = req.body;
const loan = await hrService.approveLoan(req.params.id, req.user!.id, startDate ? new Date(startDate) : new Date(), req.user!.id);
res.json(ResponseFormatter.success(loan, 'تمت الموافقة على القرض - Loan approved'));
} catch (error) {
next(error);
}
}
async rejectLoan(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { rejectedReason } = req.body;
const loan = await hrService.rejectLoan(req.params.id, rejectedReason || '', req.user!.id);
res.json(ResponseFormatter.success(loan, 'تم رفض القرض - Loan rejected'));
} catch (error) {
next(error);
}
}
async recordLoanInstallmentPayment(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { installmentId, paidDate } = req.body;
const loan = await hrService.recordLoanInstallmentPayment(req.params.id, installmentId, paidDate ? new Date(paidDate) : new Date(), req.user!.id);
res.json(ResponseFormatter.success(loan, 'تم تسجيل الدفعة - Payment recorded'));
} catch (error) {
next(error);
}
}
// ========== PURCHASE REQUESTS ==========
async findAllPurchaseRequests(req: AuthRequest, res: Response, next: NextFunction) {
try {
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20));
const employeeId = req.query.employeeId as string | undefined;
const status = req.query.status as string | undefined;
const result = await hrService.findAllPurchaseRequests({ employeeId, status }, page, pageSize);
res.json(ResponseFormatter.paginated(result.purchaseRequests, result.total, result.page, result.pageSize));
} catch (error) {
next(error);
}
}
async findPurchaseRequestById(req: AuthRequest, res: Response, next: NextFunction) {
try {
const pr = await hrService.findPurchaseRequestById(req.params.id);
res.json(ResponseFormatter.success(pr));
} catch (error) {
next(error);
}
}
async createPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const pr = await hrService.createPurchaseRequest(req.body, req.user!.id);
res.status(201).json(ResponseFormatter.success(pr, 'تم إنشاء طلب الشراء - Purchase request created'));
} catch (error) {
next(error);
}
}
async approvePurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const pr = await hrService.approvePurchaseRequest(req.params.id, req.user!.id, req.user!.id);
res.json(ResponseFormatter.success(pr, 'تمت الموافقة على طلب الشراء - Purchase request approved'));
} catch (error) {
next(error);
}
}
async rejectPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { rejectedReason } = req.body;
const pr = await hrService.rejectPurchaseRequest(req.params.id, rejectedReason || '', req.user!.id);
res.json(ResponseFormatter.success(pr, 'تم رفض طلب الشراء - Purchase request rejected'));
} catch (error) {
next(error);
}
}
// ========== LEAVE ENTITLEMENTS ==========
async getLeaveBalance(req: AuthRequest, res: Response, next: NextFunction) {
try {
const employeeId = req.params.employeeId || req.query.employeeId as string;
const year = parseInt(req.query.year as string) || new Date().getFullYear();
const balance = await hrService.getLeaveBalance(employeeId, year);
res.json(ResponseFormatter.success(balance));
} catch (error) {
next(error);
}
}
async findAllLeaveEntitlements(req: AuthRequest, res: Response, next: NextFunction) {
try {
const employeeId = req.query.employeeId as string | undefined;
const year = req.query.year ? parseInt(req.query.year as string) : undefined;
const list = await hrService.findAllLeaveEntitlements(employeeId, year);
res.json(ResponseFormatter.success(list));
} catch (error) {
next(error);
}
}
async upsertLeaveEntitlement(req: AuthRequest, res: Response, next: NextFunction) {
try {
const ent = await hrService.upsertLeaveEntitlement(req.body, req.user!.id);
res.json(ResponseFormatter.success(ent, 'تم حفظ رصيد الإجازة - Leave entitlement saved'));
} catch (error) {
next(error);
}
}
// ========== EMPLOYEE CONTRACTS ==========
async findAllEmployeeContracts(req: AuthRequest, res: Response, next: NextFunction) {
try {
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20));
const employeeId = req.query.employeeId as string | undefined;
const status = req.query.status as string | undefined;
const result = await hrService.findAllEmployeeContracts({ employeeId, status }, page, pageSize);
res.json(ResponseFormatter.paginated(result.contracts, result.total, result.page, result.pageSize));
} catch (error) {
next(error);
}
}
async findEmployeeContractById(req: AuthRequest, res: Response, next: NextFunction) {
try {
const c = await hrService.findEmployeeContractById(req.params.id);
res.json(ResponseFormatter.success(c));
} catch (error) {
next(error);
}
}
async createEmployeeContract(req: AuthRequest, res: Response, next: NextFunction) {
try {
const data = { ...req.body, startDate: new Date(req.body.startDate), endDate: req.body.endDate ? new Date(req.body.endDate) : undefined };
const c = await hrService.createEmployeeContract(data, req.user!.id);
res.status(201).json(ResponseFormatter.success(c, 'تم إنشاء العقد - Contract created'));
} catch (error) {
next(error);
}
}
async updateEmployeeContract(req: AuthRequest, res: Response, next: NextFunction) {
try {
const data = { ...req.body, endDate: req.body.endDate ? new Date(req.body.endDate) : undefined };
const c = await hrService.updateEmployeeContract(req.params.id, data, req.user!.id);
res.json(ResponseFormatter.success(c, 'تم تحديث العقد - Contract updated'));
} catch (error) {
next(error);
}
}
}
export const hrController = new HRController();

View File

@@ -1,12 +1,64 @@
import { Router } from 'express';
import { body, param } from 'express-validator';
import { hrController } from './hr.controller';
import { portalController } from './portal.controller';
import { authenticate, authorize } from '../../shared/middleware/auth';
import { validate } from '../../shared/middleware/validation';
const router = Router();
router.use(authenticate);
// ========== EMPLOYEE PORTAL (authenticate only, scoped by employeeId) ==========
router.get('/portal/me', portalController.getMe);
router.get('/portal/loans', portalController.getMyLoans);
router.post('/portal/loans', portalController.submitLoanRequest);
router.get('/portal/leave-balance', portalController.getMyLeaveBalance);
router.get('/portal/leaves', portalController.getMyLeaves);
router.post('/portal/leaves', portalController.submitLeaveRequest);
router.get(
'/portal/managed-leaves',
authorize('department_leave_requests', '*', 'read'),
portalController.getManagedLeaves
);
router.post(
'/portal/managed-leaves/:id/approve',
authorize('department_leave_requests', '*', 'approve'),
portalController.approveManagedLeave
);
router.post(
'/portal/managed-leaves/:id/reject',
authorize('department_leave_requests', '*', 'approve'),
portalController.rejectManagedLeave
);
router.get('/portal/overtime-requests', portalController.getMyOvertimeRequests);
router.post('/portal/overtime-requests', portalController.submitOvertimeRequest);
router.get(
'/portal/managed-overtime-requests',
authorize('department_overtime_requests', '*', 'view'),
portalController.getManagedOvertimeRequests
);
router.post(
'/portal/managed-overtime-requests/:attendanceId/approve',
authorize('department_overtime_requests', '*', 'approve'),
portalController.approveManagedOvertimeRequest
);
router.post(
'/portal/managed-overtime-requests/:attendanceId/reject',
authorize('department_overtime_requests', '*', 'approve'),
portalController.rejectManagedOvertimeRequest
);
router.get('/portal/purchase-requests', portalController.getMyPurchaseRequests);
router.post('/portal/purchase-requests', portalController.submitPurchaseRequest);
router.get('/portal/attendance', portalController.getMyAttendance);
router.get('/portal/salaries', portalController.getMySalaries);
// ========== EMPLOYEES ==========
router.get('/employees', authorize('hr', 'employees', 'read'), hrController.findAllEmployees);
@@ -19,11 +71,14 @@ router.post('/employees/:id/terminate', authorize('hr', 'employees', 'terminate'
router.post('/attendance', authorize('hr', 'attendance', 'create'), hrController.recordAttendance);
router.get('/attendance/:employeeId', authorize('hr', 'attendance', 'read'), hrController.getAttendance);
router.post('/attendance/sync', authorize('hr', 'attendance', 'create'), hrController.bulkSyncAttendance);
// ========== LEAVES ==========
router.get('/leaves', authorize('hr', 'leaves', 'read'), hrController.findAllLeaves);
router.post('/leaves', authorize('hr', 'leaves', 'create'), hrController.createLeaveRequest);
router.post('/leaves/:id/approve', authorize('hr', 'leaves', 'approve'), hrController.approveLeave);
router.post('/leaves/:id/reject', authorize('hr', 'leaves', 'approve'), hrController.rejectLeave);
// ========== SALARIES ==========
@@ -32,10 +87,43 @@ router.post('/salaries/process', authorize('hr', 'salaries', 'process'), hrContr
// ========== DEPARTMENTS ==========
router.get('/departments', authorize('hr', 'all', 'read'), hrController.findAllDepartments);
router.get('/departments/hierarchy', authorize('hr', 'all', 'read'), hrController.getDepartmentsHierarchy);
router.post('/departments', authorize('hr', 'all', 'create'), hrController.createDepartment);
router.put('/departments/:id', authorize('hr', 'all', 'update'), hrController.updateDepartment);
router.delete('/departments/:id', authorize('hr', 'all', 'delete'), hrController.deleteDepartment);
// ========== POSITIONS ==========
router.get('/positions', authorize('hr', 'all', 'read'), hrController.findAllPositions);
export default router;
// ========== 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;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,200 @@
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 getManagedLeaves(req: AuthRequest, res: Response, next: NextFunction) {
try {
const status = req.query.status as string | undefined;
const leaves = await portalService.getManagedLeaves(req.user?.employeeId, status);
res.json(ResponseFormatter.success(leaves));
} catch (error) {
next(error);
}
}
async approveManagedLeave(req: AuthRequest, res: Response, next: NextFunction) {
try {
const leave = await portalService.approveManagedLeave(req.user?.employeeId, req.params.id, req.user!.id);
res.json(ResponseFormatter.success(leave, 'تمت الموافقة على الإجازة من قبل مدير القسم - Leave approved by department manager'));
} catch (error) {
next(error);
}
}
async rejectManagedLeave(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { rejectedReason } = req.body;
const leave = await portalService.rejectManagedLeave(
req.user?.employeeId,
req.params.id,
rejectedReason || '',
req.user!.id
);
res.json(ResponseFormatter.success(leave, 'تم رفض طلب الإجازة من قبل مدير القسم - Leave rejected by department manager'));
} catch (error) {
next(error);
}
}
async getMyOvertimeRequests(req: AuthRequest, res: Response, next: NextFunction) {
try {
const data = await portalService.getMyOvertimeRequests(req.user?.employeeId);
res.json(ResponseFormatter.success(data));
} catch (error) {
next(error);
}
}
async submitOvertimeRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const data = {
date: req.body.date,
hours: req.body.hours,
reason: req.body.reason,
};
const result = await portalService.submitOvertimeRequest(req.user?.employeeId, data, req.user!.id);
res.status(201).json(ResponseFormatter.success(result, 'تم إرسال طلب الساعات الإضافية'));
} catch (error) {
next(error);
}
}
async getManagedOvertimeRequests(req: AuthRequest, res: Response, next: NextFunction) {
try {
const data = await portalService.getManagedOvertimeRequests(req.user?.employeeId);
res.json(ResponseFormatter.success(data));
} catch (error) {
next(error);
}
}
async approveManagedOvertimeRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const result = await portalService.approveManagedOvertimeRequest(
req.user?.employeeId,
req.params.attendanceId,
req.user!.id
);
res.json(ResponseFormatter.success(result, 'تمت الموافقة على طلب الساعات الإضافية'));
} catch (error) {
next(error);
}
}
async rejectManagedOvertimeRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const result = await portalService.rejectManagedOvertimeRequest(
req.user?.employeeId,
req.params.attendanceId,
req.body.rejectedReason || '',
req.user!.id
);
res.json(ResponseFormatter.success(result, 'تم رفض طلب الساعات الإضافية'));
} catch (error) {
next(error);
}
}
async submitLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const data = {
...req.body,
startDate: new Date(req.body.startDate),
endDate: new Date(req.body.endDate),
};
const leave = await portalService.submitLeaveRequest(req.user?.employeeId, data, req.user!.id);
res.status(201).json(ResponseFormatter.success(leave, 'تم إرسال طلب الإجازة - Leave request submitted'));
} catch (error) {
next(error);
}
}
async getMyPurchaseRequests(req: AuthRequest, res: Response, next: NextFunction) {
try {
const requests = await portalService.getMyPurchaseRequests(req.user?.employeeId);
res.json(ResponseFormatter.success(requests));
} catch (error) {
next(error);
}
}
async submitPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const pr = await portalService.submitPurchaseRequest(req.user?.employeeId, req.body, req.user!.id);
res.status(201).json(ResponseFormatter.success(pr, 'تم إرسال طلب الشراء - Purchase request submitted'));
} catch (error) {
next(error);
}
}
async getMyAttendance(req: AuthRequest, res: Response, next: NextFunction) {
try {
const month = req.query.month ? parseInt(req.query.month as string) : undefined;
const year = req.query.year ? parseInt(req.query.year as string) : undefined;
const attendance = await portalService.getMyAttendance(req.user?.employeeId, month, year);
res.json(ResponseFormatter.success(attendance));
} catch (error) {
next(error);
}
}
async getMySalaries(req: AuthRequest, res: Response, next: NextFunction) {
try {
const salaries = await portalService.getMySalaries(req.user?.employeeId);
res.json(ResponseFormatter.success(salaries));
} catch (error) {
next(error);
}
}
}
export const portalController = new PortalController();

View File

@@ -0,0 +1,466 @@
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,
},
};
}
private buildOvertimeRequestNote(
hours: number,
reason: string,
status: 'PENDING' | 'APPROVED' | 'REJECTED',
rejectedReason?: string
) {
const safeReason = String(reason || '').replace(/\|/g, '/').trim();
const safeRejectedReason = String(rejectedReason || '').replace(/\|/g, '/').trim();
let note = `OT_REQUEST|status=${status}|hours=${hours}|reason=${safeReason}`;
if (status === 'REJECTED' && safeRejectedReason) {
note += `|rejectedReason=${safeRejectedReason}`;
}
return note;
}
private isOvertimeRequestNote(notes?: string | null) {
return !!notes && notes.startsWith('OT_REQUEST|');
}
private parseOvertimeRequestNote(notes?: string | null) {
if (!notes || !notes.startsWith('OT_REQUEST|')) return null;
const parts = notes.split('|');
const data: Record<string, string> = {};
for (const part of parts.slice(1)) {
const idx = part.indexOf('=');
if (idx > -1) {
const key = part.slice(0, idx);
const value = part.slice(idx + 1);
data[key] = value;
}
}
return {
status: data.status || 'PENDING',
hours: Number(data.hours || 0),
reason: data.reason || '',
rejectedReason: data.rejectedReason || '',
};
}
private formatOvertimeRequest(attendance: any) {
const parsed = this.parseOvertimeRequestNote(attendance.notes);
if (!parsed) return null;
return {
id: attendance.id,
attendanceId: attendance.id,
date: attendance.date,
hours: parsed.hours || Number(attendance.overtimeHours || 0),
reason: parsed.reason,
status: parsed.status,
rejectedReason: parsed.rejectedReason || '',
createdAt: attendance.createdAt,
employee: attendance.employee,
};
}
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 getMyOvertimeRequests(employeeId: string | undefined) {
const empId = this.requireEmployeeId(employeeId);
const rows = await prisma.attendance.findMany({
where: {
employeeId: empId,
notes: {
startsWith: 'OT_REQUEST|',
},
},
orderBy: {
date: 'desc',
},
take: 100,
});
return rows
.map((row) => this.formatOvertimeRequest(row))
.filter(Boolean);
}
async submitOvertimeRequest(
employeeId: string | undefined,
data: { date: string; hours: number; reason: string },
userId: string
) {
const empId = this.requireEmployeeId(employeeId);
if (!data.date) {
throw new AppError(400, 'تاريخ الساعات الإضافية مطلوب');
}
if (!data.hours || Number(data.hours) <= 0) {
throw new AppError(400, 'عدد الساعات غير صالح');
}
if (!data.reason || !data.reason.trim()) {
throw new AppError(400, 'سبب الساعات الإضافية مطلوب');
}
const requestDate = new Date(data.date);
const note = this.buildOvertimeRequestNote(Number(data.hours), data.reason.trim(), 'PENDING');
const existing = await prisma.attendance.findFirst({
where: {
employeeId: empId,
date: requestDate,
},
});
let attendance;
if (existing) {
attendance = await prisma.attendance.update({
where: { id: existing.id },
data: {
overtimeHours: Number(data.hours),
notes: note,
},
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
reportingToId: true,
},
},
},
});
} else {
attendance = await prisma.attendance.create({
data: {
employeeId: empId,
date: requestDate,
status: 'PRESENT',
overtimeHours: Number(data.hours),
notes: note,
},
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
reportingToId: true,
},
},
},
});
}
return this.formatOvertimeRequest(attendance);
}
async getManagedOvertimeRequests(employeeId: string | undefined) {
this.requireEmployeeId(employeeId);
const rows = await prisma.attendance.findMany({
where: {
notes: {
startsWith: 'OT_REQUEST|',
},
},
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
reportingToId: true,
},
},
},
orderBy: {
date: 'desc',
},
take: 100,
});
return rows
.map((row) => this.formatOvertimeRequest(row))
.filter((row: any) => row && row.status === 'PENDING');
}
async approveManagedOvertimeRequest(
managerEmployeeId: string | undefined,
attendanceId: string,
userId: string
) {
this.requireEmployeeId(managerEmployeeId);
const attendance = await prisma.attendance.findUnique({
where: { id: attendanceId },
include: {
employee: {
select: {
id: true,
reportingToId: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
},
},
},
});
if (!attendance) {
throw new AppError(404, 'طلب الساعات الإضافية غير موجود');
}
if (!this.isOvertimeRequestNote(attendance.notes)) {
throw new AppError(400, 'هذا السجل ليس طلب ساعات إضافية');
}
const parsed = this.parseOvertimeRequestNote(attendance.notes);
if (!parsed || parsed.status !== 'PENDING') {
throw new AppError(400, 'هذا الطلب ليس بحالة معلقة');
}
const updatedNote = this.buildOvertimeRequestNote(parsed.hours, parsed.reason, 'APPROVED');
const updated = await prisma.attendance.update({
where: { id: attendanceId },
data: {
overtimeHours: parsed.hours,
notes: updatedNote,
},
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
reportingToId: true,
},
},
},
});
return this.formatOvertimeRequest(updated);
}
async rejectManagedOvertimeRequest(
managerEmployeeId: string | undefined,
attendanceId: string,
rejectedReason: string,
userId: string
) {
this.requireEmployeeId(managerEmployeeId);
if (!rejectedReason || !rejectedReason.trim()) {
throw new AppError(400, 'سبب الرفض مطلوب');
}
const attendance = await prisma.attendance.findUnique({
where: { id: attendanceId },
include: {
employee: {
select: {
id: true,
reportingToId: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
},
},
},
});
if (!attendance) {
throw new AppError(404, 'طلب الساعات الإضافية غير موجود');
}
if (!this.isOvertimeRequestNote(attendance.notes)) {
throw new AppError(400, 'هذا السجل ليس طلب ساعات إضافية');
}
const parsed = this.parseOvertimeRequestNote(attendance.notes);
if (!parsed || parsed.status !== 'PENDING') {
throw new AppError(400, 'هذا الطلب ليس بحالة معلقة');
}
const updatedNote = this.buildOvertimeRequestNote(
parsed.hours,
parsed.reason,
'REJECTED',
rejectedReason.trim()
);
const updated = await prisma.attendance.update({
where: { id: attendanceId },
data: {
notes: updatedNote,
},
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
reportingToId: true,
},
},
},
});
return this.formatOvertimeRequest(updated);
}
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 getManagedLeaves(employeeId: string | undefined, status?: string) {
this.requireEmployeeId(employeeId);
return hrService.findManagedLeaves(status);
}
async approveManagedLeave(employeeId: string | undefined, leaveId: string, userId: string) {
this.requireEmployeeId(employeeId);
return hrService.managerApproveLeave(leaveId, userId);
}
async rejectManagedLeave(
employeeId: string | undefined,
leaveId: string,
rejectedReason: string,
userId: string
) {
this.requireEmployeeId(employeeId);
return hrService.managerRejectLeave(leaveId, rejectedReason, userId);
}
async submitLeaveRequest(
employeeId: string | undefined,
data: { leaveType: string; startDate: Date; endDate: Date; reason?: string },
userId: string
) {
const empId = this.requireEmployeeId(employeeId);
return hrService.createLeaveRequest({ ...data, employeeId: empId }, userId);
}
async getMyPurchaseRequests(employeeId: string | undefined) {
const empId = this.requireEmployeeId(employeeId);
return prisma.purchaseRequest.findMany({
where: { employeeId: empId },
orderBy: { createdAt: 'desc' },
});
}
async submitPurchaseRequest(
employeeId: string | undefined,
data: { items: any[]; reason?: string; priority?: string },
userId: string
) {
const empId = this.requireEmployeeId(employeeId);
return hrService.createPurchaseRequest({ ...data, employeeId: empId }, userId);
}
async getMyAttendance(employeeId: string | undefined, month?: number, year?: number) {
const empId = this.requireEmployeeId(employeeId);
const now = new Date();
const m = month ?? now.getMonth() + 1;
const y = year ?? now.getFullYear();
return hrService.getAttendance(empId, m, y);
}
async getMySalaries(employeeId: string | undefined) {
const empId = this.requireEmployeeId(employeeId);
return prisma.salary.findMany({
where: { employeeId: empId },
orderBy: [{ year: 'desc' }, { month: 'desc' }],
take: 24,
});
}
}
export const portalService = new PortalService();

View File

@@ -0,0 +1,265 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from '../../shared/middleware/auth';
import { tendersService } from './tenders.service';
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
export class TendersController {
async checkDuplicates(req: AuthRequest, res: Response, next: NextFunction) {
try {
const duplicates = await tendersService.findPossibleDuplicates(req.body);
res.json(ResponseFormatter.success(duplicates));
} catch (error) {
next(error);
}
}
async create(req: AuthRequest, res: Response, next: NextFunction) {
try {
const result = await tendersService.create(req.body, req.user!.id);
res.status(201).json(
ResponseFormatter.success(
result,
'تم إنشاء المناقصة بنجاح - Tender created successfully'
)
);
} catch (error) {
next(error);
}
}
async findAll(req: AuthRequest, res: Response, next: NextFunction) {
try {
const page = parseInt(req.query.page as string) || 1;
const pageSize = parseInt(req.query.pageSize as string) || 20;
const filters = {
search: req.query.search,
status: req.query.status,
source: req.query.source,
announcementType: req.query.announcementType,
};
const result = await tendersService.findAll(filters, page, pageSize);
res.json(
ResponseFormatter.paginated(
result.tenders,
result.total,
result.page,
result.pageSize
)
);
} catch (error) {
next(error);
}
}
async findById(req: AuthRequest, res: Response, next: NextFunction) {
try {
const tender = await tendersService.findById(req.params.id);
res.json(ResponseFormatter.success(tender));
} catch (error) {
next(error);
}
}
async update(req: AuthRequest, res: Response, next: NextFunction) {
try {
const tender = await tendersService.update(
req.params.id,
req.body,
req.user!.id
);
res.json(
ResponseFormatter.success(
tender,
'تم تحديث المناقصة بنجاح - Tender updated successfully'
)
);
} catch (error) {
next(error);
}
}
async createDirective(req: AuthRequest, res: Response, next: NextFunction) {
try {
const directive = await tendersService.createDirective(
req.params.id,
req.body,
req.user!.id
);
res.status(201).json(
ResponseFormatter.success(
directive,
'تم إصدار التوجيه بنجاح - Directive created successfully'
)
);
} catch (error) {
next(error);
}
}
async updateDirective(req: AuthRequest, res: Response, next: NextFunction) {
try {
const directive = await tendersService.updateDirective(
req.params.directiveId,
req.body,
req.user!.id
);
res.json(
ResponseFormatter.success(
directive,
'تم تحديث التوجيه بنجاح - Directive updated successfully'
)
);
} catch (error) {
next(error);
}
}
async getHistory(req: AuthRequest, res: Response, next: NextFunction) {
try {
const history = await tendersService.getHistory(req.params.id);
res.json(ResponseFormatter.success(history));
} catch (error) {
next(error);
}
}
async convertToDeal(req: AuthRequest, res: Response, next: NextFunction) {
try {
const deal = await tendersService.convertToDeal(
req.params.id,
{
contactId: req.body.contactId,
pipelineId: req.body.pipelineId,
ownerId: req.body.ownerId,
},
req.user!.id
);
res.status(201).json(
ResponseFormatter.success(
deal,
'تم تحويل المناقصة إلى فرصة بنجاح - Tender converted to deal successfully'
)
);
} catch (error) {
next(error);
}
}
async getSourceValues(_req: AuthRequest, res: Response, next: NextFunction) {
try {
const values = tendersService.getSourceValues();
res.json(ResponseFormatter.success(values));
} catch (error) {
next(error);
}
}
async getAnnouncementTypeValues(
_req: AuthRequest,
res: Response,
next: NextFunction
) {
try {
const values = tendersService.getAnnouncementTypeValues();
res.json(ResponseFormatter.success(values));
} catch (error) {
next(error);
}
}
async getDirectiveTypeValues(
_req: AuthRequest,
res: Response,
next: NextFunction
) {
try {
const values = tendersService.getDirectiveTypeValues();
res.json(ResponseFormatter.success(values));
} catch (error) {
next(error);
}
}
async uploadTenderAttachment(req: AuthRequest, res: Response, next: NextFunction) {
try {
if (!req.file) {
return res.status(400).json(
ResponseFormatter.error('No file uploaded', 'Missing file')
);
}
const attachment = await tendersService.uploadTenderAttachment(
req.params.id,
req.file,
req.user!.id,
(req.body.category as string) || undefined
);
res.status(201).json(
ResponseFormatter.success(
attachment,
'تم رفع الملف بنجاح - File uploaded successfully'
)
);
} catch (error) {
next(error);
}
}
async uploadDirectiveAttachment(req: AuthRequest, res: Response, next: NextFunction) {
try {
if (!req.file) {
return res.status(400).json(
ResponseFormatter.error('No file uploaded', 'Missing file')
);
}
const attachment = await tendersService.uploadDirectiveAttachment(
req.params.directiveId,
req.file,
req.user!.id,
(req.body.category as string) || undefined
);
res.status(201).json(
ResponseFormatter.success(
attachment,
'تم رفع الملف بنجاح - File uploaded successfully'
)
);
} catch (error) {
next(error);
}
}
async viewAttachment(req: AuthRequest, res: Response, next: NextFunction) {
try {
const file = await tendersService.getAttachmentFile(req.params.attachmentId)
const fs = require('fs')
if (!fs.existsSync(file)) {
return res.status(404).json(
ResponseFormatter.error('File not found', 'الملف غير موجود')
)
}
const path = require('path')
return res.sendFile(path.resolve(file))
} catch (error) {
console.error(error)
next(error)
}
}
async deleteAttachment(req: AuthRequest, res: Response, next: NextFunction) {
try {
await tendersService.deleteAttachment(req.params.attachmentId)
res.json(ResponseFormatter.success(null, 'Deleted'))
} catch (error) {
next(error)
}
}
}
export const tendersController = new TendersController();

View File

@@ -0,0 +1,195 @@
import { Router } from 'express';
import { body, param } from 'express-validator';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { tendersController } from './tenders.controller';
import { authenticate, authorize } from '../../shared/middleware/auth';
import { validate } from '../../shared/middleware/validation';
import { config } from '../../config';
const router = Router();
const uploadDir = path.join(config.upload.path, 'tenders');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadDir),
filename: (_req, file, cb) => {
const safeName = (file.originalname || 'file').replace(/[^a-zA-Z0-9.-]/g, '_');
cb(null, `${crypto.randomUUID()}-${safeName}`);
},
});
const upload = multer({
storage,
limits: { fileSize: config.upload.maxFileSize },
});
// View attachment
router.get(
'/attachments/:attachmentId/view',
param('attachmentId').isUUID(),
validate,
tendersController.viewAttachment
)
router.use(authenticate);
// Enum/lookup routes (no resource id) - place before /:id routes
router.get(
'/source-values',
authorize('tenders', 'tenders', 'read'),
tendersController.getSourceValues
);
router.get(
'/announcement-type-values',
authorize('tenders', 'tenders', 'read'),
tendersController.getAnnouncementTypeValues
);
router.get(
'/directive-type-values',
authorize('tenders', 'tenders', 'read'),
tendersController.getDirectiveTypeValues
);
router.post(
'/check-duplicates',
authorize('tenders', 'tenders', 'create'),
[
body('issuingBodyName').optional().trim(),
body('title').optional().trim(),
body('tenderNumber').optional().trim(),
body('termsValue').optional().isNumeric(),
body('bondValue').optional().isNumeric(),
body('announcementDate').optional().isISO8601(),
body('closingDate').optional().isISO8601(),
],
validate,
tendersController.checkDuplicates
);
// List & create tenders
router.get(
'/',
authorize('tenders', 'tenders', 'read'),
tendersController.findAll
);
router.post(
'/',
authorize('tenders', 'tenders', 'create'),
[
body('tenderNumber').notEmpty().trim(),
body('issuingBodyName').notEmpty().trim(),
body('title').notEmpty().trim(),
body('termsValue').isNumeric(),
body('bondValue').isNumeric(),
body('announcementDate').isISO8601(),
body('closingDate').isISO8601(),
body('source').notEmpty(),
body('announcementType').notEmpty(),
],
validate,
tendersController.create
);
// Tender by id
router.get(
'/:id',
authorize('tenders', 'tenders', 'read'),
param('id').isUUID(),
validate,
tendersController.findById
);
router.put(
'/:id',
authorize('tenders', 'tenders', 'update'),
param('id').isUUID(),
validate,
tendersController.update
);
// Tender history
router.get(
'/:id/history',
authorize('tenders', 'tenders', 'read'),
param('id').isUUID(),
validate,
tendersController.getHistory
);
// Convert to deal
router.post(
'/:id/convert-to-deal',
authorize('tenders', 'tenders', 'update'),
[
param('id').isUUID(),
body('contactId').isUUID(),
body('pipelineId').isUUID(),
body('ownerId').optional().isUUID(),
],
validate,
tendersController.convertToDeal
);
// Directives
router.post(
'/:id/directives',
authorize('tenders', 'directives', 'create'),
[
param('id').isUUID(),
body('type').isIn(['BUY_TERMS', 'VISIT_CLIENT', 'MEET_COMMITTEE', 'PREPARE_TO_BID']),
body('assignedToEmployeeId').isUUID(),
body('notes').optional().trim(),
],
validate,
tendersController.createDirective
);
// Update directive (e.g. complete task) - route with directiveId
router.put(
'/directives/:directiveId',
authorize('tenders', 'directives', 'update'),
[
param('directiveId').isUUID(),
body('status').optional().isIn(['PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED']),
body('completionNotes').optional().trim(),
],
validate,
tendersController.updateDirective
);
// File uploads
router.post(
'/:id/attachments',
authorize('tenders', 'tenders', 'update'),
param('id').isUUID(),
validate,
upload.single('file'),
tendersController.uploadTenderAttachment
);
router.post(
'/directives/:directiveId/attachments',
authorize('tenders', 'directives', 'update'),
param('directiveId').isUUID(),
validate,
upload.single('file'),
tendersController.uploadDirectiveAttachment
);
export default router;
// Delete attachment
router.delete(
'/attachments/:attachmentId',
authorize('tenders', 'tenders', 'update'),
param('attachmentId').isUUID(),
validate,
tendersController.deleteAttachment
)

View File

@@ -0,0 +1,740 @@
import prisma from '../../config/database';
import { AppError } from '../../shared/middleware/errorHandler';
import { AuditLogger } from '../../shared/utils/auditLogger';
import { Prisma } from '@prisma/client';
import path from 'path';
import fs from 'fs'
const TENDER_SOURCE_VALUES = [
'GOVERNMENT_SITE',
'OFFICIAL_GAZETTE',
'PERSONAL',
'PARTNER',
'WHATSAPP_TELEGRAM',
'PORTAL',
'EMAIL',
'MANUAL',
] as const;
const ANNOUNCEMENT_TYPE_VALUES = [
'FIRST',
'RE_ANNOUNCEMENT_2',
'RE_ANNOUNCEMENT_3',
'RE_ANNOUNCEMENT_4',
] as const;
const DIRECTIVE_TYPE_VALUES = [
'BUY_TERMS',
'VISIT_CLIENT',
'MEET_COMMITTEE',
'PREPARE_TO_BID',
] as const;
export interface CreateTenderData {
issuingBodyName: string;
title: string;
tenderNumber: string;
termsValue: number;
bondValue: number;
// new extra fields stored inside notes metadata
initialBondValue?: number;
finalBondValue?: number;
finalBondRefundPeriod?: string;
siteVisitRequired?: boolean;
siteVisitLocation?: string;
termsPickupProvince?: string;
announcementDate: string;
closingDate: string;
announcementLink?: string;
source: string;
sourceOther?: string;
announcementType: string;
notes?: string;
contactId?: string;
}
export interface CreateDirectiveData {
type: string;
notes?: string;
assignedToEmployeeId: string;
}
export interface TenderWithDuplicates {
tender: any;
possibleDuplicates?: any[];
}
class TendersService {
async generateTenderNumber(): Promise<string> {
const year = new Date().getFullYear();
const count = await prisma.tender.count({
where: {
createdAt: {
gte: new Date(`${year}-01-01`),
lt: new Date(`${year + 1}-01-01`),
},
},
});
const seq = String(count + 1).padStart(5, '0');
return `TND-${year}-${seq}`;
}
private readonly EXTRA_META_START = '[EXTRA_TENDER_META]';
private readonly EXTRA_META_END = '[/EXTRA_TENDER_META]';
private extractTenderExtraMeta(notes?: string | null) {
if (!notes) {
return {
cleanNotes: '',
meta: {},
};
}
const start = notes.indexOf(this.EXTRA_META_START);
const end = notes.indexOf(this.EXTRA_META_END);
if (start === -1 || end === -1 || end < start) {
return {
cleanNotes: notes,
meta: {},
};
}
const jsonPart = notes.slice(start + this.EXTRA_META_START.length, end).trim();
const before = notes.slice(0, start).trim();
const after = notes.slice(end + this.EXTRA_META_END.length).trim();
const cleanNotes = [before, after].filter(Boolean).join('\n').trim();
try {
return {
cleanNotes,
meta: JSON.parse(jsonPart || '{}'),
};
} catch {
return {
cleanNotes: notes,
meta: {},
};
}
}
private buildTenderNotes(
plainNotes?: string | null,
extra?: {
initialBondValue?: number | null;
finalBondValue?: number | null;
finalBondRefundPeriod?: string | null;
siteVisitRequired?: boolean;
siteVisitLocation?: string | null;
termsPickupProvince?: string | null;
}
) {
const cleanedNotes = plainNotes?.trim() || '';
const meta = {
initialBondValue: extra?.initialBondValue ?? null,
finalBondValue: extra?.finalBondValue ?? null,
finalBondRefundPeriod: extra?.finalBondRefundPeriod?.trim() || null,
siteVisitRequired: !!extra?.siteVisitRequired,
siteVisitLocation: extra?.siteVisitLocation?.trim() || null,
termsPickupProvince: extra?.termsPickupProvince?.trim() || null,
};
const metaBlock = `${this.EXTRA_META_START}${JSON.stringify(meta)}${this.EXTRA_META_END}`;
return cleanedNotes ? `${cleanedNotes}\n${metaBlock}` : metaBlock;
}
private mapTenderExtraFields<T extends { notes?: string | null; bondValue?: any }>(tender: T) {
const { cleanNotes, meta } = this.extractTenderExtraMeta(tender.notes);
return {
...tender,
notes: cleanNotes || null,
initialBondValue: meta.initialBondValue ?? Number(tender.bondValue ?? 0),
finalBondValue: meta.finalBondValue ?? null,
finalBondRefundPeriod: meta.finalBondRefundPeriod ?? null,
siteVisitRequired: !!meta.siteVisitRequired,
siteVisitLocation: meta.siteVisitLocation ?? null,
termsPickupProvince: meta.termsPickupProvince ?? null,
};
}
async findPossibleDuplicates(data: CreateTenderData): Promise<any[]> {
const announcementDate = data.announcementDate ? new Date(data.announcementDate) : null;
const closingDate = data.closingDate ? new Date(data.closingDate) : null;
const termsValue = Number(data.termsValue);
const bondValue = Number(data.initialBondValue ?? data.bondValue ?? 0);
const where: Prisma.TenderWhereInput = {
status: { not: 'CANCELLED' },
};
const orConditions: Prisma.TenderWhereInput[] = [];
if (data.issuingBodyName?.trim()) {
orConditions.push({
issuingBodyName: { contains: data.issuingBodyName.trim(), mode: 'insensitive' },
});
}
if (data.title?.trim()) {
orConditions.push({
title: { contains: data.title.trim(), mode: 'insensitive' },
});
}
if (orConditions.length) {
where.OR = orConditions;
}
if (announcementDate) {
where.announcementDate = announcementDate;
}
if (closingDate) {
where.closingDate = closingDate;
}
if (termsValue != null && !isNaN(termsValue)) {
where.termsValue = termsValue;
}
if (bondValue != null && !isNaN(bondValue)) {
where.bondValue = bondValue;
}
const tenders = await prisma.tender.findMany({
where,
take: 10,
include: {
createdBy: { select: { id: true, email: true, username: true } },
},
orderBy: { createdAt: 'desc' },
});
return tenders;
}
async create(data: CreateTenderData, userId: string): Promise<TenderWithDuplicates> {
const possibleDuplicates = await this.findPossibleDuplicates(data);
const existing = await prisma.tender.findUnique({
where: { tenderNumber: data.tenderNumber.trim() },
});
if (existing) {
throw new AppError(400, 'Tender number already exists - رقم المناقصة موجود مسبقاً');
}
const tenderNumber = data.tenderNumber.trim();
const announcementDate = new Date(data.announcementDate);
const closingDate = new Date(data.closingDate);
if (data.siteVisitRequired && !data.siteVisitLocation?.trim()) {
throw new AppError(400, 'مكان زيارة الموقع مطلوب عند اختيار زيارة موقع إجبارية');
}
const finalNotes = this.buildTenderNotes(data.notes, {
initialBondValue: data.initialBondValue ?? data.bondValue ?? 0,
finalBondValue: data.finalBondValue ?? null,
finalBondRefundPeriod: data.finalBondRefundPeriod ?? null,
siteVisitRequired: !!data.siteVisitRequired,
siteVisitLocation: data.siteVisitRequired ? data.siteVisitLocation ?? null : null,
termsPickupProvince: data.termsPickupProvince ?? null,
});
const tender = await prisma.tender.create({
data: {
tenderNumber,
issuingBodyName: data.issuingBodyName.trim(),
title: data.title.trim(),
termsValue: data.termsValue,
bondValue: Number(data.initialBondValue ?? data.bondValue ?? 0),
announcementDate,
closingDate,
announcementLink: data.announcementLink?.trim() || null,
source: data.source,
sourceOther: data.sourceOther?.trim() || null,
announcementType: data.announcementType,
notes: finalNotes,
contactId: data.contactId || null,
createdById: userId,
},
include: {
createdBy: { select: { id: true, email: true, username: true } },
contact: { select: { id: true, name: true, email: true } },
},
});
await AuditLogger.log({
entityType: 'TENDER',
entityId: tender.id,
action: 'CREATE',
userId,
});
return {
tender: this.mapTenderExtraFields(tender),
possibleDuplicates: possibleDuplicates.length ? possibleDuplicates.map((t) => this.mapTenderExtraFields(t)) : undefined,
}; }
async findAll(filters: any, page: number, pageSize: number) {
const skip = (page - 1) * pageSize;
const where: Prisma.TenderWhereInput = {};
if (filters.search) {
where.OR = [
{ tenderNumber: { contains: filters.search, mode: 'insensitive' } },
{ title: { contains: filters.search, mode: 'insensitive' } },
{ issuingBodyName: { contains: filters.search, mode: 'insensitive' } },
];
}
if (filters.status) where.status = filters.status;
if (filters.source) where.source = filters.source;
if (filters.announcementType) where.announcementType = filters.announcementType;
const total = await prisma.tender.count({ where });
const tenders = await prisma.tender.findMany({
where,
skip,
take: pageSize,
include: {
createdBy: { select: { id: true, email: true, username: true } },
contact: { select: { id: true, name: true } },
_count: { select: { directives: true } },
},
orderBy: { createdAt: 'desc' },
});
return {
tenders: tenders.map((t) => this.mapTenderExtraFields(t)),
total,
page,
pageSize,
};
}
async findById(id: string) {
const tender = await prisma.tender.findUnique({
where: { id },
include: {
createdBy: { select: { id: true, email: true, username: true, employee: { select: { firstName: true, lastName: true } } } },
contact: true,
directives: {
include: {
assignedToEmployee: { select: { id: true, firstName: true, lastName: true, email: true, user: { select: { id: true } } } },
issuedBy: { select: { id: true, email: true, username: true } },
completedBy: { select: { id: true, email: true } },
attachments: true,
},
orderBy: { createdAt: 'desc' },
},
attachments: true,
},
});
if (!tender) throw new AppError(404, 'Tender not found');
return this.mapTenderExtraFields(tender);
}
async update(id: string, data: Partial<CreateTenderData>, userId: string) {
const existing = await prisma.tender.findUnique({ where: { id } });
if (!existing) throw new AppError(404, 'Tender not found');
if (existing.status === 'CONVERTED_TO_DEAL') {
throw new AppError(400, 'Cannot update tender that has been converted to deal');
}
const updateData: Prisma.TenderUpdateInput = {};
const existingMapped = this.mapTenderExtraFields(existing as any);
const mergedExtra = {
initialBondValue:
data.initialBondValue !== undefined
? Number(data.initialBondValue)
: existingMapped.initialBondValue ?? Number(existing.bondValue ?? 0),
finalBondValue:
data.finalBondValue !== undefined
? Number(data.finalBondValue)
: existingMapped.finalBondValue ?? null,
finalBondRefundPeriod:
data.finalBondRefundPeriod !== undefined
? data.finalBondRefundPeriod
: existingMapped.finalBondRefundPeriod ?? null,
siteVisitRequired:
data.siteVisitRequired !== undefined
? !!data.siteVisitRequired
: !!existingMapped.siteVisitRequired,
siteVisitLocation:
data.siteVisitLocation !== undefined
? data.siteVisitLocation
: existingMapped.siteVisitLocation ?? null,
termsPickupProvince:
data.termsPickupProvince !== undefined
? data.termsPickupProvince
: existingMapped.termsPickupProvince ?? null,
};
if (mergedExtra.siteVisitRequired && !mergedExtra.siteVisitLocation?.trim()) {
throw new AppError(400, 'مكان زيارة الموقع مطلوب عند اختيار زيارة موقع إجبارية');
}
if (data.title !== undefined) updateData.title = data.title.trim();
if (data.issuingBodyName !== undefined) updateData.issuingBodyName = data.issuingBodyName.trim();
if (data.termsValue !== undefined) updateData.termsValue = data.termsValue;
if (data.bondValue !== undefined || data.initialBondValue !== undefined) {
updateData.bondValue = Number(data.initialBondValue ?? data.bondValue ?? existing.bondValue);
}
if (data.announcementDate !== undefined) updateData.announcementDate = new Date(data.announcementDate);
if (data.closingDate !== undefined) updateData.closingDate = new Date(data.closingDate);
if (data.announcementLink !== undefined) updateData.announcementLink = data.announcementLink?.trim() || null;
if (data.source !== undefined) updateData.source = data.source;
if (data.sourceOther !== undefined) updateData.sourceOther = data.sourceOther?.trim() || null;
if (data.announcementType !== undefined) updateData.announcementType = data.announcementType;
if (
data.notes !== undefined ||
data.initialBondValue !== undefined ||
data.finalBondValue !== undefined ||
data.finalBondRefundPeriod !== undefined ||
data.siteVisitRequired !== undefined ||
data.siteVisitLocation !== undefined ||
data.termsPickupProvince !== undefined
) {
updateData.notes = this.buildTenderNotes(
data.notes !== undefined ? data.notes : existingMapped.notes,
{
initialBondValue: mergedExtra.initialBondValue,
finalBondValue: mergedExtra.finalBondValue,
finalBondRefundPeriod: mergedExtra.finalBondRefundPeriod,
siteVisitRequired: mergedExtra.siteVisitRequired,
siteVisitLocation: mergedExtra.siteVisitRequired ? mergedExtra.siteVisitLocation : null,
termsPickupProvince: mergedExtra.termsPickupProvince,
}
);
}
if (data.contactId !== undefined) {
updateData.contact = data.contactId
? { connect: { id: data.contactId } }
: { disconnect: true };
}
const tender = await prisma.tender.update({
where: { id },
data: updateData,
include: {
createdBy: { select: { id: true, email: true, username: true } },
contact: { select: { id: true, name: true } },
},
});
await AuditLogger.log({
entityType: 'TENDER',
entityId: id,
action: 'UPDATE',
userId,
changes: { before: existing, after: data },
});
return this.mapTenderExtraFields(tender);
}
async createDirective(tenderId: string, data: CreateDirectiveData, userId: string) {
const tender = await prisma.tender.findUnique({
where: { id: tenderId },
select: { id: true, title: true, tenderNumber: true },
});
if (!tender) throw new AppError(404, 'Tender not found');
const directive = await prisma.tenderDirective.create({
data: {
tenderId,
type: data.type,
notes: data.notes?.trim() || null,
assignedToEmployeeId: data.assignedToEmployeeId,
issuedById: userId,
},
include: {
assignedToEmployee: {
select: { id: true, firstName: true, lastName: true, user: { select: { id: true } } },
},
issuedBy: { select: { id: true, email: true, username: true } },
},
});
const assignedUser = directive.assignedToEmployee?.user;
if (assignedUser?.id) {
const typeLabel = this.getDirectiveTypeLabel(data.type);
await prisma.notification.create({
data: {
userId: assignedUser.id,
type: 'TENDER_DIRECTIVE_ASSIGNED',
title: `مهمة مناقصة - Tender task: ${tender.tenderNumber}`,
message: `${tender.title} (${tender.tenderNumber}): ${typeLabel}. ${data.notes || ''}`,
entityType: 'TENDER_DIRECTIVE',
entityId: directive.id,
},
});
}
await AuditLogger.log({
entityType: 'TENDER_DIRECTIVE',
entityId: directive.id,
action: 'CREATE',
userId,
});
return directive;
}
async updateDirective(
directiveId: string,
data: { status?: string; completionNotes?: string },
userId: string
) {
const directive = await prisma.tenderDirective.findUnique({
where: { id: directiveId },
include: { tender: true },
});
if (!directive) throw new AppError(404, 'Directive not found');
const updateData: Prisma.TenderDirectiveUpdateInput = {};
if (data.status !== undefined) updateData.status = data.status;
if (data.completionNotes !== undefined) updateData.completionNotes = data.completionNotes;
if (data.status === 'COMPLETED') {
updateData.completedAt = new Date();
updateData.completedBy = { connect: { id: userId } };
}
const updated = await prisma.tenderDirective.update({
where: { id: directiveId },
data: updateData,
include: {
assignedToEmployee: { select: { id: true, firstName: true, lastName: true } },
issuedBy: { select: { id: true, email: true } },
},
});
await AuditLogger.log({
entityType: 'TENDER_DIRECTIVE',
entityId: directiveId,
action: 'UPDATE',
userId,
});
return updated;
}
async getHistory(tenderId: string) {
const tender = await prisma.tender.findUnique({ where: { id: tenderId } });
if (!tender) throw new AppError(404, 'Tender not found');
return AuditLogger.getEntityHistory('TENDER', tenderId);
}
async getDirectiveHistory(directiveId: string) {
const dir = await prisma.tenderDirective.findUnique({ where: { id: directiveId } });
if (!dir) throw new AppError(404, 'Directive not found');
return AuditLogger.getEntityHistory('TENDER_DIRECTIVE', directiveId);
}
getDirectiveTypeLabel(type: string): string {
const labels: Record<string, string> = {
BUY_TERMS: 'شراء دفتر الشروط - Buy terms booklet',
VISIT_CLIENT: 'زيارة الزبون - Visit client',
MEET_COMMITTEE: 'التعرف على اللجنة المختصة - Meet committee',
PREPARE_TO_BID: 'الاستعداد للدخول في المناقصة - Prepare to bid',
};
return labels[type] || type;
}
getSourceValues() {
return [...TENDER_SOURCE_VALUES];
}
getAnnouncementTypeValues() {
return [...ANNOUNCEMENT_TYPE_VALUES];
}
getDirectiveTypeValues() {
return [...DIRECTIVE_TYPE_VALUES];
}
async convertToDeal(
tenderId: string,
data: { contactId: string; pipelineId: string; ownerId?: string },
userId: string
) {
const tender = await prisma.tender.findUnique({
where: { id: tenderId },
include: { contact: true },
});
if (!tender) throw new AppError(404, 'Tender not found');
if (tender.status === 'CONVERTED_TO_DEAL') {
throw new AppError(400, 'Tender already converted to deal');
}
const pipeline = await prisma.pipeline.findUnique({
where: { id: data.pipelineId },
});
if (!pipeline) throw new AppError(404, 'Pipeline not found');
const stages = (pipeline.stages as { id?: string; name?: string }[]) || [];
const firstStage = stages[0]?.id || stages[0]?.name || 'OPEN';
const dealNumber = await this.generateDealNumber();
const fiscalYear = new Date().getFullYear();
const estimatedValue = Number(tender.termsValue) || Number(tender.bondValue) || 0;
const deal = await prisma.deal.create({
data: {
dealNumber,
name: tender.title,
contactId: data.contactId,
structure: 'B2G',
pipelineId: data.pipelineId,
stage: firstStage,
estimatedValue,
ownerId: data.ownerId || userId,
fiscalYear,
currency: 'SAR',
sourceTenderId: tenderId,
},
include: {
contact: { select: { id: true, name: true, email: true } },
owner: { select: { id: true, email: true, username: true } },
pipeline: true,
},
});
await prisma.tender.update({
where: { id: tenderId },
data: { status: 'CONVERTED_TO_DEAL' },
});
await AuditLogger.log({
entityType: 'TENDER',
entityId: tenderId,
action: 'UPDATE',
userId,
changes: { status: 'CONVERTED_TO_DEAL', dealId: deal.id },
});
await AuditLogger.log({
entityType: 'DEAL',
entityId: deal.id,
action: 'CREATE',
userId,
});
return deal;
}
async uploadTenderAttachment(
tenderId: string,
file: { path: string; originalname: string; mimetype: string; size: number },
userId: string,
category?: string
) {
const tender = await prisma.tender.findUnique({ where: { id: tenderId } });
if (!tender) throw new AppError(404, 'Tender not found');
const fileName = path.basename(file.path);
const attachment = await prisma.attachment.create({
data: {
entityType: 'TENDER',
entityId: tenderId,
tenderId,
fileName,
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
path: file.path,
category: category || 'ANNOUNCEMENT',
uploadedBy: userId,
},
});
await AuditLogger.log({
entityType: 'TENDER',
entityId: tenderId,
action: 'UPDATE',
userId,
changes: { attachmentUploaded: attachment.id },
});
return attachment;
}
async uploadDirectiveAttachment(
directiveId: string,
file: { path: string; originalname: string; mimetype: string; size: number },
userId: string,
category?: string
) {
const directive = await prisma.tenderDirective.findUnique({
where: { id: directiveId },
select: { id: true, tenderId: true },
});
if (!directive) throw new AppError(404, 'Directive not found');
const fileName = path.basename(file.path);
const attachment = await prisma.attachment.create({
data: {
entityType: 'TENDER_DIRECTIVE',
entityId: directiveId,
tenderDirectiveId: directiveId,
tenderId: directive.tenderId,
fileName,
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
path: file.path,
category: category || 'TASK_FILE',
uploadedBy: userId,
},
});
await AuditLogger.log({
entityType: 'TENDER_DIRECTIVE',
entityId: directiveId,
action: 'UPDATE',
userId,
changes: { attachmentUploaded: attachment.id },
});
return attachment;
}
async getAttachmentFile(attachmentId: string): Promise<string> {
const attachment = await prisma.attachment.findUnique({
where: { id: attachmentId },
})
if (!attachment) throw new AppError(404, 'File not found')
return attachment.path
}
async deleteAttachment(attachmentId: string): Promise<void> {
const attachment = await prisma.attachment.findUnique({
where: { id: attachmentId },
})
if (!attachment) throw new AppError(404, 'File not found')
// حذف من الديسك
if (attachment.path && fs.existsSync(attachment.path)) {
fs.unlinkSync(attachment.path)
}
// حذف من DB
await prisma.attachment.delete({
where: { id: attachmentId },
})
}
private async generateDealNumber(): Promise<string> {
const year = new Date().getFullYear();
const prefix = `DEAL-${year}-`;
const lastDeal = await prisma.deal.findFirst({
where: { dealNumber: { startsWith: prefix } },
orderBy: { createdAt: 'desc' },
select: { dealNumber: true },
});
let nextNumber = 1;
if (lastDeal) {
const part = lastDeal.dealNumber.split('-')[2];
nextNumber = (parseInt(part, 10) || 0) + 1;
}
return `${prefix}${nextNumber.toString().padStart(6, '0')}`;
}
}
export const tendersService = new TendersService();

View File

@@ -3,15 +3,18 @@ import adminRoutes from '../modules/admin/admin.routes';
import authRoutes from '../modules/auth/auth.routes';
import contactsRoutes from '../modules/contacts/contacts.routes';
import crmRoutes from '../modules/crm/crm.routes';
import dashboardRoutes from '../modules/dashboard/dashboard.routes';
import hrRoutes from '../modules/hr/hr.routes';
import inventoryRoutes from '../modules/inventory/inventory.routes';
import projectsRoutes from '../modules/projects/projects.routes';
import marketingRoutes from '../modules/marketing/marketing.routes';
import tendersRoutes from '../modules/tenders/tenders.routes';
const router = Router();
// Module routes
router.use('/admin', adminRoutes);
router.use('/dashboard', dashboardRoutes);
router.use('/auth', authRoutes);
router.use('/contacts', contactsRoutes);
router.use('/crm', crmRoutes);
@@ -19,6 +22,7 @@ router.use('/hr', hrRoutes);
router.use('/inventory', inventoryRoutes);
router.use('/projects', projectsRoutes);
router.use('/marketing', marketingRoutes);
router.use('/tenders', tendersRoutes);
// API info
router.get('/', (req, res) => {
@@ -34,6 +38,7 @@ router.get('/', (req, res) => {
'Inventory & Assets',
'Tasks & Projects',
'Marketing',
'Tender Management',
],
});
});

View File

@@ -4,12 +4,47 @@ import { config } from '../../config';
import { AppError } from './errorHandler';
import prisma from '../../config/database';
export interface EffectivePermission {
module: string;
resource: string;
actions: string[];
}
function mergePermissions(
positionPerms: { module: string; resource: string; actions: unknown }[],
rolePerms: { module: string; resource: string; actions: unknown }[]
): EffectivePermission[] {
const key = (m: string, r: string) => `${m}:${r}`;
const map = new Map<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 {
user?: {
id: string;
email: string;
employeeId?: string;
employee?: any;
effectivePermissions?: EffectivePermission[];
};
}
@@ -33,7 +68,7 @@ export const authenticate = async (
email: string;
};
// Get user with employee info
// Get user with employee + roles (Phase 3: multi-group)
const user = await prisma.user.findUnique({
where: { id: decoded.id },
include: {
@@ -47,6 +82,14 @@ export const authenticate = async (
department: true,
},
},
userRoles: {
where: { role: { isActive: true } },
include: {
role: {
include: { permissions: true },
},
},
},
},
});
@@ -59,12 +102,18 @@ export const authenticate = async (
throw new AppError(403, 'الوصول مرفوض - Access denied. Active employee record required.');
}
// Attach user to request
const positionPerms = user.employee?.position?.permissions ?? [];
const rolePerms = (user as any).userRoles?.flatMap(
(ur: any) => ur.role?.permissions ?? []
) ?? [];
const effectivePermissions = mergePermissions(positionPerms, rolePerms);
req.user = {
id: user.id,
email: user.email,
employeeId: user.employeeId || undefined,
employee: user.employee,
effectivePermissions,
};
next();
@@ -76,25 +125,24 @@ export const authenticate = async (
}
};
// Permission checking middleware
// Permission checking middleware (Position + Role permissions merged)
export const authorize = (module: string, resource: string, action: string) => {
return async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
if (!req.user?.employee?.position?.permissions) {
const perms = req.user?.effectivePermissions;
if (!perms || perms.length === 0) {
throw new AppError(403, 'الوصول مرفوض - Access denied');
}
// Find permission for this module and resource (check exact match or wildcard)
const permission = req.user.employee.position.permissions.find(
(p: any) => p.module === module && (p.resource === resource || p.resource === '*' || p.resource === 'all')
const permission = perms.find(
(p) => p.module === module && (p.resource === resource || p.resource === '*' || p.resource === 'all')
);
if (!permission) {
throw new AppError(403, 'الوصول مرفوض - Access denied');
}
// Check if action is allowed (check exact match or wildcard)
const actions = permission.actions as string[];
const actions = permission.actions;
if (!actions.includes(action) && !actions.includes('*') && !actions.includes('all')) {
throw new AppError(403, 'الوصول مرفوض - Access denied');
}

View File

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

View File

@@ -24,11 +24,13 @@ services:
context: ./backend
dockerfile: Dockerfile
container_name: zerp_backend
restart: unless-stopped
restart: always
environment:
PORT: 5001
NODE_ENV: production
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-postgres123}@postgres:5432/mind14_crm?schema=public
# Default matches postgres service when POSTGRES_PASSWORD is unset (local/staging).
# Override via `.env` (Compose loads `.env`, not `.env.production`).
DATABASE_URL: ${DATABASE_URL:-postgresql://postgres:${POSTGRES_PASSWORD:-postgres123}@postgres:5432/mind14_crm?schema=public}
JWT_SECRET: ${JWT_SECRET:-z-crm-jwt-secret-change-in-production-NOW}
JWT_EXPIRES_IN: 7d
JWT_REFRESH_EXPIRES_IN: 30d
@@ -39,6 +41,12 @@ services:
condition: service_healthy
ports:
- "5001:5001"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:5001/api/v1/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
command: sh -c "npx prisma migrate deploy && node dist/server.js"
frontend:

View File

@@ -5,8 +5,8 @@
Clean the production database so you can load **new real data** that will reflect across the system at all levels. This removes existing (e.g. test/demo) data and leaves the database in a state where:
- Schema and migrations are unchanged
- Base configuration is restored (pipelines, categories, departments, roles, default users)
- All business data (contacts, deals, quotes, projects, etc.) is removed so you can enter new real data
- One System Administrator user remains for configuration
- All business data (contacts, deals, quotes, projects, etc.) is removed so you can enter new real data manually
## ⚠️ Important
@@ -21,7 +21,7 @@ Clean the production database so you can load **new real data** that will reflec
This truncates all tables and then runs the seed so you get:
- Empty business data (contacts, deals, quotes, projects, inventory, etc.)
- Restored base data: departments, positions, permissions, employees, users, contact categories, product categories, pipelines, one warehouse
- One System Administrator user (admin@system.local) with full access to all modules
### Steps on production server
@@ -87,19 +87,17 @@ All rows are removed from every table, including:
- Audit logs, notifications, approvals
- Users, employees, departments, positions, permissions
Then the **seed** recreates only the base data (users, departments, positions, permissions, employees, contact/product categories, pipelines, one warehouse).
Then the **seed** recreates only the base data (one System Administrator user with full access). No categories, pipelines, or warehouses—you configure these manually.
---
## Default users after re-seed
## Default user after re-seed
| Role | Email | Password | Access |
|-------------------|--------------------------|-----------|---------------|
| General Manager | gm@atmata.com | Admin@123 | Full system |
| Sales Manager | sales.manager@atmata.com | Admin@123 | Contacts, CRM |
| Sales Representative | sales.rep@atmata.com | Admin@123 | Basic CRM |
|-------------------|----------------------|-----------|-------------|
| System Administrator | admin@system.local | Admin@123 | Full system |
Change these passwords after first login in production.
Change the password after first login in production.
---

View File

@@ -0,0 +1,259 @@
# Software Requirements Specification: Tender Management Module
# مواصفات متطلبات البرمجيات: موديول إدارة المناقصات
**Version:** 1.0
**Module name (EN):** Tender Management
**Module name (AR):** نظام إدارة المناقصات
---
## 1. Introduction | مقدمة
### 1.1 Purpose | الهدف من الموديول
The Tender Management Module enables the sales team to register all tenders available in the market and track them in an organised way from the moment the announcement is discovered until the appropriate management decision is made. Supported decisions include:
- Purchase of terms booklet (شراء دفتر الشروط) or securing the terms booklet (تأمين دفتر الشروط)
- Visiting the issuing body (زيارة الجهة الطارحة)
- Getting to know the relevant committee (التعرف على اللجنة المختصة)
- Preparing to enter the tender (الاستعداد للدخول في المناقصة)
**Scope boundary:** The modules role ends at this stage. After the initial follow-up and the decision to proceed, the tender is **converted to an Opportunity (Deal)** in the CRM module so the engineering team can study the project.
---
## 2. Integration with Other Systems | التكامل مع الأنظمة الأخرى
### 2.1 HR Module | موديول الموارد البشرية
The HR module is used for:
- **User definition:** Users are linked to employees.
- **Permissions:** Access control and role-based permissions.
- **Assignee selection:** When issuing directives, the responsible employee is chosen from the HR employee list.
### 2.2 CRM Module | موديول CRM
After the initial follow-up phase, a tender can be **converted to an Opportunity (Deal)** in CRM. The Deal is then handled by the engineering team. In this system, “Opportunity” is implemented as the **Deal** entity (no separate Opportunity model).
---
## 3. Users and Permissions | المستخدمون والصلاحيات
### 3.1 Sales Team | فريق المبيعات
**Permissions:**
- Add new tenders
- Edit tender data
- Follow up on tenders
- Execute assigned tasks
- Upload documents
- Add notes
### 3.2 Sales Manager | مدير المبيعات
**Permissions:**
- View all tenders
- Issue directives
- Assign employees to tasks
- Add notes
- Monitor execution
### 3.3 Executive Manager | المدير التنفيذي
**Permissions:**
- Issue directives
- Assign employees
- Monitor tenders
- View all documents and notes
**Implementation note:** The system uses a `tenders` module with resources (e.g. `tenders`, `directives`) and actions (`read`, `create`, `update`, `delete`). Roles (Sales, Sales Manager, Executive) are configured in Admin with the appropriate permissions for this module.
---
## 4. Creating a New Tender | إنشاء مناقصة جديدة
Sales registers tenders discovered in the market.
### 4.1 Basic Tender Data | البيانات الأساسية للمناقصة
| Field (EN) | Field (AR) | Type | Required | Notes |
|--------------------|----------------|--------|----------|--------|
| Issuing body name | اسم الجهة الطارحة | Text | Yes | |
| Tender title | عنوان المناقصة | Text | Yes | |
| Tender number | رقم المناقصة | Text | Yes | **Unique** |
| Terms booklet value| قيمة دفتر الشروط | Decimal| Yes | |
| Bond value | قيمة التأمينات | Decimal| Yes | |
| Announcement date | تاريخ الإعلان | Date | Yes | |
| Closing date | تاريخ الإغلاق | Date | Yes | |
| Announcement link | رابط الإعلان | URL | No | |
| Source | مصدر المناقصة | See §5 | Yes | |
| Notes | ملاحظات | Text | No | |
| Announcement file | صورة/ملف الإعلان | File | No | Image or document |
### 4.2 Announcement Type | نوع إعلان المناقصة
When registering the tender, the announcement type must be set:
- First announcement (إعلان للمرة الأولى)
- Re-announcement, 2nd time (إعلان معاد للمرة الثانية)
- Re-announcement, 3rd time (إعلان معاد للمرة الثالثة)
- Re-announcement, 4th time (إعلان معاد للمرة الرابعة)
---
## 5. Tender Source | مصدر المناقصة
The system must support recording the tender source by multiple means, including:
- Government sites (مواقع حكومية)
- Official gazette (جريدة رسمية)
- Personal relations (علاقات شخصية)
- Partner companies (شركات صديقة)
- WhatsApp or Telegram groups (مجموعات واتساب أو تلغرام)
- Tender portals (بوابات المناقصات)
- Email (البريد الإلكتروني)
- Manual entry (إدخال يدوي)
**User interaction:** The user may:
- Select a source from a predefined list, or
- Enter the source as free text, or
- Paste the announcement link (stored as link; source may be derived or manual).
---
## 6. Duplicate Prevention | منع التكرار
The system must detect potential duplicate tenders.
**When:** On creation of a new tender (and optionally on update).
**Matching criteria:** The system checks for similar tenders using:
- Issuing body name (اسم الزبون / الجهة الطارحة)
- Tender title (عنوان المناقصة)
- Terms booklet value (قيمة دفتر الشروط)
- Bond value (قيمة التأمينات)
- Closing date (تاريخ الإغلاق)
- Announcement date (تاريخ الإعلان)
**Behaviour:** If one or more tenders with matching or very similar data are found:
1. The system shows a **warning** to the user that a possible duplicate exists.
2. The similar record(s) are displayed so the user can:
- Review the existing tender
- Confirm whether it is a duplicate or a different tender
- Decide whether to proceed with creating the new tender or cancel.
The user can still choose to continue after the warning; the system does not block creation.
---
## 7. Administrative Directive | التوجيه الإداري
After the tender is registered, an **administrative directive** (توجيه إداري) can be issued by the Sales Manager or the Executive.
### 7.1 Directive Contents | مكونات التوجيه
- **Directive type:** Selected from a list (e.g. Buy terms booklet, Visit client, Meet committee, Prepare to bid).
- **Additional notes:** Free text.
- **Responsible employee:** Selected from the HR employee list (the person who will execute the task).
### 7.2 Examples of Directive Types | أمثلة على التوجيهات
- Purchase terms booklet (شراء دفتر الشروط)
- Visit the client/issuing body (زيارة الزبون)
- Get to know the relevant committee (التعرف على اللجنة المختصة)
- Prepare to enter the tender (الاستعداد للدخول في المناقصة)
---
## 8. Assigning the Responsible Employee | تعيين الموظف المسؤول
When issuing a directive, the **employee responsible for executing the task** must be selected. The employee is chosen from the list of employees provided by the HR module.
---
## 9. Notifications | الإشعارات
When an employee is assigned to execute a directive:
- The system sends an **in-app notification** to the **user** linked to that employee.
- The notification includes:
- Tender name and number (اسم المناقصة + الرقم)
- Task/directive type (نوع المهمة)
- The administrative directive text
- Manager notes (ملاحظات المدير)
No separate notification table is required; the existing Notification entity is used with a type such as `TENDER_DIRECTIVE_ASSIGNED`.
---
## 10. Executing the Task | تنفيذ المهمة
After receiving the task, the assigned employee can:
1. Perform the required action.
2. Record in the system what was done.
3. Add notes or a short report.
4. Upload files related to the task (e.g. receipt, visit report).
---
## 11. File Management | إدارة الملفات
### 11.1 Announcement Files | ملفات الإعلان
- One main file per tender for the announcement (image or document).
- Stored and linked to the tender record.
### 11.2 Task Execution Files | ملفات تنفيذ المهام
The employee may attach multiple files per directive/task, for example:
- Terms booklet purchase receipt
- Terms booklets (documents)
- Visit reports
- Other documents related to the tender
The system must support upload, storage, and association of these files with the tender or the directive/task.
---
## 12. Activity Log | سجل النشاط
The system must log all operations performed on a tender, including:
- Tender creation
- Data updates
- Issuing directives
- Assigning employees
- Executing tasks
- Uploading files
- Adding notes
This log is used to display a timeline or history on the tender detail view.
---
## 13. End of Module Scope | نهاية دور الموديول
The Tender Management Modules scope ends when:
- Initial directives have been executed,
- Initial information has been gathered, and
- The decision to prepare to enter the tender has been taken.
At that point, the user can **convert the tender to an Opportunity (Deal)** in the CRM module. The resulting Deal is then used by the engineering team to study the project. Conversion creates a Deal (Opportunity) and may store a reference to the source tender for traceability.
---
## Document History
| Version | Date | Author/Notes |
|--------|------------|--------------|
| 1.0 | 2025-03-11 | Initial SRS from client requirements (Arabic). |

View File

@@ -37,10 +37,10 @@ ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Copy necessary files
COPY --from=builder /app/public ./public
# Standalone first, then static assets; public last so it is not overwritten by any nested folder in standalone.
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@
"lint": "next lint"
},
"dependencies": {
"@swc/helpers": "^0.5.21",
"@tanstack/react-query": "^5.17.9",
"axios": "^1.6.5",
"date-fns": "^3.0.6",

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

View File

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

View File

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

View File

@@ -50,7 +50,8 @@ export default function AdminDashboard() {
return labels[a] || a;
};
const formatTime = (d: string) => {
const formatTime = (d: string | null | undefined) => {
if (!d) return '-';
const diff = Date.now() - new Date(d).getTime();
const mins = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);

View File

@@ -1,259 +1,375 @@
'use client'
'use client';
import { useEffect, useMemo, useState } from 'react'
import { Plus, Edit, Trash2, Users2 } from 'lucide-react'
import Modal from '@/components/Modal'
type PermissionGroup = {
id: string
name: string
nameAr?: string
modules: string[]
createdAt: string
}
import { useState, useEffect, useCallback } from 'react';
import { UsersRound, Edit, Users, Check, X, Plus } from 'lucide-react';
import { permissionGroupsAPI } from '@/lib/api/admin';
import type { PermissionGroup } from '@/lib/api/admin';
import Modal from '@/components/Modal';
import LoadingSpinner from '@/components/LoadingSpinner';
const MODULES = [
{ id: 'contacts', name: 'إدارة جهات الاتصال' },
{ id: 'crm', name: 'إدارة علاقات العملاء' },
{ id: 'inventory', name: 'المخزون والأصول' },
{ id: 'projects', name: 'المهام والمشاريع' },
{ id: 'hr', name: 'الموارد البشرية' },
{ id: 'marketing', name: 'التسويق' },
{ id: 'admin', name: 'لوحة الإدارة' },
]
{ id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' },
{ id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' },
{ id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' },
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
{ id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' },
{ id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' },
{ id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية', nameEn: 'Department Overtime Requests' },
{ id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' },
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
];
const STORAGE_KEY = 'permissionGroups'
const ACTIONS = [
{ id: 'read', name: 'عرض' },
{ id: 'create', name: 'إنشاء' },
{ id: 'update', name: 'تعديل' },
{ id: 'delete', name: 'حذف' },
{ id: 'export', name: 'تصدير' },
{ id: 'approve', name: 'اعتماد' },
{ id: 'merge', name: 'دمج' },
];
function hasAction(perm: { actions?: unknown } | undefined, action: string): boolean {
if (!perm?.actions) return false;
const actions = Array.isArray(perm.actions) ? perm.actions : [];
return actions.includes('*') || actions.includes('all') || actions.includes(action);
}
function buildPermissionsFromMatrix(matrix: Record<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() {
const [groups, setGroups] = useState<PermissionGroup[]>([])
const [showModal, setShowModal] = useState(false)
const [editing, setEditing] = useState<PermissionGroup | null>(null)
const [groups, setGroups] = useState<PermissionGroup[]>([]);
const [loading, setLoading] = useState(true);
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 [nameAr, setNameAr] = useState('')
const [selectedModules, setSelectedModules] = useState<Record<string, boolean>>({})
const fetchGroups = useCallback(async () => {
setLoading(true);
setError(null);
try {
const list = await permissionGroupsAPI.getAll();
setGroups(list);
if (selectedId && !list.find((g) => g.id === selectedId)) setSelectedId(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'فشل تحميل المجموعات');
} finally {
setLoading(false);
}
}, [selectedId]);
useEffect(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) setGroups(JSON.parse(raw))
} catch {
// ignore
}
}, [])
fetchGroups();
}, [fetchGroups]);
const currentGroup = groups.find((g) => g.id === selectedId);
useEffect(() => {
if (currentGroup) {
setPermissionMatrix(buildMatrixFromPermissions(currentGroup.permissions || []));
}
}, [currentGroup?.id, currentGroup?.permissions]);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!createForm.name.trim()) {
alert('الاسم مطلوب');
return;
}
setSaving(true);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(groups))
} catch {
// ignore
const group = await permissionGroupsAPI.create({
name: createForm.name.trim(),
nameAr: createForm.nameAr.trim() || undefined,
description: createForm.description.trim() || undefined,
});
setShowCreateModal(false);
setCreateForm({ name: '', nameAr: '', description: '' });
await fetchGroups();
setSelectedId(group.id);
setShowEditModal(true);
} catch (err: unknown) {
alert(err instanceof Error ? err.message : 'فشل الإنشاء');
} finally {
setSaving(false);
}
}, [groups])
};
const baseModulesMap = useMemo(() => {
const m: Record<string, boolean> = {}
MODULES.forEach(x => (m[x.id] = false))
return m
}, [])
const openCreate = () => {
setEditing(null)
setName('')
setNameAr('')
setSelectedModules({ ...baseModulesMap })
setShowModal(true)
const handleSavePermissions = async () => {
if (!selectedId) return;
setSaving(true);
try {
const permissions = buildPermissionsFromMatrix(permissionMatrix);
await permissionGroupsAPI.updatePermissions(selectedId, permissions);
setShowEditModal(false);
fetchGroups();
} catch (err: unknown) {
alert(err instanceof Error ? err.message : 'فشل الحفظ');
} finally {
setSaving(false);
}
};
const 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 now = new Date().toISOString()
if (editing) {
setGroups(prev =>
prev.map(g =>
g.id === editing.id
? { ...g, name: finalName, nameAr: nameAr.trim() || undefined, modules: mods }
: g
)
)
} else {
const id = crypto?.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`
setGroups(prev => [
{ id, name: finalName, nameAr: nameAr.trim() || undefined, modules: mods, createdAt: now },
const handleTogglePermission = (moduleId: string, actionId: string) => {
setPermissionMatrix((prev) => ({
...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))
}
[moduleId]: {
...(prev[moduleId] || {}),
[actionId]: !prev[moduleId]?.[actionId],
},
}));
};
return (
<div>
<div className="mb-8 flex items-center justify-between">
<div>
<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>
<button
onClick={openCreate}
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"
onClick={() => setShowCreateModal(true)}
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" />
إضافة مجموعة
<span className="font-semibold">إضافة مجموعة</span>
</button>
</div>
{groups.length === 0 ? (
<div className="bg-white rounded-xl border border-gray-200 p-10 text-center">
<Users2 className="h-14 w-14 text-gray-300 mx-auto mb-3" />
<h3 className="text-lg font-bold text-gray-900 mb-1">لا توجد مجموعات</h3>
<p className="text-gray-600">قم بإضافة مجموعة صلاحيات لتسهيل إدارة الأدوار.</p>
{loading ? (
<div className="flex justify-center p-12">
<LoadingSpinner />
</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">
{groups.map(g => (
<div key={g.id} className="bg-white rounded-xl border border-gray-200 p-5">
<div className="flex items-start justify-between">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-1 space-y-4">
<h2 className="text-xl font-bold text-gray-900 mb-4">المجموعات ({groups.length})</h2>
{groups.map((g) => (
<div
key={g.id}
onClick={() => setSelectedId(g.id)}
className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${
selectedId === g.id ? 'border-blue-600 bg-blue-50' : 'border-gray-200 bg-white hover:border-blue-300'
}`}
>
<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>
<p className="text-sm text-gray-700 mt-3">
الوحدات: <span className="font-semibold">{g.modules.length}</span>
</p>
</div>
<div className="flex gap-1">
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">
<Users className="h-4 w-4 inline mr-1" />
{g._count?.userRoles ?? 0} مستخدم
</span>
<button
onClick={() => openEdit(g)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg"
title="تعديل"
onClick={(e) => {
e.stopPropagation();
setSelectedId(g.id);
setShowEditModal(true);
}}
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded"
>
<Edit className="h-4 w-4" />
</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 className="mt-4 flex flex-wrap gap-2">
{g.modules.map(id => {
const m = MODULES.find(x => x.id === id)
return (
<span key={id} className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded-lg">
{m?.name || id}
</span>
)
})}
</div>
</div>
))}
</div>
<div className="lg:col-span-2">
{currentGroup ? (
<div className="bg-white rounded-xl shadow-lg border p-6">
<div className="flex justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-gray-900">{currentGroup.nameAr || currentGroup.name}</h2>
<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 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>
)}
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
title={editing ? 'تعديل مجموعة الصلاحيات' : 'إضافة مجموعة صلاحيات'}
size="lg"
>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title="إضافة مجموعة صلاحيات" size="md">
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">الاسم (English)</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</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"
type="text"
value={createForm.name}
onChange={(e) => setCreateForm((p) => ({ ...p, name: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
placeholder="e.g. Campaign Approver"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">الاسم (عربي)</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Name (Arabic)</label>
<input
value={nameAr}
onChange={e => setNameAr(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
placeholder="مثال: مجموعة المبيعات"
type="text"
value={createForm.nameAr}
onChange={(e) => setCreateForm((p) => ({ ...p, nameAr: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">الوحدات ضمن المجموعة</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{MODULES.map(m => (
<button
key={m.id}
type="button"
onClick={() => toggleModule(m.id)}
className={`flex items-center justify-between px-3 py-2 rounded-lg border ${
selectedModules[m.id]
? 'border-green-500 bg-green-50'
: 'border-gray-200 bg-white hover:bg-gray-50'
}`}
>
<span className="text-sm font-medium text-gray-800">{m.name}</span>
<span
className={`text-xs px-2 py-0.5 rounded ${
selectedModules[m.id] ? 'bg-green-600 text-white' : 'bg-gray-200 text-gray-700'
}`}
>
{selectedModules[m.id] ? 'ضمن المجموعة' : 'غير محدد'}
</span>
<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"
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>
</div>
</form>
</Modal>
<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"
<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={save}
className="px-5 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-semibold"
>
حفظ
<button onClick={handleSavePermissions} disabled={saving} className="px-6 py-3 bg-blue-600 text-white rounded-lg disabled:opacity-50">
{saving ? 'جاري الحفظ...' : 'حفظ'}
</button>
</div>
</div>
)}
</Modal>
</div>
)
);
}

View File

@@ -1,18 +1,23 @@
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Shield, Edit, Trash2, Users, Check, X, Loader2, Plus } from 'lucide-react';
import { useState, useEffect, useCallback } from 'react';
import { Shield, Edit, Users, Check, X, Plus } from 'lucide-react';
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 LoadingSpinner from '@/components/LoadingSpinner';
const MODULES = [
{ id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' },
{ id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' },
{ id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' },
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
{ id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' },
{ id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' },
{ id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية', nameEn: 'Department Overtime Requests' },
{ id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' },
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
];
@@ -49,9 +54,7 @@ function buildMatrixFromPermissions(permissions: PositionPermission[]): Record<s
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)
const hasAll = perm && (Array.isArray(perm.actions)
? (perm.actions as string[]).includes('*') || (perm.actions as string[]).includes('all')
: false);
for (const a of ACTIONS) {
@@ -61,33 +64,27 @@ function buildMatrixFromPermissions(permissions: PositionPermission[]): Record<s
return matrix;
}
const initialCreateForm: CreatePositionData & { description?: string } = {
title: '',
titleAr: '',
code: '',
departmentId: '',
level: 5,
description: '',
};
export default function RolesManagement() {
const [roles, setRoles] = useState<PositionRole[]>([]);
const [departments, setDepartments] = useState<{ id: string; name: string; nameAr?: string | null }[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
// Edit modal (name + permissions)
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 [creating, setCreating] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [newTitleAr, setNewTitleAr] = useState('');
const [newDepartmentId, setNewDepartmentId] = useState('');
const [newLevel, setNewLevel] = useState<number>(1);
const [newCode, setNewCode] = useState('');
const [createForm, setCreateForm] = useState(initialCreateForm);
const [createErrors, setCreateErrors] = useState<Record<string, string>>({});
const [permissionMatrix, setPermissionMatrix] = useState<Record<string, Record<string, boolean>>>({});
const [saving, setSaving] = useState(false);
const fetchRoles = useCallback(async () => {
setLoading(true);
@@ -109,40 +106,49 @@ export default function RolesManagement() {
fetchRoles();
}, [fetchRoles]);
useEffect(() => {
departmentsAPI.getAll().then((depts) => setDepartments(depts)).catch(() => {});
}, []);
const currentRole = roles.find((r) => r.id === selectedRoleId);
// build departments options from existing roles
const departmentOptions = useMemo(() => {
const map = new Map<string, { id: string; label: string }>();
roles.forEach((r) => {
if (!r.departmentId) return;
const label = r.department?.nameAr || r.department?.name || r.departmentId;
if (!map.has(r.departmentId)) map.set(r.departmentId, { id: r.departmentId, label });
const handleCreateRole = async (e: React.FormEvent) => {
e.preventDefault();
const errs: Record<string, string> = {};
if (!createForm.title?.trim()) errs.title = 'Required';
if (!createForm.code?.trim()) errs.code = 'Required';
if (!createForm.departmentId) errs.departmentId = 'Required';
setCreateErrors(errs);
if (Object.keys(errs).length > 0) return;
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,
});
return Array.from(map.values());
}, [roles]);
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(() => {
if (currentRole) {
setPermissionMatrix(buildMatrixFromPermissions(currentRole.permissions || []));
setEditTitle(currentRole.title || '');
setEditTitleAr(currentRole.titleAr || '');
}
}, [currentRole?.id]);
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]);
}, [currentRole?.id, currentRole?.permissions]);
const handleTogglePermission = (moduleId: string, actionId: string) => {
setPermissionMatrix((prev) => ({
@@ -154,112 +160,21 @@ export default function RolesManagement() {
}));
};
const handleSaveRole = async () => {
const handleSavePermissions = async () => {
if (!selectedRoleId) return;
const titleFinal = (editTitle || '').trim() || (editTitleAr || '').trim();
if (!titleFinal) {
alert('الرجاء إدخال اسم الدور');
return;
}
setSaving(true);
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);
await positionsAPI.updatePermissions(selectedRoleId, permissions);
setShowEditModal(false);
await fetchRoles();
fetchRoles();
} catch (err: unknown) {
const msg =
(err as any)?.response?.data?.message ||
(err as any)?.response?.data?.error ||
(err as any)?.message ||
'فشل حفظ التغييرات';
alert(msg);
alert(err instanceof Error ? err.message : 'فشل حفظ الصلاحيات');
} finally {
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) => {
setSelectedRoleId(id);
setShowEditModal(false);
@@ -270,15 +185,14 @@ export default function RolesManagement() {
<div className="mb-8 flex items-center justify-between">
<div>
<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>
<button
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" />
إنشاء دور
<span className="font-semibold">إضافة دور</span>
</button>
</div>
@@ -306,8 +220,12 @@ export default function RolesManagement() {
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-3">
<div 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
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>
<h3 className="font-bold text-gray-900">{role.titleAr || role.title}</h3>
@@ -315,25 +233,11 @@ export default function RolesManagement() {
</div>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-gray-600">
<Users className="h-4 w-4" />
<span>{role.usersCount ?? role._count?.employees ?? 0} مستخدم</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={(e) => {
e.stopPropagation();
openDeleteDialog(role);
}}
className="p-1.5 text-red-600 hover:bg-red-50 rounded transition-colors"
title="حذف"
>
<Trash2 className="h-4 w-4" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
@@ -341,13 +245,11 @@ export default function RolesManagement() {
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>
@@ -365,11 +267,10 @@ export default function RolesManagement() {
onClick={() => setShowEditModal(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium"
>
تعديل
تعديل الصلاحيات
</button>
</div>
</div>
<div className="p-6">
<h3 className="text-lg font-bold text-gray-900 mb-4">مصفوفة الصلاحيات</h3>
<div className="overflow-x-auto">
@@ -386,7 +287,6 @@ export default function RolesManagement() {
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{MODULES.map((module) => (
<tr key={module.id} className="hover:bg-gray-50 transition-colors">
@@ -396,14 +296,15 @@ export default function RolesManagement() {
<p className="text-xs text-gray-600">{module.nameEn}</p>
</div>
</td>
{ACTIONS.map((action) => {
const hasPermission = permissionMatrix[module.id]?.[action.id];
return (
<td key={action.id} className="px-4 py-4 text-center">
<div
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" />}
@@ -432,189 +333,110 @@ export default function RolesManagement() {
{/* Create Role Modal */}
<Modal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onClose={() => {
setShowCreateModal(false);
setCreateForm(initialCreateForm);
setCreateErrors({});
}}
title="إضافة دور جديد"
size="lg"
size="md"
>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<form onSubmit={handleCreateRole} className="space-y-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">الاسم (English)</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Title (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"
type="text"
value={createForm.title}
onChange={(e) => setCreateForm((p) => ({ ...p, title: e.target.value }))}
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>
<label className="block text-sm font-semibold text-gray-700 mb-1">الاسم (عربي)</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Code *</label>
<input
value={newTitleAr}
onChange={(e) => setNewTitleAr(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
placeholder="مثال: مستخدم عادي"
type="text"
value={createForm.code}
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>
<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>
<div>
<label className="block text-sm font-medium 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"
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"
>
{departmentOptions.length === 0 ? (
<option value="">لا يوجد أقسام متاحة</option>
) : (
departmentOptions.map((d) => (
<option key={d.id} value={d.id}>
{d.label}
</option>
))
)}
<option value="">Select department</option>
{departments.map((d) => (
<option key={d.id} value={d.id}>{d.nameAr || d.name}</option>
))}
</select>
{departmentOptions.length === 0 && (
<p className="text-xs text-red-600 mt-1">
لا يوجد أقسام ضمن البيانات الحالية. (DepartmentId مطلوب لإنشاء الدور)
</p>
)}
{createErrors.departmentId && <p className="text-red-500 text-xs mt-1">{createErrors.departmentId}</p>}
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">Level</label>
<label className="block text-sm font-medium 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}
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>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">Code (اختياري)</label>
<input
value={newCode}
onChange={(e) => setNewCode(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
placeholder="e.g. SALES_REP (في حال كان فارغاً سيقوم النظام بتوليده تلقائياً)"
<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>
<div className="flex justify-end gap-3 pt-2">
{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-5 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
disabled={creating}
className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 font-medium"
>
إلغاء
Cancel
</button>
<button
onClick={handleCreateRole}
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={creating}
type="submit"
disabled={saving}
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}
{creating ? 'جاري الإنشاء...' : 'إنشاء'}
{saving ? 'Creating...' : 'Create Role'}
</button>
</div>
</div>
</form>
</Modal>
{/* Delete Confirmation Dialog */}
{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 */}
{/* Edit Permissions Modal */}
<Modal
isOpen={showEditModal}
onClose={() => setShowEditModal(false)}
title={`تعديل: ${currentRole?.titleAr || currentRole?.title || ''}`}
title={`تعديل صلاحيات: ${currentRole?.titleAr || currentRole?.title || ''}`}
size="2xl"
>
{currentRole && (
<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">
<table className="w-full">
<thead>
@@ -627,14 +449,12 @@ export default function RolesManagement() {
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{MODULES.map((module) => (
<tr key={module.id}>
<td className="px-4 py-4">
<p className="font-semibold text-gray-900">{module.name}</p>
</td>
{ACTIONS.map((action) => {
const hasPermission = permissionMatrix[module.id]?.[action.id];
return (
@@ -656,21 +476,18 @@ export default function RolesManagement() {
</tbody>
</table>
</div>
<div className="flex gap-3 justify-end">
<button
onClick={() => setShowEditModal(false)}
className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 font-medium"
disabled={saving}
>
إلغاء
</button>
<button
onClick={handleSaveRole}
onClick={handleSavePermissions}
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 ? 'جاري الحفظ...' : 'حفظ التغييرات'}
</button>
</div>

View File

@@ -13,7 +13,7 @@ import {
Shield,
Calendar,
} 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 type { User, CreateUserData, UpdateUserData } from '@/lib/api/admin';
import type { Employee } from '@/lib/api/employees';
@@ -567,6 +567,10 @@ function EditUserModal({
employeeId: null,
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(() => {
if (user) {
@@ -580,6 +584,41 @@ function EditUserModal({
}
}, [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) => {
e.preventDefault();
if (!user) return;
@@ -670,6 +709,50 @@ function EditUserModal({
الحساب نشط
</label>
</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">
<button type="button" onClick={onClose} className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 font-medium">
إلغاء

View File

@@ -98,7 +98,15 @@ function ContactDetailContent() {
INDIVIDUAL: 'bg-blue-100 text-blue-700',
COMPANY: 'bg-green-100 text-green-700',
HOLDING: 'bg-purple-100 text-purple-700',
GOVERNMENT: 'bg-orange-100 text-orange-700'
GOVERNMENT: 'bg-orange-100 text-orange-700',
ORGANIZATION: 'bg-cyan-100 text-cyan-700',
EMBASSIES: 'bg-red-100 text-red-700',
BANK: 'bg-emerald-100 text-emerald-700',
UNIVERSITY: 'bg-indigo-100 text-indigo-700',
SCHOOL: 'bg-yellow-100 text-yellow-700',
UN: 'bg-sky-100 text-sky-700',
NGO: 'bg-pink-100 text-pink-700',
INSTITUTION: 'bg-gray-100 text-gray-700'
}
return colors[type] || 'bg-gray-100 text-gray-700'
}
@@ -108,7 +116,15 @@ function ContactDetailContent() {
INDIVIDUAL: 'فرد - Individual',
COMPANY: 'شركة - Company',
HOLDING: 'مجموعة - Holding',
GOVERNMENT: 'حكومي - Government'
GOVERNMENT: 'حكومي - Government',
ORGANIZATION: 'منظمات - Organizations',
BANK: 'بنوك - Banks',
UNIVERSITY: 'جامعات - Universities',
EMBASSIES: 'سفارات - Embassies',
SCHOOL: 'مدارس - Schools',
UN: 'UN - United Nations',
NGO: 'NGO - Non-Governmental Organization',
INSTITUTION: 'مؤسسة - Institution'
}
return labels[type] || type
}

View File

@@ -41,20 +41,17 @@ function flattenCategories(cats: Category[], result: Category[] = []): Category[
}
function ContactsContent() {
// State Management
const [contacts, setContacts] = useState<Contact[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedContacts, setSelectedContacts] = useState<Set<string>>(new Set())
const [showBulkActions, setShowBulkActions] = useState(false)
// Pagination
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [total, setTotal] = useState(0)
const pageSize = 10
// Filters
const [searchTerm, setSearchTerm] = useState('')
const [selectedType, setSelectedType] = useState('all')
const [selectedStatus, setSelectedStatus] = useState('all')
@@ -64,7 +61,6 @@ function ContactsContent() {
const [categories, setCategories] = useState<Category[]>([])
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
// Modals
const [showCreateModal, setShowCreateModal] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
@@ -75,7 +71,6 @@ function ContactsContent() {
const [exporting, setExporting] = useState(false)
const [exportExcludeCompanyEmployees, setExportExcludeCompanyEmployees] = useState(false)
// Fetch Contacts (with debouncing for search)
const fetchContacts = useCallback(async () => {
setLoading(true)
setError(null)
@@ -104,21 +99,18 @@ function ContactsContent() {
}
}, [currentPage, searchTerm, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory])
// Debounced search
useEffect(() => {
const debounce = setTimeout(() => {
setCurrentPage(1) // Reset to page 1 on new search
setCurrentPage(1)
fetchContacts()
}, 500)
return () => clearTimeout(debounce)
}, [searchTerm])
// Fetch on filter/page change
useEffect(() => {
fetchContacts()
}, [currentPage, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory])
// Create Contact
const handleCreate = async (data: CreateContactData) => {
setSubmitting(true)
try {
@@ -136,7 +128,6 @@ function ContactsContent() {
}
}
// Edit Contact
const handleEdit = async (data: UpdateContactData) => {
if (!selectedContact) return
@@ -156,7 +147,6 @@ function ContactsContent() {
}
}
// Delete Contact
const handleDelete = async () => {
if (!selectedContact) return
@@ -175,7 +165,6 @@ function ContactsContent() {
}
}
// Utility Functions
const resetForm = () => {
setSelectedContact(null)
}
@@ -195,7 +184,15 @@ function ContactsContent() {
INDIVIDUAL: 'bg-blue-100 text-blue-700',
COMPANY: 'bg-green-100 text-green-700',
HOLDING: 'bg-purple-100 text-purple-700',
GOVERNMENT: 'bg-orange-100 text-orange-700'
GOVERNMENT: 'bg-orange-100 text-orange-700',
ORGANIZATION: 'bg-cyan-100 text-cyan-700',
EMBASSIES: 'bg-red-100 text-red-700',
BANK: 'bg-emerald-100 text-emerald-700',
UNIVERSITY: 'bg-indigo-100 text-indigo-700',
SCHOOL: 'bg-yellow-100 text-yellow-700',
UN: 'bg-sky-100 text-sky-700',
NGO: 'bg-pink-100 text-pink-700',
INSTITUTION: 'bg-gray-100 text-gray-700'
}
return colors[type] || 'bg-gray-100 text-gray-700'
}
@@ -209,15 +206,49 @@ function ContactsContent() {
INDIVIDUAL: 'فرد',
COMPANY: 'شركة',
HOLDING: 'مجموعة',
GOVERNMENT: 'حكومي'
GOVERNMENT: 'حكومي',
ORGANIZATION: 'منظمات',
EMBASSIES: 'سفارات',
BANK: 'بنوك',
UNIVERSITY: 'جامعات',
SCHOOL: 'مدارس',
UN: 'UN',
NGO: 'NGO',
INSTITUTION: 'مؤسسة'
}
return labels[type] || type
}
const organizationTypes = new Set([
'COMPANY',
'HOLDING',
'GOVERNMENT',
'ORGANIZATION',
'EMBASSIES',
'BANK',
'UNIVERSITY',
'SCHOOL',
'UN',
'NGO',
'INSTITUTION',
])
const isOrganizationContact = (contact: Contact) => organizationTypes.has(contact.type)
const getListContactName = (contact: Contact) => {
return contact.name || '-'
}
const getListCompanyName = (contact: Contact) => {
return contact.companyName || '-'
}
const getListContactNameAr = (contact: Contact) => {
return (contact as any).nameAr || ''
}
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
@@ -289,7 +320,6 @@ function ContactsContent() {
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200">
<div className="flex items-center justify-between">
@@ -344,12 +374,9 @@ function ContactsContent() {
</div>
</div>
{/* Filters and Search */}
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200 mb-6">
<div className="space-y-4">
{/* Main Filters Row */}
<div className="flex flex-col md:flex-row gap-4">
{/* Search */}
<div className="flex-1 relative">
<Search className="absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
@@ -361,7 +388,6 @@ function ContactsContent() {
/>
</div>
{/* Type Filter */}
<select
value={selectedType}
onChange={(e) => setSelectedType(e.target.value)}
@@ -372,9 +398,16 @@ function ContactsContent() {
<option value="COMPANY">Companies</option>
<option value="HOLDING">Holdings</option>
<option value="GOVERNMENT">Government</option>
<option value="ORGANIZATION">Organizations</option>
<option value="EMBASSIES">Embassies</option>
<option value="BANK">Banks</option>
<option value="UNIVERSITY">Universities</option>
<option value="SCHOOL">Schools</option>
<option value="UN">UN</option>
<option value="NGO">NGO</option>
<option value="INSTITUTION">Institution</option>
</select>
{/* Status Filter */}
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value)}
@@ -385,7 +418,6 @@ function ContactsContent() {
<option value="INACTIVE">Inactive</option>
</select>
{/* Advanced Filters Toggle */}
<button
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
className={`flex items-center gap-2 px-4 py-3 border rounded-lg transition-colors ${
@@ -399,11 +431,9 @@ function ContactsContent() {
</button>
</div>
{/* Advanced Filters */}
{showAdvancedFilters && (
<div className="pt-4 border-t border-gray-200">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Source Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Source</label>
<select
@@ -423,7 +453,6 @@ function ContactsContent() {
</select>
</div>
{/* Rating Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Rating</label>
<select
@@ -440,7 +469,6 @@ function ContactsContent() {
</select>
</div>
{/* Category Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Category</label>
<select
@@ -455,7 +483,6 @@ function ContactsContent() {
</select>
</div>
{/* Clear Filters */}
<div className="flex items-end">
<button
onClick={() => {
@@ -478,7 +505,6 @@ function ContactsContent() {
</div>
</div>
{/* Contacts Table */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
{loading ? (
<div className="p-12">
@@ -525,9 +551,9 @@ function ContactsContent() {
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Contact</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Contact Info</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Company</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Contact Info</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Contact</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Type</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Status</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Actions</th>
@@ -554,17 +580,18 @@ function ContactsContent() {
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold">
{contact.name.charAt(0)}
</div>
<div>
<p className="font-semibold text-gray-900">{contact.name}</p>
{contact.nameAr && <p className="text-sm text-gray-600">{contact.nameAr}</p>}
</div>
{getListCompanyName(contact) !== '-' && (
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-900">
{getListCompanyName(contact)}
</span>
</div>
)}
</td>
<td className="px-6 py-4">
<div className="space-y-1">
{contact.email && (
@@ -573,28 +600,40 @@ function ContactsContent() {
{contact.email}
</div>
)}
{contact.phone && (
{(contact.phone || contact.mobile) && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<Phone className="h-4 w-4" />
{contact.phone}
{contact.phone || contact.mobile}
</div>
)}
</div>
</td>
<td className="px-6 py-4">
{contact.companyName && (
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-900">{contact.companyName}</span>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold">
{getListContactName(contact).charAt(0)}
</div>
<div>
<p className="font-semibold text-gray-900">
{getListContactName(contact)}
</p>
{getListContactNameAr(contact) && (
<p className="text-sm text-gray-600">
{getListContactNameAr(contact)}
</p>
)}
</div>
</div>
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium ${getTypeColor(contact.type)}`}>
<Tag className="h-3 w-3" />
{getTypeLabel(contact.type)}
</span>
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
contact.status === 'ACTIVE'
@@ -604,6 +643,7 @@ function ContactsContent() {
{contact.status === 'ACTIVE' ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<Link
@@ -630,12 +670,12 @@ function ContactsContent() {
</div>
</td>
</tr>
)})}
)
})}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
<p className="text-sm text-gray-600">
Showing <span className="font-semibold">{((currentPage - 1) * pageSize) + 1}</span> to{' '}
@@ -681,7 +721,6 @@ function ContactsContent() {
</div>
</main>
{/* Create Modal */}
<Modal
isOpen={showCreateModal}
onClose={() => {
@@ -692,6 +731,7 @@ function ContactsContent() {
size="xl"
>
<ContactForm
key="create-contact"
onSubmit={async (data) => {
await handleCreate(data as CreateContactData)
}}
@@ -703,7 +743,6 @@ function ContactsContent() {
/>
</Modal>
{/* Edit Modal */}
<Modal
isOpen={showEditModal}
onClose={() => {
@@ -714,6 +753,7 @@ function ContactsContent() {
size="xl"
>
<ContactForm
key={selectedContact?.id || 'edit-contact'}
contact={selectedContact || undefined}
onSubmit={async (data) => {
await handleEdit(data as UpdateContactData)
@@ -726,7 +766,6 @@ function ContactsContent() {
/>
</Modal>
{/* Export Modal */}
{showExportModal && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowExportModal(false)} />
@@ -820,7 +859,6 @@ function ContactsContent() {
</div>
)}
{/* Delete Confirmation Dialog */}
{showDeleteDialog && selectedContact && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowDeleteDialog(false)} />
@@ -869,7 +907,6 @@ function ContactsContent() {
</div>
)}
{/* Import Modal */}
{showImportModal && (
<ContactImport
onClose={() => setShowImportModal(false)}

View File

@@ -7,7 +7,6 @@ import { toast } from 'react-hot-toast'
import {
ArrowLeft,
Edit,
Archive,
History,
Award,
TrendingDown,
@@ -15,15 +14,23 @@ import {
Target,
Calendar,
User,
Building2,
FileText,
Clock,
Loader2
Loader2,
Plus,
Check,
X,
Receipt,
FileSignature
} from 'lucide-react'
import ProtectedRoute from '@/components/ProtectedRoute'
import LoadingSpinner from '@/components/LoadingSpinner'
import Modal from '@/components/Modal'
import { dealsAPI, Deal } from '@/lib/api/deals'
import { quotesAPI, Quote } from '@/lib/api/quotes'
import { costSheetsAPI, CostSheet, CostSheetItem } from '@/lib/api/costSheets'
import { contractsAPI, Contract, CreateContractData } from '@/lib/api/contracts'
import { invoicesAPI, Invoice, InvoiceItem } from '@/lib/api/invoices'
import { useLanguage } from '@/contexts/LanguageContext'
function DealDetailContent() {
@@ -34,15 +41,165 @@ function DealDetailContent() {
const [deal, setDeal] = useState<Deal | null>(null)
const [quotes, setQuotes] = useState<Quote[]>([])
const [costSheets, setCostSheets] = useState<CostSheet[]>([])
const [contracts, setContracts] = useState<Contract[]>([])
const [invoices, setInvoices] = useState<Invoice[]>([])
const [history, setHistory] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'info' | 'quotes' | 'history'>('info')
const [activeTab, setActiveTab] = useState<'info' | 'quotes' | 'costSheets' | 'contracts' | 'invoices' | 'history'>('info')
const [showWinDialog, setShowWinDialog] = useState(false)
const [showLoseDialog, setShowLoseDialog] = useState(false)
const [showCostSheetModal, setShowCostSheetModal] = useState(false)
const [showContractModal, setShowContractModal] = useState(false)
const [showInvoiceModal, setShowInvoiceModal] = useState(false)
const [showPaymentModal, setShowPaymentModal] = useState<Invoice | null>(null)
const [winData, setWinData] = useState({ actualValue: 0, wonReason: '' })
const [loseData, setLoseData] = useState({ lostReason: '' })
const [submitting, setSubmitting] = useState(false)
const [costSheetForm, setCostSheetForm] = useState({
items: [{ description: '', source: '', cost: 0, quantity: 1 }] as CostSheetItem[],
totalCost: 0,
suggestedPrice: 0,
profitMargin: 0,
})
const [contractForm, setContractForm] = useState<CreateContractData>({
dealId: '',
title: '',
type: 'SALES',
clientInfo: {},
companyInfo: {},
startDate: '',
endDate: '',
value: 0,
paymentTerms: {},
deliveryTerms: {},
terms: '',
})
const [invoiceForm, setInvoiceForm] = useState({
items: [{ description: '', quantity: 1, unitPrice: 0, total: 0 }] as InvoiceItem[],
subtotal: 0,
taxAmount: 0,
total: 0,
dueDate: '',
})
const [paymentForm, setPaymentForm] = useState({ paidAmount: 0, paidDate: new Date().toISOString().slice(0, 10) })
useEffect(() => {
if (showPaymentModal) {
setPaymentForm({ paidAmount: Number(showPaymentModal?.total) || 0, paidDate: new Date().toISOString().slice(0, 10) })
}
}, [showPaymentModal])
useEffect(() => {
if (deal && showContractModal) {
const contact = deal.contact
setContractForm((f) => ({
...f,
dealId: deal.id,
clientInfo: contact ? { name: contact.name, email: contact.email, phone: contact.phone } : {},
companyInfo: f.companyInfo && Object.keys(f.companyInfo).length ? f.companyInfo : {},
}))
}
}, [deal, showContractModal])
const handleCreateCostSheet = async () => {
const items = costSheetForm.items.filter((i) => i.cost > 0 || i.description)
if (!items.length || costSheetForm.totalCost <= 0 || costSheetForm.suggestedPrice <= 0) {
toast.error(t('crm.fixFormErrors'))
return
}
setSubmitting(true)
try {
await costSheetsAPI.create({
dealId,
items,
totalCost: costSheetForm.totalCost,
suggestedPrice: costSheetForm.suggestedPrice,
profitMargin: costSheetForm.profitMargin,
})
toast.success(t('crm.costSheetCreated'))
setShowCostSheetModal(false)
setCostSheetForm({ items: [{ description: '', source: '', cost: 0, quantity: 1 }], totalCost: 0, suggestedPrice: 0, profitMargin: 0 })
fetchCostSheets()
} catch (e: any) {
toast.error(e.response?.data?.message || 'Failed')
} finally {
setSubmitting(false)
}
}
const handleCreateContract = async () => {
if (!contractForm.title || !contractForm.startDate || contractForm.value <= 0) {
toast.error(t('crm.fixFormErrors'))
return
}
setSubmitting(true)
try {
await contractsAPI.create({
...contractForm,
dealId,
clientInfo: contractForm.clientInfo || {},
companyInfo: contractForm.companyInfo || {},
paymentTerms: contractForm.paymentTerms || {},
deliveryTerms: contractForm.deliveryTerms || {},
})
toast.success(t('crm.contractCreated'))
setShowContractModal(false)
setContractForm({ dealId: '', title: '', type: 'SALES', clientInfo: {}, companyInfo: {}, startDate: '', endDate: '', value: 0, paymentTerms: {}, deliveryTerms: {}, terms: '' })
fetchContracts()
} catch (e: any) {
toast.error(e.response?.data?.message || 'Failed')
} finally {
setSubmitting(false)
}
}
const handleCreateInvoice = async () => {
const items = invoiceForm.items.filter((i) => i.quantity > 0 && i.unitPrice >= 0)
if (!items.length || invoiceForm.total <= 0 || !invoiceForm.dueDate) {
toast.error(t('crm.fixFormErrors'))
return
}
setSubmitting(true)
try {
await invoicesAPI.create({
dealId,
items: items.map((i) => ({ ...i, total: (i.quantity || 0) * (i.unitPrice || 0) })),
subtotal: invoiceForm.subtotal,
taxAmount: invoiceForm.taxAmount,
total: invoiceForm.total,
dueDate: invoiceForm.dueDate,
})
toast.success(t('crm.invoiceCreated'))
setShowInvoiceModal(false)
setInvoiceForm({ items: [{ description: '', quantity: 1, unitPrice: 0, total: 0 }], subtotal: 0, taxAmount: 0, total: 0, dueDate: '' })
fetchInvoices()
} catch (e: any) {
toast.error(e.response?.data?.message || 'Failed')
} finally {
setSubmitting(false)
}
}
const handleRecordPayment = async () => {
if (!showPaymentModal || paymentForm.paidAmount <= 0) {
toast.error(t('crm.fixFormErrors'))
return
}
setSubmitting(true)
try {
await invoicesAPI.recordPayment(showPaymentModal.id, paymentForm.paidAmount, paymentForm.paidDate)
toast.success(t('crm.paymentRecorded'))
setShowPaymentModal(null)
setPaymentForm({ paidAmount: 0, paidDate: new Date().toISOString().slice(0, 10) })
fetchInvoices()
} catch (e: any) {
toast.error(e.response?.data?.message || 'Failed')
} finally {
setSubmitting(false)
}
}
useEffect(() => {
fetchDeal()
@@ -51,6 +208,9 @@ function DealDetailContent() {
useEffect(() => {
if (deal) {
fetchQuotes()
fetchCostSheets()
fetchContracts()
fetchInvoices()
fetchHistory()
}
}, [deal])
@@ -88,6 +248,33 @@ function DealDetailContent() {
}
}
const fetchCostSheets = async () => {
try {
const data = await costSheetsAPI.getByDeal(dealId)
setCostSheets(data || [])
} catch {
setCostSheets([])
}
}
const fetchContracts = async () => {
try {
const data = await contractsAPI.getByDeal(dealId)
setContracts(data || [])
} catch {
setContracts([])
}
}
const fetchInvoices = async () => {
try {
const data = await invoicesAPI.getByDeal(dealId)
setInvoices(data || [])
} catch {
setInvoices([])
}
}
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
ACTIVE: 'bg-green-100 text-green-700',
@@ -272,18 +459,18 @@ function DealDetailContent() {
<div className="lg:col-span-2">
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<div className="border-b border-gray-200">
<nav className="flex gap-4 px-6">
{(['info', 'quotes', 'history'] as const).map((tab) => (
<nav className="flex gap-4 px-6 overflow-x-auto">
{(['info', 'quotes', 'costSheets', 'contracts', 'invoices', 'history'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors whitespace-nowrap ${
activeTab === tab
? 'border-green-600 text-green-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{tab === 'info' ? t('crm.dealInfo') : tab === 'quotes' ? t('crm.quotes') : t('crm.history')}
{tab === 'info' ? t('crm.dealInfo') : tab === 'quotes' ? t('crm.quotes') : tab === 'costSheets' ? t('crm.costSheets') : tab === 'contracts' ? t('crm.contracts') : tab === 'invoices' ? t('crm.invoices') : t('crm.history')}
</button>
))}
</nav>
@@ -377,6 +564,135 @@ function DealDetailContent() {
</div>
)}
{activeTab === 'costSheets' && (
<div>
<div className="flex justify-between items-center mb-4">
<h3 className="font-semibold text-gray-900">{t('crm.costSheets')}</h3>
<button onClick={() => setShowCostSheetModal(true)} className="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm">
<Plus className="h-4 w-4" />
{t('crm.addCostSheet')}
</button>
</div>
{costSheets.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<FileText className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>{t('common.noData')}</p>
</div>
) : (
<div className="space-y-4">
{costSheets.map((cs) => (
<div key={cs.id} className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50">
<div className="flex justify-between items-start">
<div>
<p className="font-medium text-gray-900">{cs.costSheetNumber}</p>
<p className="text-sm text-gray-500">v{cs.version} · {cs.status}</p>
<p className="text-xs text-gray-500 mt-1">
{Number(cs.totalCost)?.toLocaleString()} SAR cost · {Number(cs.suggestedPrice)?.toLocaleString()} SAR suggested · {Number(cs.profitMargin)}% margin
</p>
</div>
{cs.status === 'DRAFT' && (
<div className="flex gap-2">
<button onClick={async () => { try { await costSheetsAPI.approve(cs.id); toast.success(t('crm.costSheetApproved')); fetchCostSheets(); } catch (e: any) { toast.error(e.response?.data?.message || 'Failed'); } }} className="flex items-center gap-1 px-2 py-1 text-green-600 border border-green-300 rounded hover:bg-green-50 text-sm">
<Check className="h-4 w-4" />
{t('crm.approve')}
</button>
<button onClick={async () => { try { await costSheetsAPI.reject(cs.id); toast.success(t('crm.costSheetRejected')); fetchCostSheets(); } catch (e: any) { toast.error(e.response?.data?.message || 'Failed'); } }} className="flex items-center gap-1 px-2 py-1 text-red-600 border border-red-300 rounded hover:bg-red-50 text-sm">
<X className="h-4 w-4" />
{t('crm.reject')}
</button>
</div>
)}
</div>
<p className="text-xs text-gray-500 mt-2">{formatDate(cs.createdAt)}</p>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'contracts' && (
<div>
<div className="flex justify-between items-center mb-4">
<h3 className="font-semibold text-gray-900">{t('crm.contracts')}</h3>
<button onClick={() => setShowContractModal(true)} className="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm">
<Plus className="h-4 w-4" />
{t('crm.addContract')}
</button>
</div>
{contracts.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<FileSignature className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>{t('common.noData')}</p>
</div>
) : (
<div className="space-y-4">
{contracts.map((c) => (
<div key={c.id} className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50">
<div className="flex justify-between items-start">
<div>
<p className="font-medium text-gray-900">{c.contractNumber} · {c.title}</p>
<p className="text-sm text-gray-500">{c.type} · {c.status}</p>
<p className="text-xs text-gray-500 mt-1">
{Number(c.value)?.toLocaleString()} SAR · {formatDate(c.startDate)} {c.endDate ? ` ${formatDate(c.endDate)}` : ''}
</p>
</div>
{c.status === 'PENDING_SIGNATURE' && (
<button onClick={async () => { try { await contractsAPI.sign(c.id); toast.success(t('crm.contractSigned')); fetchContracts(); } catch (e: any) { toast.error(e.response?.data?.message || 'Failed'); } }} className="flex items-center gap-1 px-2 py-1 text-green-600 border border-green-300 rounded hover:bg-green-50 text-sm">
<FileSignature className="h-4 w-4" />
{t('crm.markSigned')}
</button>
)}
</div>
<p className="text-xs text-gray-500 mt-2">{formatDate(c.createdAt)}</p>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'invoices' && (
<div>
<div className="flex justify-between items-center mb-4">
<h3 className="font-semibold text-gray-900">{t('crm.invoices')}</h3>
<button onClick={() => setShowInvoiceModal(true)} className="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm">
<Plus className="h-4 w-4" />
{t('crm.addInvoice')}
</button>
</div>
{invoices.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<Receipt className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>{t('common.noData')}</p>
</div>
) : (
<div className="space-y-4">
{invoices.map((inv) => (
<div key={inv.id} className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50">
<div className="flex justify-between items-start">
<div>
<p className="font-medium text-gray-900">{inv.invoiceNumber}</p>
<p className="text-sm text-gray-500">{inv.status}</p>
<p className="text-xs text-gray-500 mt-1">
{Number(inv.total)?.toLocaleString()} SAR · {inv.paidAmount ? `${Number(inv.paidAmount)?.toLocaleString()} paid` : ''} · due {formatDate(inv.dueDate)}
</p>
</div>
{(inv.status === 'SENT' || inv.status === 'OVERDUE') && (
<button onClick={() => setShowPaymentModal(inv)} className="flex items-center gap-1 px-2 py-1 text-green-600 border border-green-300 rounded hover:bg-green-50 text-sm">
<DollarSign className="h-4 w-4" />
{t('crm.recordPayment')}
</button>
)}
</div>
<p className="text-xs text-gray-500 mt-2">{formatDate(inv.createdAt)}</p>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'history' && (
<div>
{history.length === 0 ? (
@@ -527,6 +843,179 @@ function DealDetailContent() {
</div>
</div>
)}
{/* Cost Sheet Modal */}
<Modal isOpen={showCostSheetModal} onClose={() => setShowCostSheetModal(false)} title={t('crm.addCostSheet')} size="xl">
<div className="space-y-4">
<p className="text-sm text-gray-600">{t('crm.costSheetItems')}</p>
{costSheetForm.items.map((item, idx) => (
<div key={idx} className="grid grid-cols-12 gap-2 items-end">
<input placeholder={t('crm.description')} value={item.description || ''} onChange={(e) => {
const next = [...costSheetForm.items]; next[idx] = { ...next[idx], description: e.target.value }; setCostSheetForm({ ...costSheetForm, items: next })
}} className="col-span-4 px-3 py-2 border rounded-lg" />
<input placeholder={t('crm.source')} value={item.source || ''} onChange={(e) => {
const next = [...costSheetForm.items]; next[idx] = { ...next[idx], source: e.target.value }; setCostSheetForm({ ...costSheetForm, items: next })
}} className="col-span-2 px-3 py-2 border rounded-lg" />
<input type="number" placeholder="Cost" value={item.cost || ''} onChange={(e) => {
const next = [...costSheetForm.items]; next[idx] = { ...next[idx], cost: parseFloat(e.target.value) || 0 }; const total = next.reduce((s, i) => s + (i.cost || 0) * (i.quantity || 1), 0); setCostSheetForm({ ...costSheetForm, items: next, totalCost: total })
}} className="col-span-2 px-3 py-2 border rounded-lg" />
<input type="number" placeholder="Qty" value={item.quantity || 1} onChange={(e) => {
const next = [...costSheetForm.items]; next[idx] = { ...next[idx], quantity: parseInt(e.target.value) || 1 }; const total = next.reduce((s, i) => s + (i.cost || 0) * (i.quantity || 1), 0); setCostSheetForm({ ...costSheetForm, items: next, totalCost: total })
}} className="col-span-2 px-3 py-2 border rounded-lg" />
<button type="button" onClick={() => setCostSheetForm({ ...costSheetForm, items: costSheetForm.items.filter((_, i) => i !== idx) })} className="col-span-2 py-2 text-red-600 hover:bg-red-50 rounded">×</button>
</div>
))}
<button type="button" onClick={() => setCostSheetForm({ ...costSheetForm, items: [...costSheetForm.items, { description: '', source: '', cost: 0, quantity: 1 }] })} className="text-sm text-green-600 hover:underline">
+ {t('crm.addRow')}
</button>
<div className="grid grid-cols-3 gap-4 pt-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.totalCost')}</label>
<input type="number" value={costSheetForm.totalCost || ''} onChange={(e) => setCostSheetForm({ ...costSheetForm, totalCost: parseFloat(e.target.value) || 0 })} className="w-full px-3 py-2 border rounded-lg" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.suggestedPrice')}</label>
<input type="number" value={costSheetForm.suggestedPrice || ''} onChange={(e) => setCostSheetForm({ ...costSheetForm, suggestedPrice: parseFloat(e.target.value) || 0 })} className="w-full px-3 py-2 border rounded-lg" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.profitMargin')} (%)</label>
<input type="number" value={costSheetForm.profitMargin || ''} onChange={(e) => setCostSheetForm({ ...costSheetForm, profitMargin: parseFloat(e.target.value) || 0 })} className="w-full px-3 py-2 border rounded-lg" />
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button onClick={() => setShowCostSheetModal(false)} className="px-4 py-2 border rounded-lg">{t('common.cancel')}</button>
<button onClick={handleCreateCostSheet} disabled={submitting} className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg disabled:opacity-50">
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{t('common.save')}
</button>
</div>
</div>
</Modal>
{/* Contract Modal */}
<Modal isOpen={showContractModal} onClose={() => setShowContractModal(false)} title={t('crm.addContract')} size="xl">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.contractTitle')} *</label>
<input value={contractForm.title} onChange={(e) => setContractForm({ ...contractForm, title: e.target.value })} className="w-full px-3 py-2 border rounded-lg" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.contractType')}</label>
<select value={contractForm.type} onChange={(e) => setContractForm({ ...contractForm, type: e.target.value })} className="w-full px-3 py-2 border rounded-lg">
<option value="SALES">{t('crm.contractTypeSales')}</option>
<option value="SERVICE">{t('crm.contractTypeService')}</option>
<option value="MAINTENANCE">{t('crm.contractTypeMaintenance')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.contractValue')} *</label>
<input type="number" value={contractForm.value || ''} onChange={(e) => setContractForm({ ...contractForm, value: parseFloat(e.target.value) || 0 })} className="w-full px-3 py-2 border rounded-lg" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.startDate')} *</label>
<input type="date" value={contractForm.startDate} onChange={(e) => setContractForm({ ...contractForm, startDate: e.target.value })} className="w-full px-3 py-2 border rounded-lg" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.endDate')}</label>
<input type="date" value={contractForm.endDate || ''} onChange={(e) => setContractForm({ ...contractForm, endDate: e.target.value })} className="w-full px-3 py-2 border rounded-lg" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.paymentTerms')}</label>
<input placeholder="e.g. Net 30" value={typeof contractForm.paymentTerms === 'object' && contractForm.paymentTerms?.description ? (contractForm.paymentTerms as any).description : ''} onChange={(e) => setContractForm({ ...contractForm, paymentTerms: { description: e.target.value } })} className="w-full px-3 py-2 border rounded-lg" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.deliveryTerms')}</label>
<input placeholder="e.g. FOB" value={typeof contractForm.deliveryTerms === 'object' && contractForm.deliveryTerms?.description ? (contractForm.deliveryTerms as any).description : ''} onChange={(e) => setContractForm({ ...contractForm, deliveryTerms: { description: e.target.value } })} className="w-full px-3 py-2 border rounded-lg" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.terms')}</label>
<textarea value={contractForm.terms} onChange={(e) => setContractForm({ ...contractForm, terms: e.target.value })} rows={3} className="w-full px-3 py-2 border rounded-lg" />
</div>
<div className="flex justify-end gap-3 pt-4">
<button onClick={() => setShowContractModal(false)} className="px-4 py-2 border rounded-lg">{t('common.cancel')}</button>
<button onClick={handleCreateContract} disabled={submitting} className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg disabled:opacity-50">
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{t('common.save')}
</button>
</div>
</div>
</Modal>
{/* Invoice Modal */}
<Modal isOpen={showInvoiceModal} onClose={() => setShowInvoiceModal(false)} title={t('crm.addInvoice')} size="xl">
<div className="space-y-4">
<p className="text-sm text-gray-600">{t('crm.invoiceItems')}</p>
{invoiceForm.items.map((item, idx) => (
<div key={idx} className="grid grid-cols-12 gap-2 items-end">
<input placeholder={t('crm.description')} value={item.description || ''} onChange={(e) => { const next = [...invoiceForm.items]; next[idx] = { ...next[idx], description: e.target.value }; setInvoiceForm({ ...invoiceForm, items: next }) }} className="col-span-4 px-3 py-2 border rounded-lg" />
<input type="number" placeholder="Qty" value={item.quantity || 1} onChange={(e) => {
const next = [...invoiceForm.items]; next[idx] = { ...next[idx], quantity: parseInt(e.target.value) || 1, unitPrice: next[idx].unitPrice || 0 }; next[idx].total = (next[idx].quantity || 0) * (next[idx].unitPrice || 0); const sub = next.reduce((s, i) => s + ((i.quantity || 0) * (i.unitPrice || 0)), 0); setInvoiceForm({ ...invoiceForm, items: next, subtotal: sub, total: sub + invoiceForm.taxAmount })
}} className="col-span-2 px-3 py-2 border rounded-lg" />
<input type="number" placeholder="Unit Price" value={item.unitPrice || ''} onChange={(e) => {
const next = [...invoiceForm.items]; next[idx] = { ...next[idx], unitPrice: parseFloat(e.target.value) || 0 }; next[idx].total = (next[idx].quantity || 0) * (next[idx].unitPrice || 0); const sub = next.reduce((s, i) => s + ((i.quantity || 0) * (i.unitPrice || 0)), 0); setInvoiceForm({ ...invoiceForm, items: next, subtotal: sub, total: sub + invoiceForm.taxAmount })
}} className="col-span-2 px-3 py-2 border rounded-lg" />
<span className="col-span-2 py-2 text-gray-600">{(item.quantity || 0) * (item.unitPrice || 0)}</span>
<button type="button" onClick={() => setInvoiceForm({ ...invoiceForm, items: invoiceForm.items.filter((_, i) => i !== idx) })} className="col-span-2 py-2 text-red-600 hover:bg-red-50 rounded">×</button>
</div>
))}
<button type="button" onClick={() => setInvoiceForm({ ...invoiceForm, items: [...invoiceForm.items, { description: '', quantity: 1, unitPrice: 0, total: 0 }] })} className="text-sm text-green-600 hover:underline">
+ {t('crm.addRow')}
</button>
<div className="grid grid-cols-4 gap-4 pt-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.subtotal')}</label>
<input type="number" value={invoiceForm.subtotal || ''} onChange={(e) => setInvoiceForm({ ...invoiceForm, subtotal: parseFloat(e.target.value) || 0, total: (parseFloat(e.target.value) || 0) + invoiceForm.taxAmount })} className="w-full px-3 py-2 border rounded-lg" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.taxAmount')}</label>
<input type="number" value={invoiceForm.taxAmount || ''} onChange={(e) => setInvoiceForm({ ...invoiceForm, taxAmount: parseFloat(e.target.value) || 0, total: invoiceForm.subtotal + (parseFloat(e.target.value) || 0) })} className="w-full px-3 py-2 border rounded-lg" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.total')}</label>
<input type="number" value={invoiceForm.total || ''} onChange={(e) => setInvoiceForm({ ...invoiceForm, total: parseFloat(e.target.value) || 0 })} className="w-full px-3 py-2 border rounded-lg" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.dueDate')} *</label>
<input type="date" value={invoiceForm.dueDate} onChange={(e) => setInvoiceForm({ ...invoiceForm, dueDate: e.target.value })} className="w-full px-3 py-2 border rounded-lg" />
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button onClick={() => setShowInvoiceModal(false)} className="px-4 py-2 border rounded-lg">{t('common.cancel')}</button>
<button onClick={handleCreateInvoice} disabled={submitting} className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg disabled:opacity-50">
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{t('common.save')}
</button>
</div>
</div>
</Modal>
{/* Record Payment Modal */}
{showPaymentModal && (
<Modal isOpen={!!showPaymentModal} onClose={() => setShowPaymentModal(null)} title={t('crm.recordPayment')}>
<div className="space-y-4">
<p className="text-sm text-gray-600">{showPaymentModal.invoiceNumber} · {Number(showPaymentModal.total)?.toLocaleString()} SAR</p>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.paidAmount')} *</label>
<input type="number" value={paymentForm.paidAmount || ''} onChange={(e) => setPaymentForm({ ...paymentForm, paidAmount: parseFloat(e.target.value) || 0 })} className="w-full px-3 py-2 border rounded-lg" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.paidDate')}</label>
<input type="date" value={paymentForm.paidDate} onChange={(e) => setPaymentForm({ ...paymentForm, paidDate: e.target.value })} className="w-full px-3 py-2 border rounded-lg" />
</div>
<div className="flex justify-end gap-3 pt-4">
<button onClick={() => setShowPaymentModal(null)} className="px-4 py-2 border rounded-lg">{t('common.cancel')}</button>
<button onClick={handleRecordPayment} disabled={submitting} className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg disabled:opacity-50">
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{t('crm.recordPayment')}
</button>
</div>
</div>
</Modal>
)}
</div>
)
}

View File

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

View File

@@ -1,5 +1,8 @@
'use client'
import { useState, useEffect } from 'react'
import Image from 'next/image'
import logoImage from '@/assets/logo.png'
import ProtectedRoute from '@/components/ProtectedRoute'
import { useAuth } from '@/contexts/AuthContext'
import { useLanguage } from '@/contexts/LanguageContext'
@@ -7,6 +10,7 @@ import LanguageSwitcher from '@/components/LanguageSwitcher'
import Link from 'next/link'
import {
Users,
User,
TrendingUp,
Package,
CheckSquare,
@@ -16,12 +20,23 @@ import {
Building2,
Settings,
Bell,
Shield
Shield,
FileText
} from 'lucide-react'
import { dashboardAPI } from '@/lib/api'
function DashboardContent() {
const { user, logout, hasPermission } = useAuth()
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 = [
{
@@ -44,6 +59,16 @@ function DashboardContent() {
description: 'الفرص التجارية والعروض والصفقات',
permission: 'crm'
},
{
id: 'tenders',
name: 'إدارة المناقصات',
nameEn: 'Tender Management',
icon: FileText,
color: 'bg-indigo-500',
href: '/tenders',
description: 'تسجيل ومتابعة المناقصات وتحويلها إلى فرص',
permission: 'tenders'
},
{
id: 'inventory',
name: 'المخزون والأصول',
@@ -74,6 +99,16 @@ function DashboardContent() {
description: 'الموظفين والإجازات والرواتب',
permission: 'hr'
},
{
id: 'portal',
name: 'البوابة الذاتية',
nameEn: 'My Portal',
icon: User,
color: 'bg-cyan-500',
href: '/portal',
description: 'قروضي، إجازاتي، طلبات الشراء والرواتب',
permission: 'portal'
},
{
id: 'marketing',
name: 'التسويق',
@@ -108,11 +143,16 @@ function DashboardContent() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="bg-primary-600 p-2 rounded-lg">
<Building2 className="h-8 w-8 text-white" />
</div>
<Image
src={logoImage}
alt="Company Logo"
width={48}
height={48}
className="object-contain"
priority
/>
<div>
<h1 className="text-2xl font-bold text-gray-900">Z.CRM</h1>
<h1 className="text-2xl font-bold text-gray-900">ATMATA</h1>
<p className="text-sm text-gray-600">نظام إدارة علاقات العملاء</p>
</div>
</div>
@@ -128,7 +168,7 @@ function DashboardContent() {
</div>
{/* Admin Panel Link - Only for admins */}
{(hasPermission('admin', 'view') || user?.role?.name === 'المدير العام' || user?.role?.nameEn === 'General Manager') && (
{hasPermission('admin', 'view') && (
<Link
href="/admin"
className="p-2 hover:bg-red-50 rounded-lg transition-colors relative group"
@@ -144,7 +184,9 @@ function DashboardContent() {
{/* Notifications */}
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors relative">
<Bell className="h-5 w-5 text-gray-600" />
{stats.notifications > 0 && (
<span className="absolute top-1 right-1 h-2 w-2 bg-red-500 rounded-full"></span>
)}
</button>
{/* Settings */}
@@ -193,7 +235,7 @@ function DashboardContent() {
<div className="flex items-center justify-between">
<div>
<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 className="bg-green-100 p-3 rounded-lg">
<CheckSquare className="h-8 w-8 text-green-600" />
@@ -205,7 +247,7 @@ function DashboardContent() {
<div className="flex items-center justify-between">
<div>
<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 className="bg-orange-100 p-3 rounded-lg">
<Bell className="h-8 w-8 text-orange-600" />
@@ -217,7 +259,7 @@ function DashboardContent() {
<div className="flex items-center justify-between">
<div>
<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 className="bg-purple-100 p-3 rounded-lg">
<Users className="h-8 w-8 text-purple-600" />
@@ -268,37 +310,7 @@ function DashboardContent() {
{/* Recent Activity */}
<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>
<div className="space-y-4">
<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>
<p className="text-gray-500 text-center py-6">لا يوجد نشاط حديث</p>
</div>
</main>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect width="128" height="128" rx="24" 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>
</svg>

Before

Width:  |  Height:  |  Size: 270 B

View File

@@ -19,8 +19,11 @@ const readexPro = Readex_Pro({
})
export const metadata: Metadata = {
title: 'Z.CRM - نظام إدارة علاقات العملاء',
title: 'ATMATA - نظام إدارة علاقات العملاء',
description: 'Enterprise CRM System for Contact Management, Sales, HR, Inventory, Projects, and Marketing',
icons: {
icon: '/favicon.ico',
},
}
export default function RootLayout({

View File

@@ -7,7 +7,7 @@ import { LogIn, Mail, Lock, Building2, AlertCircle } from 'lucide-react'
export default function LoginPage() {
const { login } = useAuth()
const [email, setEmail] = useState('')
const [emailOrUsername, setEmailOrUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
@@ -18,7 +18,7 @@ export default function LoginPage() {
setIsLoading(true)
try {
await login(email, password)
await login(emailOrUsername, password)
} catch (err: any) {
setError(err.message || 'فشل تسجيل الدخول. الرجاء المحاولة مرة أخرى.')
} finally {
@@ -37,7 +37,7 @@ export default function LoginPage() {
</div>
</Link>
<h1 className="text-3xl font-bold text-gray-900 mb-2">تسجيل الدخول</h1>
<p className="text-gray-600">Z.CRM - نظام إدارة علاقات العملاء</p>
<p className="text-gray-600">ATMATA - نظام إدارة علاقات العملاء</p>
</div>
{/* Login Form */}
@@ -50,20 +50,21 @@ export default function LoginPage() {
)}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Email Field */}
{/* Email or Username Field */}
<div>
<label htmlFor="email" className="block text-sm font-semibold text-gray-700 mb-2">
البريد الإلكتروني
<label htmlFor="emailOrUsername" className="block text-sm font-semibold text-gray-700 mb-2">
البريد الإلكتروني أو اسم المستخدم
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
id="emailOrUsername"
type="text"
value={emailOrUsername}
onChange={(e) => setEmailOrUsername(e.target.value)}
required
placeholder="example@atmata.com"
placeholder="admin أو example@atmata.com"
autoComplete="username"
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all"
disabled={isLoading}
/>
@@ -110,15 +111,14 @@ export default function LoginPage() {
</button>
</form>
{/* Demo Credentials */}
{/* System Administrator
<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>
<div className="text-sm text-blue-800 space-y-1">
<p> <strong>المدير العام:</strong> gm@atmata.com / Admin@123</p>
<p> <strong>مدير المبيعات:</strong> sales.manager@atmata.com / Admin@123</p>
<p> <strong>مندوب مبيعات:</strong> sales.rep@atmata.com / Admin@123</p>
<h3 className="text-sm font-semibold text-blue-900 mb-2">مدير النظام:</h3>
<div className="text-sm text-blue-800">
<p><strong>admin@system.local</strong> / Admin@123</p>
</div>
</div>
*/}
</div>
{/* Back to Home */}

View File

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

View File

@@ -79,7 +79,7 @@ export default function Home() {
<Building2 className="h-8 w-8 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Z.CRM</h1>
<h1 className="text-2xl font-bold text-gray-900">ATMATA</h1>
<p className="text-sm text-gray-600">نظام إدارة علاقات العملاء</p>
</div>
</div>
@@ -144,7 +144,7 @@ export default function Home() {
جاهز لتحويل إدارة أعمالك؟
</h3>
<p className="text-xl mb-8 text-primary-100">
ابدأ باستخدام Z.CRM اليوم وشاهد الفرق
ابدأ باستخدام Atmata System اليوم وشاهد الفرق
</p>
<Link
href="/login"
@@ -162,10 +162,10 @@ export default function Home() {
<div className="text-center">
<div className="flex items-center justify-center gap-2 mb-3">
<Building2 className="h-6 w-6 text-primary-600" />
<span className="text-lg font-bold text-gray-900">Z.CRM</span>
<span className="text-lg font-bold text-gray-900">Atmata System</span>
</div>
<p className="text-gray-600">
© 2024 Z.CRM. جميع الحقوق محفوظة.
© 2026 ATMATA جميع الحقوق محفوظة.
</p>
</div>
</div>

View File

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

View File

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

View File

@@ -0,0 +1,292 @@
'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 { Plus } from 'lucide-react'
const LEAVE_TYPES = [
{ value: 'ANNUAL', label: 'إجازة سنوية' },
{ value: 'HOURLY', 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: '',
leaveDate: '',
startTime: '',
endTime: '',
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()
let payload: any = {
leaveType: form.leaveType,
reason: form.reason || undefined,
}
if (form.leaveType === 'ANNUAL') {
if (!form.startDate || !form.endDate) {
toast.error('أدخل تاريخ البداية والنهاية')
return
}
if (new Date(form.endDate) < new Date(form.startDate)) {
toast.error('تاريخ النهاية يجب أن يكون بعد البداية')
return
}
payload.startDate = form.startDate
payload.endDate = form.endDate
} else {
if (!form.leaveDate || !form.startTime || !form.endTime) {
toast.error('أدخل التاريخ والوقت للإجازة الساعية')
return
}
if (form.startTime >= form.endTime) {
toast.error('وقت النهاية يجب أن يكون بعد البداية')
return
}
payload.startDate = `${form.leaveDate}T${form.startTime}:00`
payload.endDate = `${form.leaveDate}T${form.endTime}:00`
}
setSubmitting(true)
portalAPI.submitLeaveRequest(payload)
.then(() => {
setShowModal(false)
setForm({
leaveType: 'ANNUAL',
startDate: '',
endDate: '',
leaveDate: '',
startTime: '',
endTime: '',
reason: '',
})
toast.success('تم إرسال طلب الإجازة')
load()
})
.catch(() => toast.error('فشل إرسال الطلب'))
.finally(() => setSubmitting(false))
}
if (loading) return <LoadingSpinner />
return (
<div className="space-y-6">
{/* HEADER */}
<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}
</p>
<p className="text-2xl font-bold text-teal-600 mt-1">{b.available} يوم</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">
<div>
<p className="font-medium">
{l.leaveType === 'ANNUAL' ? 'سنوية' : 'ساعية'} -{' '}
{l.leaveType === 'HOURLY'
? `${new Date(l.startDate).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${new Date(l.endDate).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
: `${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>
</div>
<span className={`px-3 py-1 rounded-full text-xs ${statusInfo.color}`}>
{statusInfo.label}
</span>
</div>
)
})}
</div>
)}
</div>
{/* الفورم */}
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب إجازة جديد">
<form onSubmit={handleSubmit} className="space-y-4">
{/* نوع الإجازة */}
<select
value={form.leaveType}
onChange={(e) =>
setForm({
leaveType: e.target.value,
startDate: '',
endDate: '',
leaveDate: '',
startTime: '',
endTime: '',
reason: '',
})
}
className="w-full px-3 py-2 border rounded-lg"
>
{LEAVE_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
{/* سنوية */}
{form.leaveType === 'ANNUAL' ? (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm">من تاريخ</label>
<input
type="date"
value={form.startDate}
onChange={(e) => setForm(p => ({ ...p, startDate: e.target.value }))}
className="border p-2 rounded w-full"
/>
</div>
<div>
<label className="text-sm">إلى تاريخ</label>
<input
type="date"
value={form.endDate}
onChange={(e) => setForm(p => ({ ...p, endDate: e.target.value }))}
className="border p-2 rounded w-full"
/>
</div>
</div>
) : (
/* ساعية */
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-sm">التاريخ</label>
<input
type="date"
value={form.leaveDate}
onChange={(e) => setForm(p => ({ ...p, leaveDate: e.target.value }))}
className="border p-2 rounded w-full"
/>
</div>
<div>
<label className="text-sm">من الساعة</label>
<input
type="time"
value={form.startTime}
onChange={(e) => setForm(p => ({ ...p, startTime: e.target.value }))}
className="border p-2 rounded w-full"
/>
</div>
<div>
<label className="text-sm">إلى الساعة</label>
<input
type="time"
value={form.endTime}
onChange={(e) => setForm(p => ({ ...p, endTime: e.target.value }))}
className="border p-2 rounded w-full"
/>
</div>
</div>
)}
{/* السبب */}
<textarea
placeholder="اكتب سبب الإجازة..."
value={form.reason}
onChange={(e) => setForm(p => ({ ...p, reason: e.target.value }))}
className="w-full border p-2 rounded"
/>
{/* أزرار */}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setShowModal(false)}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg"
>
إلغاء
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
>
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
</button>
</div>
</form>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,187 @@
'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_HR: { label: 'بانتظار موافقة الموارد البشرية', color: 'bg-amber-100 text-amber-800' },
PENDING_ADMIN: { label: 'بانتظار موافقة مدير النظام', color: 'bg-orange-100 text-orange-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
}
if (!form.reason.trim()) {
toast.error('سبب القرض مطلوب')
return
}
setSubmitting(true)
portalAPI.submitLoanRequest({
type: form.type,
amount,
installments: parseInt(form.installments) || 1,
reason: form.reason.trim(),
})
.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}
required
/>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setShowModal(false)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">
إلغاء
</button>
<button type="submit" disabled={submitting} className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50">
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
</button>
</div>
</form>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,191 @@
'use client'
import { useAuth } from '@/contexts/AuthContext'
import { useEffect, useState } from 'react'
import { portalAPI, type ManagedLeave } from '@/lib/api/portal'
import LoadingSpinner from '@/components/LoadingSpinner'
import { toast } from 'react-hot-toast'
import { CheckCircle2, XCircle, Calendar, User, ArrowLeft } from 'lucide-react'
import Link from 'next/link'
export default function ManagedLeavesPage() {
const { hasPermission } = useAuth()
const [leaves, setLeaves] = useState<ManagedLeave[]>([])
const [loading, setLoading] = useState(true)
const [processingId, setProcessingId] = useState<string | null>(null)
const canViewDepartmentLeaveRequests = hasPermission('department_leave_requests', 'view')
const canApproveDepartmentLeaveRequests = hasPermission('department_leave_requests', 'approve')
const fetchLeaves = async () => {
try {
setLoading(true)
const data = await portalAPI.getManagedLeaves('PENDING')
setLeaves(data)
} catch (error: any) {
toast.error(error?.response?.data?.message || 'فشل تحميل طلبات الإجازات')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (canViewDepartmentLeaveRequests) {
fetchLeaves()
} else {
setLoading(false)
}
}, [canViewDepartmentLeaveRequests])
const handleApprove = async (id: string) => {
try {
setProcessingId(id)
await portalAPI.approveManagedLeave(id)
toast.success('تمت الموافقة على طلب الإجازة')
fetchLeaves()
} catch (error: any) {
toast.error(error?.response?.data?.message || 'فشل اعتماد طلب الإجازة')
} finally {
setProcessingId(null)
}
}
const handleReject = async (id: string) => {
const rejectedReason = window.prompt('اكتب سبب الرفض')
if (!rejectedReason || !rejectedReason.trim()) return
try {
setProcessingId(id)
await portalAPI.rejectManagedLeave(id, rejectedReason.trim())
toast.success('تم رفض طلب الإجازة')
fetchLeaves()
} catch (error: any) {
toast.error(error?.response?.data?.message || 'فشل رفض طلب الإجازة')
} finally {
setProcessingId(null)
}
}
const formatLeaveType = (leaveType: string) => {
if (leaveType === 'ANNUAL') return 'إجازة سنوية'
if (leaveType === 'HOURLY') return 'إجازة ساعية'
return leaveType
}
if (!canViewDepartmentLeaveRequests) {
return <div className="text-center text-gray-500 py-12">الوصول مرفوض</div>
}
if (loading) return <LoadingSpinner />
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">طلبات إجازات القسم</h1>
<p className="text-gray-600 mt-1">اعتماد أو رفض طلبات موظفي القسم المباشرين</p>
</div>
<Link
href="/portal"
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
<ArrowLeft className="h-4 w-4" />
العودة
</Link>
</div>
<div className="bg-white rounded-xl shadow border border-gray-100 overflow-hidden">
{leaves.length === 0 ? (
<div className="p-12 text-center text-gray-500">
لا توجد طلبات إجازات معلقة لموظفي القسم
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-4 text-right font-semibold text-gray-700">الموظف</th>
<th className="px-6 py-4 text-right font-semibold text-gray-700">نوع الإجازة</th>
<th className="px-6 py-4 text-right font-semibold text-gray-700">الفترة</th>
<th className="px-6 py-4 text-right font-semibold text-gray-700">المدة</th>
<th className="px-6 py-4 text-right font-semibold text-gray-700">السبب</th>
<th className="px-6 py-4 text-right font-semibold text-gray-700">الإجراءات</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{leaves.map((leave) => (
<tr key={leave.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-gray-400" />
<div>
<p className="font-medium text-gray-900">
{leave.employee.firstName} {leave.employee.lastName}
</p>
<p className="text-xs text-gray-500">{leave.employee.uniqueEmployeeId}</p>
</div>
</div>
</td>
<td className="px-6 py-4 text-gray-900">
{formatLeaveType(leave.leaveType)}
</td>
<td className="px-6 py-4 text-gray-600">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-gray-400" />
<div>
<p>{new Date(leave.startDate).toLocaleString()}</p>
<p>{new Date(leave.endDate).toLocaleString()}</p>
</div>
</div>
</td>
<td className="px-6 py-4 text-gray-900">
{leave.leaveType === 'HOURLY'
? `${new Date(leave.startDate).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${new Date(leave.endDate).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
: `${leave.days} يوم`}
</td>
<td className="px-6 py-4 text-gray-600 max-w-xs">
<p className="truncate" title={leave.reason || ''}>
{leave.reason || '-'}
</p>
</td>
<td className="px-6 py-4">
{canApproveDepartmentLeaveRequests ? (
<div className="flex items-center gap-2">
<button
onClick={() => handleApprove(leave.id)}
disabled={processingId === leave.id}
className="inline-flex items-center gap-2 px-3 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50"
>
<CheckCircle2 className="h-4 w-4" />
قبول
</button>
<button
onClick={() => handleReject(leave.id)}
disabled={processingId === leave.id}
className="inline-flex items-center gap-2 px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
>
<XCircle className="h-4 w-4" />
رفض
</button>
</div>
) : (
<span className="text-sm text-gray-500">عرض فقط</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,134 @@
'use client'
import { useAuth } from '@/contexts/AuthContext'
import { useEffect, useState } from 'react'
import { portalAPI, type PortalOvertimeRequest } from '@/lib/api/portal'
import LoadingSpinner from '@/components/LoadingSpinner'
import { toast } from 'react-hot-toast'
import { CheckCircle2, XCircle, User } from 'lucide-react'
export default function ManagedOvertimeRequestsPage() {
const { hasPermission } = useAuth()
const [items, setItems] = useState<PortalOvertimeRequest[]>([])
const [loading, setLoading] = useState(true)
const [processingId, setProcessingId] = useState<string | null>(null)
const canView = hasPermission('department_overtime_requests', 'view')
const canApprove = hasPermission('department_overtime_requests', 'approve')
const loadData = async () => {
try {
setLoading(true)
const data = await portalAPI.getManagedOvertimeRequests()
setItems(data)
} catch (error: any) {
toast.error(error?.response?.data?.message || 'فشل تحميل الطلبات')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (canView) {
loadData()
} else {
setLoading(false)
}
}, [canView])
const handleApprove = async (attendanceId: string) => {
try {
setProcessingId(attendanceId)
await portalAPI.approveManagedOvertimeRequest(attendanceId)
toast.success('تمت الموافقة')
loadData()
} catch (error: any) {
toast.error(error?.response?.data?.message || 'فشل التنفيذ')
} finally {
setProcessingId(null)
}
}
const handleReject = async (attendanceId: string) => {
const rejectedReason = window.prompt('اكتب سبب الرفض')
if (!rejectedReason || !rejectedReason.trim()) return
try {
setProcessingId(attendanceId)
await portalAPI.rejectManagedOvertimeRequest(attendanceId, rejectedReason.trim())
toast.success('تم الرفض')
loadData()
} catch (error: any) {
toast.error(error?.response?.data?.message || 'فشل التنفيذ')
} finally {
setProcessingId(null)
}
}
if (!canView) {
return <div className="text-center py-12 text-gray-500">الوصول مرفوض</div>
}
if (loading) return <LoadingSpinner />
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">طلبات الساعات الإضافية</h1>
<p className="text-gray-600 mt-1">طلبات موظفي القسم المباشرين</p>
</div>
<div className="bg-white rounded-xl shadow border border-gray-100 overflow-hidden">
{items.length === 0 ? (
<div className="p-10 text-center text-gray-500">لا توجد طلبات معلقة</div>
) : (
<div className="divide-y divide-gray-200">
{items.map((item) => (
<div key={item.id} className="p-6 flex items-start justify-between gap-6">
<div className="space-y-2">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-gray-400" />
<p className="font-semibold text-gray-900">
{item.employee?.firstName} {item.employee?.lastName}
</p>
<span className="text-xs text-gray-500">{item.employee?.uniqueEmployeeId}</span>
</div>
<p className="text-sm text-gray-600">
التاريخ: {new Date(item.date).toLocaleDateString()}
</p>
<p className="text-sm text-gray-600">عدد الساعات: {item.hours}</p>
<p className="text-sm text-gray-600">السبب: {item.reason}</p>
</div>
{canApprove ? (
<div className="flex gap-2">
<button
onClick={() => handleApprove(item.attendanceId)}
disabled={processingId === item.attendanceId}
className="inline-flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg disabled:opacity-50"
>
<CheckCircle2 className="h-4 w-4" />
قبول
</button>
<button
onClick={() => handleReject(item.attendanceId)}
disabled={processingId === item.attendanceId}
className="inline-flex items-center gap-2 px-3 py-2 bg-red-600 text-white rounded-lg disabled:opacity-50"
>
<XCircle className="h-4 w-4" />
رفض
</button>
</div>
) : (
<span className="text-sm text-gray-500">عرض فقط</span>
)}
</div>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,191 @@
'use client'
import { useEffect, useState } from 'react'
import { portalAPI, type PortalOvertimeRequest } from '@/lib/api/portal'
import Modal from '@/components/Modal'
import LoadingSpinner from '@/components/LoadingSpinner'
import { toast } from 'react-hot-toast'
import { Plus, Clock3 } 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' },
}
export default function PortalOvertimePage() {
const [items, setItems] = useState<PortalOvertimeRequest[]>([])
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [open, setOpen] = useState(false)
const [form, setForm] = useState({
date: '',
hours: '',
reason: '',
})
const loadData = async () => {
try {
setLoading(true)
const data = await portalAPI.getOvertimeRequests()
setItems(data)
} catch (error: any) {
toast.error(error?.response?.data?.message || 'فشل تحميل الطلبات')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadData()
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const hours = parseFloat(form.hours)
if (!form.date) {
toast.error('اختر التاريخ')
return
}
if (!hours || hours <= 0) {
toast.error('أدخل عدد ساعات صالح')
return
}
if (!form.reason.trim()) {
toast.error('سبب الساعات الإضافية مطلوب')
return
}
try {
setSubmitting(true)
await portalAPI.submitOvertimeRequest({
date: form.date,
hours,
reason: form.reason.trim(),
})
toast.success('تم إرسال الطلب')
setOpen(false)
setForm({ date: '', hours: '', reason: '' })
loadData()
} catch (error: any) {
toast.error(error?.response?.data?.message || 'فشل إرسال الطلب')
} finally {
setSubmitting(false)
}
}
if (loading) return <LoadingSpinner />
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">الساعات الإضافية</h1>
<p className="text-gray-600 mt-1">إرسال ومتابعة طلبات الساعات الإضافية</p>
</div>
<button
onClick={() => setOpen(true)}
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" />
إضافة طلب
</button>
</div>
<div className="bg-white rounded-xl shadow border border-gray-100 overflow-hidden">
{items.length === 0 ? (
<div className="p-10 text-center text-gray-500">لا توجد طلبات حتى الآن</div>
) : (
<div className="divide-y divide-gray-200">
{items.map((item) => {
const meta = STATUS_MAP[item.status] || STATUS_MAP.PENDING
return (
<div key={item.id} className="p-6 flex items-start justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Clock3 className="h-4 w-4 text-gray-500" />
<p className="font-semibold text-gray-900">
{new Date(item.date).toLocaleDateString()}
</p>
</div>
<p className="text-sm text-gray-600">عدد الساعات: {item.hours}</p>
<p className="text-sm text-gray-600">السبب: {item.reason}</p>
{item.rejectedReason ? (
<p className="text-sm text-red-600">سبب الرفض: {item.rejectedReason}</p>
) : null}
</div>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${meta.color}`}>
{meta.label}
</span>
</div>
)
})}
</div>
)}
</div>
<Modal isOpen={open} onClose={() => setOpen(false)} title="طلب ساعات إضافية">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block mb-1 text-sm font-medium text-gray-700">التاريخ</label>
<input
type="date"
value={form.date}
onChange={(e) => setForm((p) => ({ ...p, date: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
required
/>
</div>
<div>
<label className="block mb-1 text-sm font-medium text-gray-700">عدد الساعات</label>
<input
type="number"
min="0.5"
step="0.5"
value={form.hours}
onChange={(e) => setForm((p) => ({ ...p, hours: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
required
/>
</div>
<div>
<label className="block mb-1 text-sm font-medium text-gray-700">السبب</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={4}
required
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={() => setOpen(false)}
className="px-4 py-2 border border-gray-300 rounded-lg"
>
إلغاء
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
>
{submitting ? 'جارٍ الإرسال...' : 'إرسال'}
</button>
</div>
</form>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,181 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { useAuth } from '@/contexts/AuthContext'
import { portalAPI, type PortalProfile } from '@/lib/api/portal'
import LoadingSpinner from '@/components/LoadingSpinner'
import { Banknote, Calendar, ShoppingCart, Clock, Plus, ArrowLeft, CheckCircle2 } from 'lucide-react'
import { toast } from 'react-hot-toast'
export default function PortalDashboardPage() {
const { hasPermission } = useAuth()
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 canViewDepartmentLeaveRequests = hasPermission('department_leave_requests', 'view')
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 ${canViewDepartmentLeaveRequests ? 'lg:grid-cols-5' : '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>
{canViewDepartmentLeaveRequests && (
<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-emerald-100 p-3 rounded-lg">
<CheckCircle2 className="h-6 w-6 text-emerald-600" />
</div>
</div>
<Link href="/portal/managed-leaves" className="mt-4 text-sm text-emerald-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 === 'HOURLY' ? 'ساعية' : 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>
{canViewDepartmentLeaveRequests && (
<Link href="/portal/managed-leaves" className="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700">
<CheckCircle2 className="h-4 w-4" />
طلبات إجازات القسم
</Link>
)}
<Link href="/portal/purchase-requests" className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
<Plus className="h-4 w-4" />
طلب شراء
</Link>
</div>
</div>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,723 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { toast } from 'react-hot-toast'
import {
ArrowLeft,
FileText,
Calendar,
Building2,
DollarSign,
History,
Plus,
Loader2,
CheckCircle2,
Upload,
ExternalLink,
MapPin,
} from 'lucide-react'
import ProtectedRoute from '@/components/ProtectedRoute'
import LoadingSpinner from '@/components/LoadingSpinner'
import Modal from '@/components/Modal'
import { tendersAPI, Tender, TenderDirective, CreateDirectiveData } from '@/lib/api/tenders'
import { contactsAPI } from '@/lib/api/contacts'
import { pipelinesAPI } from '@/lib/api/pipelines'
import { employeesAPI } from '@/lib/api/employees'
import { useLanguage } from '@/contexts/LanguageContext'
const DIRECTIVE_TYPE_LABELS: Record<string, string> = {
BUY_TERMS: 'Buy terms booklet',
VISIT_CLIENT: 'Visit client',
MEET_COMMITTEE: 'Meet committee',
PREPARE_TO_BID: 'Prepare to bid',
}
function TenderDetailContent() {
const params = useParams()
const router = useRouter()
const tenderId = params.id as string
const { t } = useLanguage()
const [tender, setTender] = useState<Tender | null>(null)
const [history, setHistory] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<'info' | 'directives' | 'attachments' | 'history'>('info')
const [showDirectiveModal, setShowDirectiveModal] = useState(false)
const [showConvertModal, setShowConvertModal] = useState(false)
const [showCompleteModal, setShowCompleteModal] = useState<TenderDirective | null>(null)
const [employees, setEmployees] = useState<any[]>([])
const [contacts, setContacts] = useState<any[]>([])
const [pipelines, setPipelines] = useState<any[]>([])
const [directiveForm, setDirectiveForm] = useState<CreateDirectiveData>({
type: 'BUY_TERMS',
assignedToEmployeeId: '',
notes: '',
})
const [convertForm, setConvertForm] = useState({
contactId: '',
pipelineId: '',
ownerId: '',
})
const [completeNotes, setCompleteNotes] = useState('')
const [directiveTypeValues, setDirectiveTypeValues] = useState<string[]>([])
const [submitting, setSubmitting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const directiveFileInputRef = useRef<HTMLInputElement>(null)
const [uploadingDirectiveId, setUploadingDirectiveId] = useState<string | null>(null)
const [directiveIdForUpload, setDirectiveIdForUpload] = useState<string | null>(null)
const fetchTender = async () => {
try {
const data = await tendersAPI.getById(tenderId)
setTender(data)
} catch {
toast.error(t('tenders.loadError'))
} finally {
setLoading(false)
}
}
const fetchHistory = async () => {
try {
const data = await tendersAPI.getHistory(tenderId)
setHistory(data)
} catch {}
}
useEffect(() => {
fetchTender()
}, [tenderId])
useEffect(() => {
if (tender) fetchHistory()
}, [tender?.id])
useEffect(() => {
tendersAPI.getDirectiveTypeValues().then(setDirectiveTypeValues).catch(() => {})
}, [])
useEffect(() => {
if (showDirectiveModal || showConvertModal) {
employeesAPI
.getAll({ status: 'ACTIVE', pageSize: 500 })
.then((r: any) => setEmployees(r.employees || []))
.catch(() => {})
}
if (showConvertModal) {
contactsAPI
.getAll({ pageSize: 500 })
.then((r: any) => setContacts(r.contacts || []))
.catch(() => {})
pipelinesAPI.getAll().then(setPipelines).catch(() => {})
}
}, [showDirectiveModal, showConvertModal])
const handleAddDirective = async (e: React.FormEvent) => {
e.preventDefault()
if (!directiveForm.assignedToEmployeeId) {
toast.error(t('tenders.assignee') + ' ' + t('common.required'))
return
}
setSubmitting(true)
try {
await tendersAPI.createDirective(tenderId, directiveForm)
toast.success('Directive created')
setShowDirectiveModal(false)
setDirectiveForm({ type: 'BUY_TERMS', assignedToEmployeeId: '', notes: '' })
fetchTender()
} catch (err: any) {
toast.error(err.response?.data?.message || 'Failed')
} finally {
setSubmitting(false)
}
}
const handleCompleteDirective = async (e: React.FormEvent) => {
e.preventDefault()
if (!showCompleteModal) return
setSubmitting(true)
try {
await tendersAPI.updateDirective(showCompleteModal.id, {
status: 'COMPLETED',
completionNotes: completeNotes,
})
toast.success('Task completed')
setShowCompleteModal(null)
setCompleteNotes('')
fetchTender()
} catch (err: any) {
toast.error(err.response?.data?.message || 'Failed')
} finally {
setSubmitting(false)
}
}
const handleConvertToDeal = async (e: React.FormEvent) => {
e.preventDefault()
if (!convertForm.contactId || !convertForm.pipelineId) {
toast.error('Contact and Pipeline are required')
return
}
setSubmitting(true)
try {
const deal = await tendersAPI.convertToDeal(tenderId, convertForm)
toast.success('Tender converted to deal')
setShowConvertModal(false)
router.push(`/crm/deals/${deal.id}`)
} catch (err: any) {
toast.error(err.response?.data?.message || 'Failed')
} finally {
setSubmitting(false)
}
}
const handleTenderFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setSubmitting(true)
try {
await tendersAPI.uploadTenderAttachment(tenderId, file)
toast.success(t('tenders.uploadFile'))
fetchTender()
} catch (err: any) {
toast.error(err.response?.data?.message || 'Upload failed')
} finally {
setSubmitting(false)
e.target.value = ''
}
}
const handleDirectiveFileSelect = (directiveId: string) => {
setDirectiveIdForUpload(directiveId)
setTimeout(() => directiveFileInputRef.current?.click(), 0)
}
const handleDirectiveFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
const directiveId = directiveIdForUpload
e.target.value = ''
setDirectiveIdForUpload(null)
if (!file || !directiveId) return
setUploadingDirectiveId(directiveId)
try {
await tendersAPI.uploadDirectiveAttachment(directiveId, file)
toast.success(t('tenders.uploadFile'))
fetchTender()
} catch (err: any) {
toast.error(err.response?.data?.message || 'Upload failed')
} finally {
setUploadingDirectiveId(null)
}
}
if (loading || !tender) {
return (
<div className="min-h-screen flex items-center justify-center">
<LoadingSpinner />
</div>
)
}
const tabs = [
{ id: 'info', label: t('tenders.titleLabel') || 'Info', icon: FileText },
{ id: 'directives', label: t('tenders.directives'), icon: CheckCircle2 },
{ id: 'attachments', label: t('tenders.attachments'), icon: Upload },
{ id: 'history', label: t('tenders.history'), icon: History },
]
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/tenders" className="p-2 hover:bg-gray-200 rounded-lg">
<ArrowLeft className="h-5 w-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-gray-900">
{tender.tenderNumber} {tender.title}
</h1>
<p className="text-sm text-gray-600">{tender.issuingBodyName}</p>
</div>
</div>
{tender.status === 'ACTIVE' && (
<button
onClick={() => setShowConvertModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
>
<ExternalLink className="h-4 w-4" />
{t('tenders.convertToDeal')}
</button>
)}
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div className="border-b border-gray-200 flex gap-1 p-1">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium ${
activeTab === tab.id
? 'bg-indigo-100 text-indigo-800'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<tab.icon className="h-4 w-4" />
{tab.label}
</button>
))}
</div>
<div className="p-6">
{activeTab === 'info' && (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-start gap-2">
<Calendar className="h-5 w-5 text-gray-400 mt-0.5" />
<div>
<p className="text-xs text-gray-500">{t('tenders.announcementDate')}</p>
<p>{tender.announcementDate?.split('T')[0]}</p>
</div>
</div>
<div className="flex items-start gap-2">
<Calendar className="h-5 w-5 text-gray-400 mt-0.5" />
<div>
<p className="text-xs text-gray-500">{t('tenders.closingDate')}</p>
<p>{tender.closingDate?.split('T')[0]}</p>
</div>
</div>
<div className="flex items-start gap-2">
<DollarSign className="h-5 w-5 text-gray-400 mt-0.5" />
<div>
<p className="text-xs text-gray-500">{t('tenders.termsValue')}</p>
<p>{Number(tender.termsValue)} SAR</p>
</div>
</div>
<div className="flex items-start gap-2">
<DollarSign className="h-5 w-5 text-gray-400 mt-0.5" />
<div>
<p className="text-xs text-gray-500">التأمينات الأولية</p>
<p>{Number(tender.initialBondValue || tender.bondValue || 0)} SAR</p>
</div>
</div>
<div className="flex items-start gap-2">
<DollarSign className="h-5 w-5 text-gray-400 mt-0.5" />
<div>
<p className="text-xs text-gray-500">التأمينات النهائية</p>
<p>{Number(tender.finalBondValue || 0)} SAR</p>
</div>
</div>
<div className="flex items-start gap-2">
<Calendar className="h-5 w-5 text-gray-400 mt-0.5" />
<div>
<p className="text-xs text-gray-500">زمن الاسترجاع</p>
<p>{tender.finalBondRefundPeriod || '-'}</p>
</div>
</div>
<div className="flex items-start gap-2">
<Calendar className="h-5 w-5 text-gray-400 mt-0.5" />
<div>
<p className="text-xs text-gray-500">زيارة الموقع</p>
<p>{tender.siteVisitRequired ? 'إجبارية' : 'غير إجبارية'}</p>
</div>
</div>
{tender.siteVisitRequired && (
<div className="flex items-start gap-2">
<MapPin className="h-5 w-5 text-gray-400 mt-0.5" />
<div>
<p className="text-xs text-gray-500">مكان الزيارة</p>
<p>{tender.siteVisitLocation || '-'}</p>
</div>
</div>
)}
<div className="flex items-start gap-2">
<MapPin className="h-5 w-5 text-gray-400 mt-0.5" />
<div>
<p className="text-xs text-gray-500">مكان استلام دفتر الشروط</p>
<p>{tender.termsPickupProvince || '-'}</p>
</div>
</div>
</div>
{tender.announcementLink && (
<p>
<a
href={tender.announcementLink}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 hover:underline"
>
{t('tenders.announcementLink')}
</a>
</p>
)}
{tender.notes && (
<div>
<p className="text-xs text-gray-500">{t('common.notes')}</p>
<p className="whitespace-pre-wrap">{tender.notes}</p>
</div>
)}
</div>
)}
{activeTab === 'directives' && (
<div>
<div className="flex justify-between items-center mb-4">
<h3 className="font-medium">{t('tenders.directives')}</h3>
<button
onClick={() => setShowDirectiveModal(true)}
className="flex items-center gap-1 text-indigo-600 hover:underline"
>
<Plus className="h-4 w-4" />
{t('tenders.addDirective')}
</button>
</div>
{!tender.directives?.length ? (
<p className="text-gray-500">{t('common.noData')}</p>
) : (
<ul className="space-y-3">
{tender.directives.map((d) => (
<li
key={d.id}
className="border rounded-lg p-4 flex flex-wrap items-center justify-between gap-2"
>
<div>
<p className="font-medium">{DIRECTIVE_TYPE_LABELS[d.type] || d.type}</p>
<p className="text-sm text-gray-600">
{d.assignedToEmployee
? `${d.assignedToEmployee.firstName} ${d.assignedToEmployee.lastName}`
: ''}{' '}
· {d.status}
</p>
{d.completionNotes && (
<p className="text-sm mt-1">{d.completionNotes}</p>
)}
</div>
<div className="flex items-center gap-2">
{d.status !== 'COMPLETED' && d.assignedToEmployee?.user?.id && (
<button
onClick={() => setShowCompleteModal(d)}
className="text-sm text-green-600 hover:underline"
>
{t('tenders.completeTask')}
</button>
)}
<input
type="file"
ref={directiveFileInputRef}
className="hidden"
onChange={handleDirectiveFileUpload}
/>
<button
type="button"
onClick={() => handleDirectiveFileSelect(d.id)}
disabled={uploadingDirectiveId === d.id}
className="text-sm text-indigo-600 hover:underline flex items-center gap-1"
>
{uploadingDirectiveId === d.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{t('tenders.uploadFile')}
</button>
</div>
</li>
))}
</ul>
)}
</div>
)}
{activeTab === 'attachments' && (
<div>
<div className="flex items-center gap-4 mb-4">
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleTenderFileUpload}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={submitting}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{t('tenders.uploadFile')}
</button>
</div>
{!tender.attachments?.length ? (
<p className="text-gray-500">{t('common.noData')}</p>
) : (
<ul className="space-y-2">
{tender.attachments.map((a: any) => (
<li
key={a.id}
className="flex items-center justify-between border rounded px-3 py-2"
>
<a
href={`${process.env.NEXT_PUBLIC_API_URL}/tenders/attachments/${a.id}/view`}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-indigo-600 hover:underline flex items-center gap-1"
>
<ExternalLink className="h-4 w-4" />
{a.originalName || a.fileName}
</a>
<button
onClick={async () => {
if (!confirm('حذف الملف؟')) return
try {
await tendersAPI.deleteAttachment(a.id)
toast.success('تم الحذف')
fetchTender()
} catch {
toast.error('فشل الحذف')
}
}}
className="text-red-600 text-sm hover:underline"
>
حذف
</button>
</li>
))}
</ul>
)}
</div>
)}
{activeTab === 'history' && (
<div>
{history.length === 0 ? (
<p className="text-gray-500">{t('common.noData')}</p>
) : (
<ul className="space-y-2">
{history.map((h: any) => (
<li key={h.id} className="text-sm border-b border-gray-100 pb-2">
<span className="font-medium">{h.action}</span> · {h.user?.username} ·{' '}
{h.createdAt?.split('T')[0]}
</li>
))}
</ul>
)}
</div>
)}
</div>
</div>
</div>
<Modal
isOpen={showDirectiveModal}
onClose={() => setShowDirectiveModal(false)}
title={t('tenders.addDirective')}
>
<form onSubmit={handleAddDirective} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('tenders.directiveType')}
</label>
<select
value={directiveForm.type}
onChange={(e) => setDirectiveForm({ ...directiveForm, type: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
>
{directiveTypeValues.map((v) => (
<option key={v} value={v}>
{DIRECTIVE_TYPE_LABELS[v] || v}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('tenders.assignee')} *
</label>
<select
value={directiveForm.assignedToEmployeeId}
onChange={(e) =>
setDirectiveForm({ ...directiveForm, assignedToEmployeeId: e.target.value })
}
className="w-full px-3 py-2 border rounded-lg"
required
>
<option value="">Select employee</option>
{employees.map((emp) => (
<option key={emp.id} value={emp.id}>
{emp.firstName} {emp.lastName}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('common.notes')}
</label>
<textarea
value={directiveForm.notes || ''}
onChange={(e) => setDirectiveForm({ ...directiveForm, notes: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
rows={2}
/>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setShowDirectiveModal(false)}
className="px-4 py-2 border rounded-lg"
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg disabled:opacity-50 flex items-center gap-2"
>
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
{t('common.save')}
</button>
</div>
</form>
</Modal>
<Modal
isOpen={!!showCompleteModal}
onClose={() => setShowCompleteModal(null)}
title={t('tenders.completeTask')}
>
<form onSubmit={handleCompleteDirective} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('tenders.completionNotes')}
</label>
<textarea
value={completeNotes}
onChange={(e) => setCompleteNotes(e.target.value)}
className="w-full px-3 py-2 border rounded-lg"
rows={3}
/>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setShowCompleteModal(null)}
className="px-4 py-2 border rounded-lg"
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 bg-green-600 text-white rounded-lg disabled:opacity-50 flex items-center gap-2"
>
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
{t('common.save')}
</button>
</div>
</form>
</Modal>
<Modal
isOpen={showConvertModal}
onClose={() => setShowConvertModal(false)}
title={t('tenders.convertToDeal')}
>
<form onSubmit={handleConvertToDeal} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Contact *</label>
<select
value={convertForm.contactId}
onChange={(e) => setConvertForm({ ...convertForm, contactId: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
required
>
<option value="">Select contact</option>
{contacts.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Pipeline *</label>
<select
value={convertForm.pipelineId}
onChange={(e) => setConvertForm({ ...convertForm, pipelineId: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
required
>
<option value="">Select pipeline</option>
{pipelines.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setShowConvertModal(false)}
className="px-4 py-2 border rounded-lg"
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 bg-green-600 text-white rounded-lg disabled:opacity-50 flex items-center gap-2"
>
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
{t('tenders.convertToDeal')}
</button>
</div>
</form>
</Modal>
</div>
)
}
export default function TenderDetailPage() {
return (
<ProtectedRoute>
<TenderDetailContent />
</ProtectedRoute>
)
}

View File

@@ -0,0 +1,674 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import ProtectedRoute from '@/components/ProtectedRoute'
import Modal from '@/components/Modal'
import LoadingSpinner from '@/components/LoadingSpinner'
import Link from 'next/link'
import { toast } from 'react-hot-toast'
import {
FileText,
Plus,
Search,
AlertCircle,
ArrowLeft,
Eye,
Loader2,
} from 'lucide-react'
import { tendersAPI, Tender, CreateTenderData, TenderFilters } from '@/lib/api/tenders'
import { useLanguage } from '@/contexts/LanguageContext'
const SOURCE_LABELS: Record<string, string> = {
GOVERNMENT_SITE: 'Government site',
OFFICIAL_GAZETTE: 'Official gazette',
PERSONAL: 'Personal relations',
PARTNER: 'Partner companies',
WHATSAPP_TELEGRAM: 'WhatsApp/Telegram',
PORTAL: 'Tender portals',
EMAIL: 'Email',
MANUAL: 'Manual entry',
}
const SYRIA_PROVINCES = [
'دمشق',
'ريف دمشق',
'حلب',
'حمص',
'حماة',
'اللاذقية',
'طرطوس',
'إدلب',
'درعا',
'السويداء',
'القنيطرة',
'دير الزور',
'الرقة',
'الحسكة',
]
const ANNOUNCEMENT_LABELS: Record<string, string> = {
FIRST: 'First announcement',
RE_ANNOUNCEMENT_2: 'Re-announcement 2nd',
RE_ANNOUNCEMENT_3: 'Re-announcement 3rd',
RE_ANNOUNCEMENT_4: 'Re-announcement 4th',
}
const getInitialFormData = (): CreateTenderData => ({
tenderNumber: '',
issuingBodyName: '',
title: '',
termsValue: 0,
bondValue: 0,
initialBondValue: 0,
finalBondValue: 0,
finalBondRefundPeriod: '',
siteVisitRequired: false,
siteVisitLocation: '',
termsPickupProvince: '',
announcementDate: '',
closingDate: '',
source: 'MANUAL',
announcementType: 'FIRST',
notes: '',
})
function TendersContent() {
const { t } = useLanguage()
const [tenders, setTenders] = useState<Tender[]>([])
const [loading, setLoading] = useState(true)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [total, setTotal] = useState(0)
const pageSize = 10
const [searchTerm, setSearchTerm] = useState('')
const [selectedStatus, setSelectedStatus] = useState('all')
const [showCreateModal, setShowCreateModal] = useState(false)
const [formData, setFormData] = useState<CreateTenderData>(getInitialFormData())
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [possibleDuplicates, setPossibleDuplicates] = useState<Tender[]>([])
const [showDuplicateWarning, setShowDuplicateWarning] = useState(false)
const [sourceValues, setSourceValues] = useState<string[]>([])
const [announcementTypeValues, setAnnouncementTypeValues] = useState<string[]>([])
const resetForm = () => {
setFormData(getInitialFormData())
setFormErrors({})
setPossibleDuplicates([])
setShowDuplicateWarning(false)
}
const fetchTenders = useCallback(async () => {
setLoading(true)
try {
const filters: TenderFilters = { page: currentPage, pageSize }
if (searchTerm) filters.search = searchTerm
if (selectedStatus !== 'all') filters.status = selectedStatus
const data = await tendersAPI.getAll(filters)
setTenders(data.tenders)
setTotal(data.total)
setTotalPages(data.totalPages)
} catch {
toast.error(t('tenders.loadError') || 'Failed to load tenders')
} finally {
setLoading(false)
}
}, [currentPage, searchTerm, selectedStatus, t])
useEffect(() => {
fetchTenders()
}, [fetchTenders])
useEffect(() => {
tendersAPI.getSourceValues().then(setSourceValues).catch(() => {})
tendersAPI.getAnnouncementTypeValues().then(setAnnouncementTypeValues).catch(() => {})
}, [])
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
const errors: Record<string, string> = {}
if (!formData.tenderNumber?.trim()) errors.tenderNumber = t('common.required')
if (!formData.issuingBodyName?.trim()) errors.issuingBodyName = t('common.required')
if (!formData.title?.trim()) errors.title = t('common.required')
if (!formData.announcementDate) errors.announcementDate = t('common.required')
if (!formData.closingDate) errors.closingDate = t('common.required')
if (Number(formData.initialBondValue || 0) < 0) {
errors.initialBondValue = t('common.required')
}
if (formData.siteVisitRequired && !formData.siteVisitLocation?.trim()) {
errors.siteVisitLocation = t('common.required')
}
setFormErrors(errors)
if (Object.keys(errors).length > 0) return
setSubmitting(true)
try {
const result = await tendersAPI.create({
...formData,
bondValue: Number(formData.initialBondValue ?? formData.bondValue ?? 0),
})
if (result.possibleDuplicates && result.possibleDuplicates.length > 0) {
setPossibleDuplicates(result.possibleDuplicates)
setShowDuplicateWarning(true)
toast(t('tenders.duplicateWarning') || 'Possible duplicates found. Please review.', {
icon: '⚠️',
})
} else {
toast.success(t('tenders.createSuccess') || 'Tender created successfully')
setShowCreateModal(false)
resetForm()
fetchTenders()
}
} catch (err: any) {
toast.error(err.response?.data?.message || 'Failed to create tender')
} finally {
setSubmitting(false)
}
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link
href="/dashboard"
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
>
<ArrowLeft className="h-5 w-5" />
</Link>
<div className="flex items-center gap-2">
<FileText className="h-8 w-8 text-indigo-600" />
<div>
<h1 className="text-2xl font-bold text-gray-900">
{t('nav.tenders') || 'Tenders'}
</h1>
<p className="text-sm text-gray-600">
{t('tenders.subtitle') || 'Tender Management'}
</p>
</div>
</div>
</div>
<button
onClick={() => {
resetForm()
setShowCreateModal(true)
}}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
>
<Plus className="h-5 w-5" />
{t('tenders.addTender') || 'Add Tender'}
</button>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div className="p-4 border-b border-gray-200 flex flex-wrap gap-4 items-center">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder={t('tenders.searchPlaceholder') || 'Search by number, title, issuing body...'}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
>
<option value="all">{t('common.all') || 'All status'}</option>
<option value="ACTIVE">Active</option>
<option value="CONVERTED_TO_DEAL">Converted</option>
<option value="CANCELLED">Cancelled</option>
</select>
</div>
{loading ? (
<div className="flex justify-center py-12">
<LoadingSpinner />
</div>
) : tenders.length === 0 ? (
<div className="text-center py-12 text-gray-500">
{t('tenders.noTenders') || 'No tenders found.'}
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
{t('tenders.tenderNumber') || 'Number'}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
{t('tenders.title') || 'Title'}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
{t('tenders.issuingBody') || 'Issuing body'}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
{t('tenders.closingDate') || 'Closing date'}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
{t('common.status')}
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
{t('common.actions')}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{tenders.map((tender) => (
<tr key={tender.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm font-medium text-gray-900">
{tender.tenderNumber}
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{tender.title}
</td>
<td className="px-4 py-3 text-sm text-gray-600">
{tender.issuingBodyName}
</td>
<td className="px-4 py-3 text-sm text-gray-600">
{tender.closingDate?.split('T')[0]}
</td>
<td className="px-4 py-3">
<span
className={`px-2 py-1 text-xs rounded-full ${
tender.status === 'ACTIVE'
? 'bg-green-100 text-green-800'
: tender.status === 'CONVERTED_TO_DEAL'
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{tender.status}
</span>
</td>
<td className="px-4 py-3 text-right">
<Link
href={`/tenders/${tender.id}`}
className="inline-flex items-center gap-1 text-indigo-600 hover:underline"
>
<Eye className="h-4 w-4" />
{t('common.view') || 'View'}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{totalPages > 1 && (
<div className="px-4 py-3 border-t border-gray-200 flex items-center justify-between">
<p className="text-sm text-gray-600">
{t('common.showing') || 'Showing'} {(currentPage - 1) * pageSize + 1}
{Math.min(currentPage * pageSize, total)} {t('common.of') || 'of'} {total}
</p>
<div className="flex gap-2">
<button
disabled={currentPage <= 1}
onClick={() => setCurrentPage((p) => p - 1)}
className="px-3 py-1 border rounded disabled:opacity-50"
>
{t('crm.paginationPrevious') || 'Previous'}
</button>
<button
disabled={currentPage >= totalPages}
onClick={() => setCurrentPage((p) => p + 1)}
className="px-3 py-1 border rounded disabled:opacity-50"
>
{t('crm.paginationNext') || 'Next'}
</button>
</div>
</div>
)}
</div>
</div>
<Modal
isOpen={showCreateModal}
onClose={() => {
setShowCreateModal(false)
resetForm()
}}
title={t('tenders.addTender') || 'Add Tender'}
>
<form onSubmit={handleCreate} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('tenders.tenderNumber')} *
</label>
<input
type="text"
value={formData.tenderNumber}
onChange={(e) => setFormData({ ...formData, tenderNumber: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
{formErrors.tenderNumber && (
<p className="text-red-500 text-xs mt-1">{formErrors.tenderNumber}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('tenders.issuingBody')} *
</label>
<input
type="text"
value={formData.issuingBodyName}
onChange={(e) => setFormData({ ...formData, issuingBodyName: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
{formErrors.issuingBodyName && (
<p className="text-red-500 text-xs mt-1">{formErrors.issuingBodyName}</p>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('tenders.titleLabel')} *
</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
{formErrors.title && (
<p className="text-red-500 text-xs mt-1">{formErrors.title}</p>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
قيمة دفتر الشروط *
</label>
<input
type="number"
min={0}
value={formData.termsValue || ''}
onChange={(e) =>
setFormData({ ...formData, termsValue: Number(e.target.value) || 0 })
}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
قيمة التأمينات الأولية *
</label>
<input
type="number"
min={0}
value={formData.initialBondValue || ''}
onChange={(e) =>
setFormData({
...formData,
initialBondValue: Number(e.target.value) || 0,
bondValue: Number(e.target.value) || 0,
})
}
className="w-full px-3 py-2 border rounded-lg"
/>
{formErrors.initialBondValue && (
<p className="text-red-500 text-xs mt-1">{formErrors.initialBondValue}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
قيمة التأمينات النهائية
</label>
<input
type="number"
min={0}
value={formData.finalBondValue || ''}
onChange={(e) =>
setFormData({ ...formData, finalBondValue: Number(e.target.value) || 0 })
}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
زمن الاسترجاع
</label>
<input
type="text"
value={formData.finalBondRefundPeriod || ''}
onChange={(e) =>
setFormData({ ...formData, finalBondRefundPeriod: e.target.value })
}
placeholder="مثال: بعد 90 يوم من التسليم النهائي"
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('tenders.announcementDate')} *
</label>
<input
type="date"
value={formData.announcementDate}
onChange={(e) => setFormData({ ...formData, announcementDate: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
{formErrors.announcementDate && (
<p className="text-red-500 text-xs mt-1">{formErrors.announcementDate}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('tenders.closingDate')} *
</label>
<input
type="date"
value={formData.closingDate}
onChange={(e) => setFormData({ ...formData, closingDate: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
{formErrors.closingDate && (
<p className="text-red-500 text-xs mt-1">{formErrors.closingDate}</p>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('tenders.source')}
</label>
<select
value={formData.source}
onChange={(e) => setFormData({ ...formData, source: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
>
{sourceValues.map((s) => (
<option key={s} value={s}>
{SOURCE_LABELS[s] || s}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('tenders.announcementType')}
</label>
<select
value={formData.announcementType}
onChange={(e) => setFormData({ ...formData, announcementType: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
>
{announcementTypeValues.map((a) => (
<option key={a} value={a}>
{ANNOUNCEMENT_LABELS[a] || a}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
زيارة الموقع
</label>
<select
value={formData.siteVisitRequired ? 'YES' : 'NO'}
onChange={(e) =>
setFormData({
...formData,
siteVisitRequired: e.target.value === 'YES',
siteVisitLocation: e.target.value === 'YES' ? formData.siteVisitLocation || '' : '',
})
}
className="w-full px-3 py-2 border rounded-lg"
>
<option value="NO">غير إجبارية</option>
<option value="YES">إجبارية</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
مكان استلام دفتر الشروط - المحافظة
</label>
<select
value={formData.termsPickupProvince || ''}
onChange={(e) => setFormData({ ...formData, termsPickupProvince: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
>
<option value="">اختر المحافظة</option>
{SYRIA_PROVINCES.map((province) => (
<option key={province} value={province}>
{province}
</option>
))}
</select>
</div>
</div>
{formData.siteVisitRequired && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
مكان الزيارة *
</label>
<input
type="text"
value={formData.siteVisitLocation || ''}
onChange={(e) => setFormData({ ...formData, siteVisitLocation: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
placeholder="اكتب مكان أو عنوان زيارة الموقع"
/>
{formErrors.siteVisitLocation && (
<p className="text-red-500 text-xs mt-1">{formErrors.siteVisitLocation}</p>
)}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('tenders.announcementLink')}
</label>
<input
type="url"
value={formData.announcementLink || ''}
onChange={(e) => setFormData({ ...formData, announcementLink: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('common.notes')}
</label>
<textarea
value={formData.notes || ''}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
rows={2}
/>
</div>
{showDuplicateWarning && possibleDuplicates.length > 0 && (
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-800">
{t('tenders.duplicateWarning') || 'Possible duplicates found'}
</p>
<ul className="text-sm text-amber-700 mt-1 list-disc list-inside">
{possibleDuplicates.slice(0, 3).map((d) => (
<li key={d.id}>
<Link href={`/tenders/${d.id}`} className="underline">
{d.tenderNumber} - {d.title}
</Link>
</li>
))}
</ul>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-4">
<button
type="button"
onClick={() => {
setShowCreateModal(false)
resetForm()
}}
className="px-4 py-2 border rounded-lg"
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 flex items-center gap-2"
>
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
{t('common.save')}
</button>
</div>
</form>
</Modal>
</div>
)
}
export default function TendersPage() {
return (
<ProtectedRoute>
<TendersContent />
</ProtectedRoute>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -15,11 +15,7 @@ interface ContactFormProps {
submitting?: boolean
}
export default function ContactForm({ contact, onSubmit, onCancel, submitting = false }: ContactFormProps) {
const isEdit = !!contact
// Form state
const [formData, setFormData] = useState<CreateContactData>({
const buildInitialFormData = (contact?: Contact): CreateContactData => ({
type: contact?.type || 'INDIVIDUAL',
name: contact?.name || '',
nameAr: contact?.nameAr,
@@ -33,7 +29,7 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
commercialRegister: contact?.commercialRegister,
address: contact?.address,
city: contact?.city,
country: contact?.country || 'Saudi Arabia',
country: contact?.country || 'Syria',
postalCode: contact?.postalCode,
source: contact?.source || 'WEBSITE',
categories: contact?.categories?.map((c: any) => c.id || c) || [],
@@ -43,12 +39,23 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
customFields: contact?.customFields
})
export default function ContactForm({ contact, onSubmit, onCancel, submitting = false }: ContactFormProps) {
const isEdit = !!contact
const [formData, setFormData] = useState<CreateContactData>(buildInitialFormData(contact))
const [rating, setRating] = useState<number>(contact?.rating || 0)
const [newTag, setNewTag] = useState('')
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
const [categories, setCategories] = useState<Category[]>([])
const [employees, setEmployees] = useState<Employee[]>([])
useEffect(() => {
setFormData(buildInitialFormData(contact))
setRating(contact?.rating || 0)
setNewTag('')
setFormErrors({})
}, [contact])
useEffect(() => {
categoriesAPI.getTree().then(setCategories).catch(() => {})
}, [])
@@ -72,7 +79,23 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
const isCompanyEmployeeSelected = companyEmployeeCategoryId && (formData.categories || []).includes(companyEmployeeCategoryId)
// Validation
const organizationTypes = new Set([
'COMPANY',
'HOLDING',
'GOVERNMENT',
'ORGANIZATION',
'EMBASSIES',
'BANK',
'UNIVERSITY',
'SCHOOL',
'UN',
'NGO',
'INSTITUTION',
])
const isOrganizationType = organizationTypes.has(formData.type)
const showCompanyFields = isOrganizationType
const validateForm = (): boolean => {
const errors: Record<string, string> = {}
@@ -103,28 +126,49 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validateForm()) return
// Clean up empty strings to undefined for optional fields
const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, value]) => {
// Keep the value if it's not an empty string, or if it's a required field
if (value !== '' || ['type', 'name', 'source', 'country'].includes(key)) {
const requiredFields = ['type', 'name', 'source', 'country']
// keep required fields as-is
if (requiredFields.includes(key)) {
acc[key] = value
return acc
}
// in edit mode, allow clearing optional fields by sending null
if (isEdit && value === '') {
acc[key] = null
return acc
}
// in create mode, ignore empty optional fields
if (value !== '') {
acc[key] = value
}
return acc
}, {} as any)
// Remove parentId if it's empty or undefined
if (!cleanData.parentId) {
delete cleanData.parentId
}
// Remove categories if empty array
if (cleanData.categories && cleanData.categories.length === 0) {
delete cleanData.categories
}
// Remove employeeId if empty
if (!cleanData.employeeId) {
if (!cleanData.parentId) {
delete cleanData.parentId
}
if (cleanData.categories && cleanData.categories.length === 0) {
delete cleanData.categories
}
// employeeId:
// - in create: remove if empty
// - in edit: keep null if user cleared it
if (!isEdit && !cleanData.employeeId) {
delete cleanData.employeeId
}
@@ -152,16 +196,12 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
})
}
const showCompanyFields = ['COMPANY', 'HOLDING', 'GOVERNMENT'].includes(formData.type)
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Information Section */}
<form onSubmit={handleSubmit} noValidate className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Basic Information</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Contact Type */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Contact Type <span className="text-red-500">*</span>
@@ -175,11 +215,18 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
<option value="COMPANY">Company - شركة</option>
<option value="HOLDING">Holding - مجموعة</option>
<option value="GOVERNMENT">Government - حكومي</option>
<option value="ORGANIZATION">Organizations - منظمات</option>
<option value="EMBASSIES">Embassies - سفارات</option>
<option value="BANK">Banks - بنوك</option>
<option value="UNIVERSITY">Universities - جامعات</option>
<option value="SCHOOL">Schools - مدارس</option>
<option value="UN">UN - الأمم المتحدة</option>
<option value="NGO">NGO - منظمة غير حكومية</option>
<option value="INSTITUTION">Institution - مؤسسة</option>
</select>
{formErrors.type && <p className="text-red-500 text-xs mt-1">{formErrors.type}</p>}
</div>
{/* Source */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Source <span className="text-red-500">*</span>
@@ -202,37 +249,20 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
</div>
</div>
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name <span className="text-red-500">*</span>
{isOrganizationType ? 'Contact Person Name' : 'Name'} <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
placeholder="Enter contact name"
placeholder={isOrganizationType ? 'Enter contact person name' : 'Enter contact name'}
/>
{formErrors.name && <p className="text-red-500 text-xs mt-1">{formErrors.name}</p>}
</div>
{/* Arabic Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Arabic Name - الاسم بالعربية
</label>
<input
type="text"
value={formData.nameAr || ''}
onChange={(e) => setFormData({ ...formData, nameAr: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
placeholder="أدخل الاسم بالعربية"
dir="rtl"
/>
</div>
{/* Rating */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Rating
@@ -268,12 +298,10 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
</div>
</div>
{/* Contact Methods Section */}
<div className="pt-6 border-t">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Contact Methods</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Email */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
@@ -288,7 +316,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
{formErrors.email && <p className="text-red-500 text-xs mt-1">{formErrors.email}</p>}
</div>
{/* Phone */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Phone
@@ -305,7 +332,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Mobile */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Mobile
@@ -319,7 +345,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
/>
</div>
{/* Website */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Website
@@ -336,44 +361,27 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
</div>
</div>
{/* Company Information Section (conditional) */}
{showCompanyFields && (
<div className="pt-6 border-t">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Company Information</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Company Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Company Name
{formData.type === 'EMBASSIES' ? 'Embassy Name' : 'Company / Organization Name'}
</label>
<input
type="text"
value={formData.companyName || ''}
onChange={(e) => setFormData({ ...formData, companyName: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
placeholder="Company name"
placeholder={formData.type === 'EMBASSIES' ? 'Embassy name' : 'Company / organization name'}
/>
</div>
{/* Company Name Arabic */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Company Name (Arabic) - اسم الشركة
</label>
<input
type="text"
value={formData.companyNameAr || ''}
onChange={(e) => setFormData({ ...formData, companyNameAr: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
placeholder="اسم الشركة بالعربية"
dir="rtl"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Tax Number */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tax Number
@@ -387,7 +395,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
/>
</div>
{/* Commercial Register */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Commercial Register
@@ -405,11 +412,9 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
</div>
)}
{/* Address Section */}
<div className="pt-6 border-t">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Address Information</h3>
<div className="space-y-4">
{/* Address */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Street Address
@@ -424,7 +429,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* City */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
City
@@ -438,7 +442,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
/>
</div>
{/* Country */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Country
@@ -452,7 +455,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
/>
</div>
{/* Postal Code */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Postal Code
@@ -469,7 +471,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
</div>
</div>
{/* Categories Section */}
<div className="pt-6 border-t">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Categories</h3>
<CategorySelector
@@ -479,7 +480,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
/>
</div>
{/* Employee Link - when Company Employee category is selected */}
{isCompanyEmployeeSelected && (
<div className="pt-6 border-t">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Link to Employee (Optional)</h3>
@@ -501,11 +501,9 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
</div>
)}
{/* Tags Section */}
<div className="pt-6 border-t">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Tags</h3>
<div className="space-y-3">
{/* Tag input */}
<div className="flex gap-2">
<input
type="text"
@@ -524,7 +522,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
</button>
</div>
{/* Tags display */}
{formData.tags && formData.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{formData.tags.map((tag, index) => (
@@ -547,7 +544,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
</div>
</div>
{/* Duplicate Detection */}
<DuplicateAlert
email={formData.email}
phone={formData.phone}
@@ -556,14 +552,12 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
commercialRegister={formData.commercialRegister}
excludeId={contact?.id}
onMerge={(contactId) => {
// Navigate to merge page with pre-selected contacts
if (typeof window !== 'undefined') {
window.location.href = `/contacts/merge?sourceId=${contactId}`
}
}}
/>
{/* Form Actions */}
<div className="flex items-center justify-end gap-3 pt-6 border-t">
<button
type="button"

View File

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

View File

@@ -99,12 +99,17 @@ const translations = {
active: 'Active',
inactive: 'Inactive',
archived: 'Archived',
deleted: 'Deleted'
deleted: 'Deleted',
all: 'All',
view: 'View',
showing: 'Showing',
of: 'of'
},
nav: {
dashboard: 'Dashboard',
contacts: 'Contacts',
crm: 'CRM',
tenders: 'Tenders',
projects: 'Projects',
inventory: 'Inventory',
hr: 'HR',
@@ -274,7 +279,79 @@ const translations = {
processing: 'Processing...',
deleting: 'Deleting...',
deleteDealConfirm: 'Are you sure you want to delete',
deleteDealDesc: 'This will mark the deal as lost'
deleteDealDesc: 'This will mark the deal as lost',
costSheets: 'Cost Sheets',
contracts: 'Contracts',
invoices: 'Invoices',
addCostSheet: 'Add Cost Sheet',
addContract: 'Add Contract',
addInvoice: 'Add Invoice',
approve: 'Approve',
reject: 'Reject',
markSigned: 'Mark Signed',
recordPayment: 'Record Payment',
costSheetApproved: 'Cost sheet approved',
costSheetRejected: 'Cost sheet rejected',
contractSigned: 'Contract signed',
paymentRecorded: 'Payment recorded',
costSheetCreated: 'Cost sheet created',
contractCreated: 'Contract created',
invoiceCreated: 'Invoice created',
costSheetItems: 'Cost items (description, source, cost, quantity)',
invoiceItems: 'Line items (description, quantity, unit price)',
description: 'Description',
source: 'Source',
addRow: 'Add row',
totalCost: 'Total Cost',
suggestedPrice: 'Suggested Price',
profitMargin: 'Profit Margin',
contractTitle: 'Contract Title',
contractType: 'Contract Type',
contractTypeSales: 'Sales',
contractTypeService: 'Service',
contractTypeMaintenance: 'Maintenance',
contractValue: 'Contract Value',
startDate: 'Start Date',
endDate: 'End Date',
paymentTerms: 'Payment Terms',
deliveryTerms: 'Delivery Terms',
terms: 'Terms & Conditions',
subtotal: 'Subtotal',
taxAmount: 'Tax Amount',
total: 'Total',
dueDate: 'Due Date',
paidAmount: 'Paid Amount',
paidDate: 'Paid Date'
},
tenders: {
title: 'Tenders',
subtitle: 'Tender Management',
addTender: 'Add Tender',
tenderNumber: 'Tender number',
issuingBody: 'Issuing body',
titleLabel: 'Title',
termsValue: 'Terms booklet value',
bondValue: 'Bond value',
announcementDate: 'Announcement date',
closingDate: 'Closing date',
announcementLink: 'Announcement link',
source: 'Source',
announcementType: 'Announcement type',
searchPlaceholder: 'Search by number, title, issuing body...',
noTenders: 'No tenders found.',
loadError: 'Failed to load tenders',
createSuccess: 'Tender created successfully',
duplicateWarning: 'Possible duplicates found. Please review.',
directives: 'Directives',
addDirective: 'Add directive',
directiveType: 'Directive type',
assignee: 'Assignee',
convertToDeal: 'Convert to Opportunity',
history: 'History',
attachments: 'Attachments',
uploadFile: 'Upload file',
completeTask: 'Complete task',
completionNotes: 'Completion notes'
},
import: {
title: 'Import Contacts',
@@ -339,6 +416,7 @@ const translations = {
dashboard: 'لوحة التحكم',
contacts: 'جهات الاتصال',
crm: 'إدارة العملاء',
tenders: 'المناقصات',
projects: 'المشاريع',
inventory: 'المخزون',
hr: 'الموارد البشرية',
@@ -346,6 +424,36 @@ const translations = {
settings: 'الإعدادات',
logout: 'تسجيل الخروج'
},
tenders: {
title: 'المناقصات',
subtitle: 'نظام إدارة المناقصات',
addTender: 'إضافة مناقصة',
tenderNumber: 'رقم المناقصة',
issuingBody: 'الجهة الطارحة',
titleLabel: 'عنوان المناقصة',
termsValue: 'قيمة دفتر الشروط',
bondValue: 'قيمة التأمينات',
announcementDate: 'تاريخ الإعلان',
closingDate: 'تاريخ الإغلاق',
announcementLink: 'رابط الإعلان',
source: 'مصدر المناقصة',
announcementType: 'نوع الإعلان',
searchPlaceholder: 'البحث بالرقم أو العنوان أو الجهة الطارحة...',
noTenders: 'لم يتم العثور على مناقصات.',
loadError: 'فشل تحميل المناقصات',
createSuccess: 'تم إنشاء المناقصة بنجاح',
duplicateWarning: 'يوجد مناقصات مشابهة. يرجى المراجعة.',
directives: 'التوجيهات',
addDirective: 'إضافة توجيه',
directiveType: 'نوع التوجيه',
assignee: 'الموظف المسؤول',
convertToDeal: 'تحويل إلى فرصة',
history: 'السجل',
attachments: 'المرفقات',
uploadFile: 'رفع ملف',
completeTask: 'إتمام المهمة',
completionNotes: 'ملاحظات الإنجاز'
},
contacts: {
title: 'جهات الاتصال',
addContact: 'إضافة جهة اتصال',
@@ -508,7 +616,49 @@ const translations = {
processing: 'جاري المعالجة...',
deleting: 'جاري الحذف...',
deleteDealConfirm: 'هل أنت متأكد من حذف',
deleteDealDesc: 'سيتم تحديد الصفقة كخاسرة'
deleteDealDesc: 'سيتم تحديد الصفقة كخاسرة',
costSheets: 'كشوفات التكلفة',
contracts: 'العقود',
invoices: 'الفواتير',
addCostSheet: 'إضافة كشف تكلفة',
addContract: 'إضافة عقد',
addInvoice: 'إضافة فاتورة',
approve: 'موافقة',
reject: 'رفض',
markSigned: 'توقيع',
recordPayment: 'تسجيل الدفع',
costSheetApproved: 'تمت الموافقة على كشف التكلفة',
costSheetRejected: 'تم رفض كشف التكلفة',
contractSigned: 'تم توقيع العقد',
paymentRecorded: 'تم تسجيل الدفع',
costSheetCreated: 'تم إنشاء كشف التكلفة',
contractCreated: 'تم إنشاء العقد',
invoiceCreated: 'تم إنشاء الفاتورة',
costSheetItems: 'بنود التكلفة (الوصف، المصدر، التكلفة، الكمية)',
invoiceItems: 'بنود الفاتورة (الوصف، الكمية، سعر الوحدة)',
description: 'الوصف',
source: 'المصدر',
addRow: 'إضافة صف',
totalCost: 'إجمالي التكلفة',
suggestedPrice: 'السعر المقترح',
profitMargin: 'هامش الربح',
contractTitle: 'عنوان العقد',
contractType: 'نوع العقد',
contractTypeSales: 'مبيعات',
contractTypeService: 'خدمة',
contractTypeMaintenance: 'صيانة',
contractValue: 'قيمة العقد',
startDate: 'تاريخ البداية',
endDate: 'تاريخ النهاية',
paymentTerms: 'شروط الدفع',
deliveryTerms: 'شروط التسليم',
terms: 'الشروط والأحكام',
subtotal: 'المجموع الفرعي',
taxAmount: 'ضريبة',
total: 'الإجمالي',
dueDate: 'تاريخ الاستحقاق',
paidAmount: 'المبلغ المدفوع',
paidDate: 'تاريخ الدفع'
},
import: {
title: 'استيراد جهات الاتصال',

View File

@@ -77,6 +77,10 @@ export const contactsAPI = {
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 = {
// Deals
getDeals: (params?: any) => api.get('/crm/deals', { params }),

View File

@@ -113,7 +113,7 @@ export const statsAPI = {
},
};
// Positions (Roles) API
// Positions (Roles) API - maps to HR positions with permissions
export interface PositionPermission {
id: string;
module: string;
@@ -126,25 +126,19 @@ export interface PositionRole {
title: string;
titleAr?: string | null;
code: string;
level: number;
departmentId: string;
department?: { id?: string; name: string; nameAr?: string | null };
department?: { name: string; nameAr?: string | null };
permissions: PositionPermission[];
usersCount: number;
_count?: { employees: number };
}
export interface CreatePositionPayload {
export interface CreatePositionData {
title: string;
titleAr?: string | null;
titleAr?: string;
code: string;
departmentId: string;
level?: number;
code?: string;
}
export interface UpdatePositionPayload {
title?: string;
titleAr?: string | null;
description?: string;
}
export const positionsAPI = {
@@ -153,20 +147,19 @@ export const positionsAPI = {
return response.data.data || [];
},
create: async (payload: CreatePositionPayload): Promise<PositionRole> => {
const response = await api.post('/admin/positions', payload);
create: async (data: CreatePositionData): Promise<PositionRole> => {
const response = await api.post('/admin/positions', data);
return response.data.data;
},
update: async (positionId: string, payload: UpdatePositionPayload): Promise<PositionRole> => {
const response = await api.put(`/admin/positions/${positionId}`, payload);
update: async (
id: string,
data: Partial<CreatePositionData & { isActive?: boolean }>
): Promise<PositionRole> => {
const response = await api.put(`/admin/positions/${id}`, data);
return response.data.data;
},
delete: async (positionId: string): Promise<void> => {
await api.delete(`/admin/positions/${positionId}`);
},
updatePermissions: async (
positionId: 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 {
id: 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
export interface AuditLog {
id: string;
@@ -260,7 +300,7 @@ export const auditLogsAPI = {
},
};
// System Settings API (placeholder)
// System Settings API (placeholder - out of scope)
export interface SystemSetting {
key: string;
value: unknown;
@@ -279,7 +319,7 @@ export const settingsAPI = {
},
};
// System Health API (placeholder)
// System Health API (placeholder - optional)
export interface SystemHealth {
status: string;
database: string;

View File

@@ -0,0 +1,65 @@
import { api } from '../api'
export interface Contract {
id: string
contractNumber: string
dealId: string
deal?: any
version?: number
title: string
type: string
clientInfo: any
companyInfo: any
startDate: string
endDate?: string
value: number
paymentTerms: any
deliveryTerms: any
terms: string
status: string
signedAt?: string
documentUrl?: string
createdAt: string
updatedAt: string
}
export interface CreateContractData {
dealId: string
title: string
type: string
clientInfo: any
companyInfo: any
startDate: string
endDate?: string
value: number
paymentTerms: any
deliveryTerms: any
terms: string
}
export const contractsAPI = {
getByDeal: async (dealId: string): Promise<Contract[]> => {
const response = await api.get(`/crm/deals/${dealId}/contracts`)
return response.data.data || []
},
getById: async (id: string): Promise<Contract> => {
const response = await api.get(`/crm/contracts/${id}`)
return response.data.data
},
create: async (data: CreateContractData): Promise<Contract> => {
const response = await api.post('/crm/contracts', data)
return response.data.data
},
update: async (id: string, data: Partial<CreateContractData>): Promise<Contract> => {
const response = await api.put(`/crm/contracts/${id}`, data)
return response.data.data
},
sign: async (id: string): Promise<Contract> => {
const response = await api.post(`/crm/contracts/${id}/sign`)
return response.data.data
}
}

View File

@@ -0,0 +1,60 @@
import { api } from '../api'
export interface CostSheetItem {
description?: string
source?: string
cost: number
quantity: number
}
export interface CostSheet {
id: string
costSheetNumber: string
dealId: string
deal?: any
version: number
items: CostSheetItem[] | any
totalCost: number
suggestedPrice: number
profitMargin: number
status: string
approvedBy?: string
approvedAt?: string
createdAt: string
updatedAt: string
}
export interface CreateCostSheetData {
dealId: string
items: CostSheetItem[] | any[]
totalCost: number
suggestedPrice: number
profitMargin: number
}
export const costSheetsAPI = {
getByDeal: async (dealId: string): Promise<CostSheet[]> => {
const response = await api.get(`/crm/deals/${dealId}/cost-sheets`)
return response.data.data || []
},
getById: async (id: string): Promise<CostSheet> => {
const response = await api.get(`/crm/cost-sheets/${id}`)
return response.data.data
},
create: async (data: CreateCostSheetData): Promise<CostSheet> => {
const response = await api.post('/crm/cost-sheets', data)
return response.data.data
},
approve: async (id: string): Promise<CostSheet> => {
const response = await api.post(`/crm/cost-sheets/${id}/approve`)
return response.data.data
},
reject: async (id: string): Promise<CostSheet> => {
const response = await api.post(`/crm/cost-sheets/${id}/reject`)
return response.data.data
}
}

View File

@@ -1,149 +1,5 @@
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 {
id: string
uniqueEmployeeId: string
@@ -169,11 +25,7 @@ export interface Employee {
position?: any
reportingToId?: string
reportingTo?: any
baseSalary: number
basicSalary?: any
currency?: string
status: string
createdAt: string
updatedAt: string
@@ -197,7 +49,6 @@ export interface CreateEmployeeData {
departmentId: string
positionId: string
reportingToId?: string
baseSalary: number
}
@@ -221,6 +72,7 @@ export interface EmployeesResponse {
}
export const employeesAPI = {
// Get all employees with filters and pagination
getAll: async (filters: EmployeeFilters = {}): Promise<EmployeesResponse> => {
const params = new URLSearchParams()
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 { data, pagination } = response.data
const normalized = Array.isArray(data) ? data.map(normalizeEmployeeFromApi) : []
const employees = (data || []).map((e: any) => ({
...e,
baseSalary: e.baseSalary ?? e.basicSalary ?? 0,
}))
return {
employees: normalized,
employees,
total: pagination?.total || 0,
page: pagination?.page || 1,
pageSize: pagination?.pageSize || 20,
@@ -244,35 +97,70 @@ export const employeesAPI = {
}
},
// Get single employee by ID
getById: async (id: string): Promise<Employee> => {
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> => {
const payload = normalizeEmployeeToApi(data)
const response = await api.post('/hr/employees', payload)
return normalizeEmployeeFromApi(response.data.data)
const response = await api.post('/hr/employees', data)
return response.data.data
},
// Update existing employee
update: async (id: string, data: UpdateEmployeeData): Promise<Employee> => {
const payload = normalizeEmployeeToApi(data)
const response = await api.put(`/hr/employees/${id}`, payload)
return normalizeEmployeeFromApi(response.data.data)
const response = await api.put(`/hr/employees/${id}`, data)
return response.data.data
},
// Delete employee
delete: async (id: string): Promise<void> => {
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 = {
getAll: async (): Promise<any[]> => {
const response = await api.get('/hr/departments')
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 = {
getAll: async (): Promise<any[]> => {
const response = await api.get('/hr/positions')

View File

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

View File

@@ -0,0 +1,64 @@
import { api } from '../api'
export interface InvoiceItem {
description?: string
quantity: number
unitPrice: number
total?: number
}
export interface Invoice {
id: string
invoiceNumber: string
dealId?: string
deal?: any
items: InvoiceItem[] | any
subtotal: number
taxAmount: number
total: number
status: string
dueDate: string
paidDate?: string
paidAmount?: number
createdAt: string
updatedAt: string
}
export interface CreateInvoiceData {
dealId?: string
items: InvoiceItem[] | any[]
subtotal: number
taxAmount: number
total: number
dueDate: string
}
export const invoicesAPI = {
getByDeal: async (dealId: string): Promise<Invoice[]> => {
const response = await api.get(`/crm/deals/${dealId}/invoices`)
return response.data.data || []
},
getById: async (id: string): Promise<Invoice> => {
const response = await api.get(`/crm/invoices/${id}`)
return response.data.data
},
create: async (data: CreateInvoiceData): Promise<Invoice> => {
const response = await api.post('/crm/invoices', data)
return response.data.data
},
update: async (id: string, data: Partial<CreateInvoiceData>): Promise<Invoice> => {
const response = await api.put(`/crm/invoices/${id}`, data)
return response.data.data
},
recordPayment: async (id: string, paidAmount: number, paidDate?: string): Promise<Invoice> => {
const response = await api.post(`/crm/invoices/${id}/record-payment`, {
paidAmount,
paidDate: paidDate || new Date().toISOString(),
})
return response.data.data
}
}

View File

@@ -0,0 +1,251 @@
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 PortalOvertimeRequest {
id: string
attendanceId: string
date: string
hours: number
reason: string
status: string
rejectedReason?: string
createdAt: string
employee?: {
id: string
firstName: string
lastName: string
uniqueEmployeeId: string
reportingToId?: 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 ManagedLeave {
id: string
leaveType: string
startDate: string
endDate: string
days: number
status: string
reason?: string
rejectedReason?: string
employee: {
id: string
firstName: string
lastName: string
uniqueEmployeeId: string
reportingToId?: 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 = {
getManagedLeaves: async (status?: string): Promise<ManagedLeave[]> => {
const q = new URLSearchParams()
if (status && status !== 'all') q.append('status', status)
const response = await api.get(`/hr/portal/managed-leaves?${q.toString()}`)
return response.data.data || []
},
approveManagedLeave: async (id: string) => {
const response = await api.post(`/hr/portal/managed-leaves/${id}/approve`)
return response.data.data
},
rejectManagedLeave: async (id: string, rejectedReason: string) => {
const response = await api.post(`/hr/portal/managed-leaves/${id}/reject`, { rejectedReason })
return response.data.data
},
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
},
getOvertimeRequests: async (): Promise<PortalOvertimeRequest[]> => {
const response = await api.get('/hr/portal/overtime-requests')
return response.data.data || []
},
submitOvertimeRequest: async (data: {
date: string
hours: number
reason: string
}): Promise<PortalOvertimeRequest> => {
const response = await api.post('/hr/portal/overtime-requests', data)
return response.data.data
},
getManagedOvertimeRequests: async (): Promise<PortalOvertimeRequest[]> => {
const response = await api.get('/hr/portal/managed-overtime-requests')
return response.data.data || []
},
approveManagedOvertimeRequest: async (attendanceId: string): Promise<PortalOvertimeRequest> => {
const response = await api.post(`/hr/portal/managed-overtime-requests/${attendanceId}/approve`)
return response.data.data
},
rejectManagedOvertimeRequest: async (
attendanceId: string,
rejectedReason: string
): Promise<PortalOvertimeRequest> => {
const response = await api.post(`/hr/portal/managed-overtime-requests/${attendanceId}/reject`, {
rejectedReason,
})
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 || []
},
}

View File

@@ -0,0 +1,208 @@
import { api } from '../api'
export interface Tender {
id: string
tenderNumber: string
issuingBodyName: string
title: string
termsValue: number
bondValue: number
// extra fields stored inside notes metadata for now
initialBondValue?: number | null
finalBondValue?: number | null
finalBondRefundPeriod?: string | null
siteVisitRequired?: boolean
siteVisitLocation?: string | null
termsPickupProvince?: string | null
announcementDate: string
closingDate: string
announcementLink?: string
source: string
sourceOther?: string
announcementType: string
notes?: string
status: string
contactId?: string
contact?: any
createdById: string
createdBy?: any
createdAt: string
updatedAt: string
directives?: TenderDirective[]
attachments?: any[]
_count?: { directives: number }
}
export interface TenderDirective {
id: string
tenderId: string
type: string
notes?: string
assignedToEmployeeId: string
assignedToEmployee?: any
issuedById: string
issuedBy?: any
status: string
completedAt?: string
completionNotes?: string
completedById?: string
createdAt: string
updatedAt: string
attachments?: any[]
}
export interface CreateTenderData {
tenderNumber: string
issuingBodyName: string
title: string
termsValue: number
bondValue: number
// extra UI/backend fields without DB migration
initialBondValue?: number
finalBondValue?: number
finalBondRefundPeriod?: string
siteVisitRequired?: boolean
siteVisitLocation?: string
termsPickupProvince?: string
announcementDate: string
closingDate: string
announcementLink?: string
source: string
sourceOther?: string
announcementType: string
notes?: string
contactId?: string
}
export interface CreateDirectiveData {
type: string
assignedToEmployeeId: string
notes?: string
}
export interface TenderFilters {
search?: string
status?: string
source?: string
announcementType?: string
page?: number
pageSize?: number
}
export interface TendersResponse {
data: Tender[]
pagination: {
total: number
page: number
pageSize: number
totalPages: number
}
}
export const tendersAPI = {
getAll: async (filters: TenderFilters = {}): Promise<{ tenders: Tender[]; total: number; page: number; pageSize: number; totalPages: number }> => {
const params = new URLSearchParams()
if (filters.search) params.append('search', filters.search)
if (filters.status) params.append('status', filters.status)
if (filters.source) params.append('source', filters.source)
if (filters.announcementType) params.append('announcementType', filters.announcementType)
if (filters.page) params.append('page', filters.page.toString())
if (filters.pageSize) params.append('pageSize', filters.pageSize.toString())
const response = await api.get(`/tenders?${params.toString()}`)
const { data, pagination } = response.data
return {
tenders: data || [],
total: pagination?.total ?? 0,
page: pagination?.page ?? 1,
pageSize: pagination?.pageSize ?? 20,
totalPages: pagination?.totalPages ?? 0,
}
},
getById: async (id: string): Promise<Tender> => {
const response = await api.get(`/tenders/${id}`)
return response.data.data
},
create: async (data: CreateTenderData): Promise<{ tender: Tender; possibleDuplicates?: Tender[] }> => {
const response = await api.post('/tenders', data)
return response.data.data
},
update: async (id: string, data: Partial<CreateTenderData>): Promise<Tender> => {
const response = await api.put(`/tenders/${id}`, data)
return response.data.data
},
checkDuplicates: async (data: Partial<CreateTenderData>): Promise<Tender[]> => {
const response = await api.post('/tenders/check-duplicates', data)
return response.data.data
},
getHistory: async (id: string): Promise<any[]> => {
const response = await api.get(`/tenders/${id}/history`)
return response.data.data
},
createDirective: async (tenderId: string, data: CreateDirectiveData): Promise<TenderDirective> => {
const response = await api.post(`/tenders/${tenderId}/directives`, data)
return response.data.data
},
updateDirective: async (directiveId: string, data: { status?: string; completionNotes?: string }): Promise<TenderDirective> => {
const response = await api.put(`/tenders/directives/${directiveId}`, data)
return response.data.data
},
convertToDeal: async (tenderId: string, data: { contactId: string; pipelineId: string; ownerId?: string }): Promise<any> => {
const response = await api.post(`/tenders/${tenderId}/convert-to-deal`, data)
return response.data.data
},
uploadTenderAttachment: async (tenderId: string, file: File, category?: string): Promise<any> => {
const formData = new FormData()
formData.append('file', file)
if (category) formData.append('category', category)
const response = await api.post(`/tenders/${tenderId}/attachments`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data.data
},
uploadDirectiveAttachment: async (directiveId: string, file: File, category?: string): Promise<any> => {
const formData = new FormData()
formData.append('file', file)
if (category) formData.append('category', category)
const response = await api.post(`/tenders/directives/${directiveId}/attachments`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data.data
},
deleteAttachment: async (attachmentId: string): Promise<void> => {
await api.delete(`/tenders/attachments/${attachmentId}`)
},
getSourceValues: async (): Promise<string[]> => {
const response = await api.get('/tenders/source-values')
return response.data.data
},
getAnnouncementTypeValues: async (): Promise<string[]> => {
const response = await api.get('/tenders/announcement-type-values')
return response.data.data
},
getDirectiveTypeValues: async (): Promise<string[]> => {
const response = await api.get('/tenders/directive-type-values')
return response.data.data
},
}

68
package-lock.json generated
View File

@@ -1,14 +1,15 @@
{
"name": "mind14-crm",
"name": "z-crm",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mind14-crm",
"name": "z-crm",
"version": "1.0.0",
"license": "PROPRIETARY",
"devDependencies": {
"@playwright/test": "^1.58.2",
"concurrently": "^8.2.2"
}
},
@@ -22,6 +23,22 @@
"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": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -175,6 +192,21 @@
"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": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -212,6 +244,38 @@
"dev": true,
"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": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",

Some files were not shown because too many files have changed in this diff Show More