Compare commits
29 Commits
ae890ca1c5
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| bda70feb18 | |||
| 13f2214df5 | |||
| 04cc054be1 | |||
| 8954e9c1dd | |||
|
|
7b73053d80 | ||
|
|
f101989047 | ||
|
|
03312c3769 | ||
|
|
1014d88313 | ||
|
|
278d8f6982 | ||
|
|
94d651c29e | ||
|
|
3e8985ffe0 | ||
|
|
3fbe607ed7 | ||
|
|
14d2597722 | ||
|
|
45c43ab526 | ||
|
|
78aa7c0fb5 | ||
|
|
005edf2b69 | ||
|
|
5f7e9e517f | ||
|
|
4043f3bd6c | ||
|
|
3fd62ba0ad | ||
|
|
ba33072d95 | ||
|
|
03dea2e52b | ||
|
|
854a42980d | ||
|
|
4c139429e2 | ||
|
|
18c13cdf7c | ||
|
|
8a20927044 | ||
|
|
6d82c5007c | ||
|
|
625bc26a05 | ||
|
|
8365f4da2d | ||
|
|
72ed9a2ff5 |
@@ -6,3 +6,6 @@ JWT_SECRET=your-super-secure-jwt-secret-change-this-now-2024
|
|||||||
|
|
||||||
# Domain
|
# Domain
|
||||||
DOMAIN=zerp.atmata-group.com
|
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
174
DEVELOPER_STAGING_DEPLOY.md
Normal 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 team’s 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 server’s 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**.
|
||||||
1
backend/package-lock.json
generated
1
backend/package-lock.json
generated
@@ -2909,7 +2909,6 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
|||||||
28
backend/prisma/add-tenders-permissions.sql
Normal file
28
backend/prisma/add-tenders-permissions.sql
Normal 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'
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -42,7 +42,7 @@ model AuditLog {
|
|||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
email String @unique
|
email String
|
||||||
username String @unique
|
username String @unique
|
||||||
password String
|
password String
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
@@ -70,6 +70,9 @@ model User {
|
|||||||
projectMembers ProjectMember[]
|
projectMembers ProjectMember[]
|
||||||
campaigns Campaign[]
|
campaigns Campaign[]
|
||||||
userRoles UserRole[]
|
userRoles UserRole[]
|
||||||
|
tendersCreated Tender[]
|
||||||
|
tenderDirectivesIssued TenderDirective[]
|
||||||
|
tenderDirectivesCompleted TenderDirective[] @relation("TenderDirectiveCompletedBy")
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -178,6 +181,9 @@ model Employee {
|
|||||||
// Documents
|
// Documents
|
||||||
documents Json? // Array of document references
|
documents Json? // Array of document references
|
||||||
|
|
||||||
|
// ZK Tico / Attendance device - maps to employee pin on device
|
||||||
|
attendancePin String? @unique
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -192,6 +198,11 @@ model Employee {
|
|||||||
disciplinaryActions DisciplinaryAction[]
|
disciplinaryActions DisciplinaryAction[]
|
||||||
allowances Allowance[]
|
allowances Allowance[]
|
||||||
commissions Commission[]
|
commissions Commission[]
|
||||||
|
loans Loan[]
|
||||||
|
purchaseRequests PurchaseRequest[]
|
||||||
|
leaveEntitlements LeaveEntitlement[]
|
||||||
|
employeeContracts EmployeeContract[]
|
||||||
|
tenderDirectivesAssigned TenderDirective[]
|
||||||
|
|
||||||
@@index([departmentId])
|
@@index([departmentId])
|
||||||
@@index([positionId])
|
@@index([positionId])
|
||||||
@@ -270,12 +281,18 @@ model Attendance {
|
|||||||
status String // PRESENT, ABSENT, LATE, HALF_DAY, etc.
|
status String // PRESENT, ABSENT, LATE, HALF_DAY, etc.
|
||||||
notes String?
|
notes String?
|
||||||
|
|
||||||
|
// ZK Tico / External device sync
|
||||||
|
sourceDeviceId String?
|
||||||
|
externalId String?
|
||||||
|
rawData Json?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@unique([employeeId, date])
|
@@unique([employeeId, date])
|
||||||
@@index([employeeId])
|
@@index([employeeId])
|
||||||
@@index([date])
|
@@index([date])
|
||||||
|
@@index([sourceDeviceId])
|
||||||
@@map("attendances")
|
@@map("attendances")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,6 +435,115 @@ model DisciplinaryAction {
|
|||||||
@@map("disciplinary_actions")
|
@@map("disciplinary_actions")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Loan {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
employeeId String
|
||||||
|
employee Employee @relation(fields: [employeeId], references: [id])
|
||||||
|
loanNumber String @unique
|
||||||
|
type String // SALARY_ADVANCE, EQUIPMENT, PERSONAL, etc.
|
||||||
|
amount Decimal @db.Decimal(12, 2)
|
||||||
|
currency String @default("SAR")
|
||||||
|
installments Int @default(1)
|
||||||
|
monthlyAmount Decimal? @db.Decimal(12, 2)
|
||||||
|
reason String?
|
||||||
|
status String @default("PENDING") // PENDING, APPROVED, REJECTED, ACTIVE, PAID_OFF
|
||||||
|
approvedBy String?
|
||||||
|
approvedAt DateTime?
|
||||||
|
rejectedReason String?
|
||||||
|
startDate DateTime? @db.Date
|
||||||
|
endDate DateTime? @db.Date
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
installmentsList LoanInstallment[]
|
||||||
|
|
||||||
|
@@index([employeeId])
|
||||||
|
@@index([status])
|
||||||
|
@@map("loans")
|
||||||
|
}
|
||||||
|
|
||||||
|
model LoanInstallment {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
loanId String
|
||||||
|
loan Loan @relation(fields: [loanId], references: [id], onDelete: Cascade)
|
||||||
|
installmentNumber Int
|
||||||
|
dueDate DateTime @db.Date
|
||||||
|
amount Decimal @db.Decimal(12, 2)
|
||||||
|
paidDate DateTime? @db.Date
|
||||||
|
status String @default("PENDING") // PENDING, PAID, OVERDUE
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([loanId, installmentNumber])
|
||||||
|
@@index([loanId])
|
||||||
|
@@map("loan_installments")
|
||||||
|
}
|
||||||
|
|
||||||
|
model PurchaseRequest {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
requestNumber String @unique
|
||||||
|
employeeId String
|
||||||
|
employee Employee @relation(fields: [employeeId], references: [id])
|
||||||
|
items Json // Array of { description, quantity, estimatedPrice, etc. }
|
||||||
|
totalAmount Decimal? @db.Decimal(12, 2)
|
||||||
|
reason String?
|
||||||
|
priority String @default("NORMAL") // LOW, NORMAL, HIGH, URGENT
|
||||||
|
status String @default("PENDING") // PENDING, APPROVED, REJECTED, ORDERED
|
||||||
|
approvedBy String?
|
||||||
|
approvedAt DateTime?
|
||||||
|
rejectedReason String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([employeeId])
|
||||||
|
@@index([status])
|
||||||
|
@@map("purchase_requests")
|
||||||
|
}
|
||||||
|
|
||||||
|
model LeaveEntitlement {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
employeeId String
|
||||||
|
employee Employee @relation(fields: [employeeId], references: [id])
|
||||||
|
year Int
|
||||||
|
leaveType String // ANNUAL, SICK, EMERGENCY, etc.
|
||||||
|
totalDays Int @default(0)
|
||||||
|
usedDays Int @default(0)
|
||||||
|
carriedOver Int @default(0)
|
||||||
|
notes String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([employeeId, year, leaveType])
|
||||||
|
@@index([employeeId])
|
||||||
|
@@map("leave_entitlements")
|
||||||
|
}
|
||||||
|
|
||||||
|
model EmployeeContract {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
employeeId String
|
||||||
|
employee Employee @relation(fields: [employeeId], references: [id])
|
||||||
|
contractNumber String @unique
|
||||||
|
type String // FIXED, UNLIMITED, PROBATION, etc.
|
||||||
|
startDate DateTime @db.Date
|
||||||
|
endDate DateTime? @db.Date
|
||||||
|
salary Decimal @db.Decimal(12, 2)
|
||||||
|
currency String @default("SAR")
|
||||||
|
documentUrl String?
|
||||||
|
status String @default("ACTIVE") // DRAFT, ACTIVE, EXPIRED, TERMINATED
|
||||||
|
notes String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([employeeId])
|
||||||
|
@@index([status])
|
||||||
|
@@map("employee_contracts")
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// MODULE 1: CONTACT MANAGEMENT
|
// MODULE 1: CONTACT MANAGEMENT
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -488,6 +614,7 @@ model Contact {
|
|||||||
deals Deal[]
|
deals Deal[]
|
||||||
attachments Attachment[]
|
attachments Attachment[]
|
||||||
notes Note[]
|
notes Note[]
|
||||||
|
tenders Tender[]
|
||||||
|
|
||||||
@@index([type])
|
@@index([type])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@ -584,6 +711,10 @@ model Deal {
|
|||||||
// Status
|
// Status
|
||||||
status String @default("ACTIVE") // ACTIVE, WON, LOST, CANCELLED, ARCHIVED
|
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -751,6 +882,66 @@ model Invoice {
|
|||||||
@@map("invoices")
|
@@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
|
// MODULE 3: INVENTORY & ASSETS
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -1312,6 +1503,10 @@ model Attachment {
|
|||||||
project Project? @relation(fields: [projectId], references: [id])
|
project Project? @relation(fields: [projectId], references: [id])
|
||||||
taskId String?
|
taskId String?
|
||||||
task Task? @relation(fields: [taskId], references: [id])
|
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
|
// File Info
|
||||||
fileName String
|
fileName String
|
||||||
@@ -1333,6 +1528,8 @@ model Attachment {
|
|||||||
@@index([dealId])
|
@@index([dealId])
|
||||||
@@index([projectId])
|
@@index([projectId])
|
||||||
@@index([taskId])
|
@@index([taskId])
|
||||||
|
@@index([tenderId])
|
||||||
|
@@index([tenderDirectiveId])
|
||||||
@@map("attachments")
|
@@map("attachments")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ async function main() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const modules = ['contacts', 'crm', 'inventory', 'projects', 'hr', 'marketing', 'admin'];
|
const modules = ['contacts', 'crm', 'tenders', 'inventory', 'projects', 'hr', 'marketing', 'admin'];
|
||||||
for (const module of modules) {
|
for (const module of modules) {
|
||||||
await prisma.positionPermission.create({
|
await prisma.positionPermission.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -67,6 +67,8 @@ async function main() {
|
|||||||
data: [
|
data: [
|
||||||
{ positionId: salesRepPosition.id, module: 'contacts', resource: '*', actions: ['read', 'create', 'update'] },
|
{ 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: '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'] },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,18 @@ export const config = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
cors: {
|
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: {
|
upload: {
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ class AdminService {
|
|||||||
throw new AppError(400, 'هذا الموظف مرتبط بحساب مستخدم بالفعل - Employee already has a user account');
|
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 },
|
where: { email: data.email },
|
||||||
});
|
});
|
||||||
if (emailExists) {
|
if (emailExists) {
|
||||||
@@ -206,7 +206,7 @@ class AdminService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.email && data.email !== existing.email) {
|
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) {
|
if (emailExists) {
|
||||||
throw new AppError(400, 'البريد الإلكتروني مستخدم بالفعل - Email already in use');
|
throw new AppError(400, 'البريد الإلكتروني مستخدم بالفعل - Email already in use');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response } from 'express'
|
import { Request, Response } from 'express'
|
||||||
import { authService } from './auth.service'
|
import { authService } from './auth.service'
|
||||||
import { AuthRequest } from '@/shared/middleware/auth'
|
import { AuthRequest } from '../../shared/middleware/auth'
|
||||||
|
|
||||||
export const authController = {
|
export const authController = {
|
||||||
register: async (req: Request, res: Response) => {
|
register: async (req: Request, res: Response) => {
|
||||||
@@ -21,17 +21,27 @@ export const authController = {
|
|||||||
|
|
||||||
login: async (req: Request, res: Response) => {
|
login: async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const { email, password } = req.body
|
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({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'تم تسجيل الدخول بنجاح',
|
message: 'تم تسجيل الدخول بنجاح',
|
||||||
data: result
|
data: result
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
res.status(401).json({
|
res.status(error?.statusCode || 401).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: error.message
|
message: error.message || 'بيانات الدخول غير صحيحة'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ router.post(
|
|||||||
router.post(
|
router.post(
|
||||||
'/login',
|
'/login',
|
||||||
[
|
[
|
||||||
body('email').isEmail().withMessage('البريد الإلكتروني غير صالح'),
|
body('email').trim().notEmpty().withMessage('البريد الإلكتروني أو اسم المستخدم مطلوب'),
|
||||||
body('password').notEmpty().withMessage('كلمة المرور مطلوبة'),
|
body('password').notEmpty().withMessage('كلمة المرور مطلوبة'),
|
||||||
],
|
],
|
||||||
validate,
|
validate,
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs'
|
||||||
import jwt, { Secret, SignOptions } from 'jsonwebtoken';
|
import jwt, { Secret, SignOptions } from 'jsonwebtoken'
|
||||||
import prisma from '../../config/database';
|
import prisma from '../../config/database'
|
||||||
import { config } from '../../config';
|
import { config } from '../../config'
|
||||||
import { AppError } from '../../shared/middleware/errorHandler';
|
import { AppError } from '../../shared/middleware/errorHandler'
|
||||||
|
|
||||||
class AuthService {
|
class AuthService {
|
||||||
async register(data: {
|
async register(data: {
|
||||||
email: string;
|
email: string
|
||||||
username: string;
|
username: string
|
||||||
password: string;
|
password: string
|
||||||
employeeId?: string;
|
employeeId?: string
|
||||||
}) {
|
}) {
|
||||||
// Hash password
|
const hashedPassword = await bcrypt.hash(
|
||||||
const hashedPassword = await bcrypt.hash(data.password, config.security.bcryptRounds);
|
data.password,
|
||||||
|
config.security.bcryptRounds
|
||||||
|
)
|
||||||
|
|
||||||
// Create user
|
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email: data.email,
|
email: data.email,
|
||||||
username: data.username,
|
username: data.username,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
employeeId: data.employeeId,
|
employeeId: data.employeeId
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -28,120 +29,140 @@ class AuthService {
|
|||||||
username: true,
|
username: true,
|
||||||
employeeId: true,
|
employeeId: true,
|
||||||
isActive: 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({
|
await prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { refreshToken: tokens.refreshToken },
|
data: { refreshToken: tokens.refreshToken }
|
||||||
});
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
...tokens,
|
...tokens
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async login(email: string, password: string) {
|
|
||||||
// Find user with employee info and permissions
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { email },
|
|
||||||
include: {
|
|
||||||
employee: {
|
|
||||||
include: {
|
|
||||||
position: {
|
|
||||||
include: {
|
|
||||||
permissions: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
department: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
async login(emailOrUsername: string, password: string) {
|
||||||
throw new AppError(401, 'بيانات الدخول غير صحيحة - Invalid credentials');
|
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: { 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: {
|
||||||
|
employee: {
|
||||||
|
include: {
|
||||||
|
position: { include: { permissions: true } },
|
||||||
|
department: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(401, 'بيانات الدخول غير صحيحة - Invalid credentials')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is active
|
|
||||||
if (!user.isActive) {
|
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()) {
|
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) {
|
if (!isPasswordValid) {
|
||||||
// Increment failed login attempts
|
const failedAttempts = (user.failedLoginAttempts || 0) + 1
|
||||||
const failedAttempts = user.failedLoginAttempts + 1;
|
const updateData: any = { failedLoginAttempts: failedAttempts }
|
||||||
const updateData: any = { failedLoginAttempts: failedAttempts };
|
|
||||||
|
|
||||||
// Lock account after 5 failed attempts
|
|
||||||
if (failedAttempts >= 5) {
|
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({
|
await prisma.user.update({
|
||||||
where: { id: user.id },
|
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') {
|
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({
|
await prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: {
|
||||||
failedLoginAttempts: 0,
|
failedLoginAttempts: 0,
|
||||||
lockedUntil: null,
|
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({
|
await prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { refreshToken: tokens.refreshToken },
|
data: { refreshToken: tokens.refreshToken }
|
||||||
});
|
})
|
||||||
|
|
||||||
// Return user data without password, with role info
|
const { password: _pw, ...userWithoutPassword } = user
|
||||||
const { password: _, ...userWithoutPassword } = user;
|
|
||||||
|
|
||||||
// Format role and permissions
|
const role = user.employee?.position
|
||||||
const role = user.employee?.position ? {
|
? {
|
||||||
id: user.employee.position.id,
|
id: user.employee.position.id,
|
||||||
name: user.employee.position.titleAr || user.employee.position.title,
|
name: user.employee.position.titleAr || user.employee.position.title,
|
||||||
nameEn: user.employee.position.title,
|
nameEn: user.employee.position.title,
|
||||||
permissions: user.employee.position.permissions || []
|
permissions: user.employee.position.permissions || []
|
||||||
} : null;
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: {
|
user: {
|
||||||
...userWithoutPassword,
|
...userWithoutPassword,
|
||||||
role
|
role
|
||||||
},
|
},
|
||||||
...tokens,
|
...tokens
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserById(userId: string) {
|
async getUserById(userId: string) {
|
||||||
@@ -150,77 +171,57 @@ class AuthService {
|
|||||||
include: {
|
include: {
|
||||||
employee: {
|
employee: {
|
||||||
include: {
|
include: {
|
||||||
position: {
|
position: { include: { permissions: true } },
|
||||||
include: {
|
department: true
|
||||||
permissions: true,
|
}
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
department: true,
|
})
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) throw new AppError(404, 'المستخدم غير موجود - User not found')
|
||||||
throw new AppError(404, 'المستخدم غير موجود - User not found');
|
if (!user.isActive) throw new AppError(403, 'الحساب غير مفعل - Account is inactive')
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.isActive) {
|
const { password: _pw, ...userWithoutPassword } = user
|
||||||
throw new AppError(403, 'الحساب غير مفعل - Account is inactive');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format user data
|
const role = user.employee?.position
|
||||||
const { password: _, ...userWithoutPassword } = user;
|
? {
|
||||||
|
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
|
||||||
|
|
||||||
const role = user.employee?.position ? {
|
return { ...userWithoutPassword, role }
|
||||||
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;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...userWithoutPassword,
|
|
||||||
role
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshToken(refreshToken: string) {
|
async refreshToken(refreshToken: string) {
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(refreshToken, config.jwt.secret) as {
|
const decoded = jwt.verify(refreshToken, config.jwt.secret) as { id: string; email: string }
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Verify refresh token matches stored token
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { id: decoded.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: decoded.id } })
|
||||||
if (!user || user.refreshToken !== refreshToken || !user.isActive) {
|
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({
|
await prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { refreshToken: tokens.refreshToken },
|
data: { refreshToken: tokens.refreshToken }
|
||||||
});
|
})
|
||||||
|
|
||||||
return tokens;
|
return tokens
|
||||||
} catch (error) {
|
} catch {
|
||||||
throw new AppError(401, 'رمز غير صالح - Invalid token');
|
throw new AppError(401, 'رمز غير صالح - Invalid token')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout(userId: string) {
|
async logout(userId: string) {
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: { refreshToken: null },
|
data: { refreshToken: null }
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserProfile(userId: string) {
|
async getUserProfile(userId: string) {
|
||||||
@@ -234,47 +235,35 @@ class AuthService {
|
|||||||
lastLogin: true,
|
lastLogin: true,
|
||||||
employee: {
|
employee: {
|
||||||
include: {
|
include: {
|
||||||
position: {
|
position: { include: { permissions: true } },
|
||||||
include: {
|
department: true
|
||||||
permissions: true,
|
}
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
department: true,
|
})
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) throw new AppError(404, 'المستخدم غير موجود - User not found')
|
||||||
throw new AppError(404, 'المستخدم غير موجود - User not found');
|
return user
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateTokens(userId: string, email: string) {
|
private generateTokens(userId: string, email: string) {
|
||||||
const payload = { id: userId, email };
|
const payload = { id: userId, email }
|
||||||
const secret = config.jwt.secret as Secret;
|
const secret = config.jwt.secret as Secret
|
||||||
|
|
||||||
const accessToken = jwt.sign(
|
const accessToken = jwt.sign(payload, secret, {
|
||||||
payload,
|
expiresIn: config.jwt.expiresIn
|
||||||
secret,
|
} as SignOptions)
|
||||||
{ expiresIn: config.jwt.expiresIn } as SignOptions
|
|
||||||
);
|
|
||||||
|
|
||||||
const refreshToken = jwt.sign(
|
const refreshToken = jwt.sign(payload, secret, {
|
||||||
payload,
|
expiresIn: config.jwt.refreshExpiresIn
|
||||||
secret,
|
} as SignOptions)
|
||||||
{ expiresIn: config.jwt.refreshExpiresIn } as SignOptions
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
expiresIn: config.jwt.expiresIn,
|
expiresIn: config.jwt.expiresIn
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authService = new AuthService();
|
export const authService = new AuthService()
|
||||||
|
|
||||||
@@ -42,7 +42,8 @@ router.post(
|
|||||||
'/',
|
'/',
|
||||||
authorize('contacts', 'contacts', 'create'),
|
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('name').notEmpty().trim(),
|
||||||
body('email').optional().isEmail(),
|
body('email').optional().isEmail(),
|
||||||
body('source').notEmpty(),
|
body('source').notEmpty(),
|
||||||
@@ -57,7 +58,29 @@ router.put(
|
|||||||
authorize('contacts', 'contacts', 'update'),
|
authorize('contacts', 'contacts', 'update'),
|
||||||
[
|
[
|
||||||
param('id').isUUID(),
|
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,
|
validate,
|
||||||
],
|
],
|
||||||
contactsController.update
|
contactsController.update
|
||||||
|
|||||||
@@ -328,46 +328,50 @@ class ContactsService {
|
|||||||
|
|
||||||
// Update contact
|
// Update contact
|
||||||
const contact = await prisma.contact.update({
|
const contact = await prisma.contact.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
name: data.name,
|
type: data.type,
|
||||||
nameAr: data.nameAr,
|
name: data.name,
|
||||||
email: data.email,
|
nameAr: data.nameAr,
|
||||||
phone: data.phone,
|
email: data.email === '' || data.email === undefined ? null : data.email,
|
||||||
mobile: data.mobile,
|
phone: data.phone,
|
||||||
website: data.website,
|
mobile: data.mobile,
|
||||||
companyName: data.companyName,
|
website: data.website,
|
||||||
companyNameAr: data.companyNameAr,
|
companyName: data.companyName,
|
||||||
taxNumber: data.taxNumber,
|
companyNameAr: data.companyNameAr,
|
||||||
commercialRegister: data.commercialRegister,
|
taxNumber: data.taxNumber,
|
||||||
address: data.address,
|
commercialRegister: data.commercialRegister,
|
||||||
city: data.city,
|
address: data.address,
|
||||||
country: data.country,
|
city: data.city,
|
||||||
postalCode: data.postalCode,
|
country: data.country,
|
||||||
categories: data.categories ? {
|
postalCode: data.postalCode,
|
||||||
set: data.categories.map(id => ({ id }))
|
categories: data.categories
|
||||||
} : undefined,
|
? {
|
||||||
tags: data.tags,
|
set: data.categories.map((id) => ({ id })),
|
||||||
employeeId: data.employeeId !== undefined ? (data.employeeId || null) : undefined,
|
}
|
||||||
source: data.source,
|
: undefined,
|
||||||
status: data.status,
|
tags: data.tags,
|
||||||
rating: data.rating,
|
employeeId:
|
||||||
customFields: data.customFields,
|
data.employeeId !== undefined ? (data.employeeId || null) : undefined,
|
||||||
|
source: data.source,
|
||||||
|
status: data.status,
|
||||||
|
rating: data.rating,
|
||||||
|
customFields: data.customFields,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
categories: true,
|
||||||
|
parent: true,
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
},
|
},
|
||||||
include: {
|
},
|
||||||
categories: true,
|
},
|
||||||
parent: true,
|
});
|
||||||
employee: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
firstName: true,
|
|
||||||
lastName: true,
|
|
||||||
email: true,
|
|
||||||
uniqueEmployeeId: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log audit
|
// Log audit
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
@@ -679,7 +683,7 @@ class ContactsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate type
|
// 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({
|
results.errors.push({
|
||||||
row: rowNumber,
|
row: rowNumber,
|
||||||
field: 'type',
|
field: 'type',
|
||||||
|
|||||||
65
backend/src/modules/crm/contracts.controller.ts
Normal file
65
backend/src/modules/crm/contracts.controller.ts
Normal 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();
|
||||||
136
backend/src/modules/crm/contracts.service.ts
Normal file
136
backend/src/modules/crm/contracts.service.ts
Normal 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();
|
||||||
55
backend/src/modules/crm/costSheets.controller.ts
Normal file
55
backend/src/modules/crm/costSheets.controller.ts
Normal 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();
|
||||||
113
backend/src/modules/crm/costSheets.service.ts
Normal file
113
backend/src/modules/crm/costSheets.service.ts
Normal 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();
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { body, param } from 'express-validator';
|
import { body, param } from 'express-validator';
|
||||||
import { pipelinesController, dealsController, quotesController } from './crm.controller';
|
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 { authenticate, authorize } from '../../shared/middleware/auth';
|
||||||
import { validate } from '../../shared/middleware/validation';
|
import { validate } from '../../shared/middleware/validation';
|
||||||
|
|
||||||
@@ -171,5 +174,153 @@ router.post(
|
|||||||
quotesController.send
|
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;
|
export default router;
|
||||||
|
|
||||||
|
|||||||
61
backend/src/modules/crm/invoices.controller.ts
Normal file
61
backend/src/modules/crm/invoices.controller.ts
Normal 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();
|
||||||
130
backend/src/modules/crm/invoices.service.ts
Normal file
130
backend/src/modules/crm/invoices.service.ts
Normal 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();
|
||||||
@@ -8,7 +8,11 @@ class DashboardController {
|
|||||||
const userId = req.user!.id;
|
const userId = req.user!.id;
|
||||||
|
|
||||||
const [contactsCount, activeTasksCount, unreadNotificationsCount] = await Promise.all([
|
const [contactsCount, activeTasksCount, unreadNotificationsCount] = await Promise.all([
|
||||||
prisma.contact.count(),
|
prisma.contact.count({
|
||||||
|
where: {
|
||||||
|
archivedAt: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
prisma.task.count({
|
prisma.task.count({
|
||||||
where: {
|
where: {
|
||||||
status: { notIn: ['COMPLETED', 'CANCELLED'] },
|
status: { notIn: ['COMPLETED', 'CANCELLED'] },
|
||||||
|
|||||||
@@ -98,6 +98,16 @@ export class HRController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async bulkSyncAttendance(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { deviceId, records } = req.body;
|
||||||
|
const results = await hrService.bulkSyncAttendanceFromDevice(deviceId, records || [], req.user!.id);
|
||||||
|
res.json(ResponseFormatter.success(results, 'تم مزامنة الحضور - Attendance synced'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========== LEAVES ==========
|
// ========== LEAVES ==========
|
||||||
|
|
||||||
async createLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
async createLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
@@ -118,6 +128,29 @@ export class HRController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async rejectLeave(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { rejectedReason } = req.body;
|
||||||
|
const leave = await hrService.rejectLeave(req.params.id, rejectedReason || '', req.user!.id);
|
||||||
|
res.json(ResponseFormatter.success(leave, 'تم رفض طلب الإجازة - Leave rejected'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllLeaves(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
||||||
|
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20));
|
||||||
|
const employeeId = req.query.employeeId as string | undefined;
|
||||||
|
const status = req.query.status as string | undefined;
|
||||||
|
const result = await hrService.findAllLeaves({ employeeId, status }, page, pageSize);
|
||||||
|
res.json(ResponseFormatter.paginated(result.leaves, result.total, result.page, result.pageSize));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========== SALARIES ==========
|
// ========== SALARIES ==========
|
||||||
|
|
||||||
async processSalary(req: AuthRequest, res: Response, next: NextFunction) {
|
async processSalary(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
@@ -187,6 +220,198 @@ export class HRController {
|
|||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== LOANS ==========
|
||||||
|
|
||||||
|
async findAllLoans(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
||||||
|
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20));
|
||||||
|
const employeeId = req.query.employeeId as string | undefined;
|
||||||
|
const status = req.query.status as string | undefined;
|
||||||
|
const result = await hrService.findAllLoans({ employeeId, status }, page, pageSize);
|
||||||
|
res.json(ResponseFormatter.paginated(result.loans, result.total, result.page, result.pageSize));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findLoanById(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const loan = await hrService.findLoanById(req.params.id);
|
||||||
|
res.json(ResponseFormatter.success(loan));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLoan(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const loan = await hrService.createLoan(req.body, req.user!.id);
|
||||||
|
res.status(201).json(ResponseFormatter.success(loan, 'تم إنشاء طلب القرض - Loan request created'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async approveLoan(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { startDate } = req.body;
|
||||||
|
const loan = await hrService.approveLoan(req.params.id, req.user!.id, startDate ? new Date(startDate) : new Date(), req.user!.id);
|
||||||
|
res.json(ResponseFormatter.success(loan, 'تمت الموافقة على القرض - Loan approved'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectLoan(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { rejectedReason } = req.body;
|
||||||
|
const loan = await hrService.rejectLoan(req.params.id, rejectedReason || '', req.user!.id);
|
||||||
|
res.json(ResponseFormatter.success(loan, 'تم رفض القرض - Loan rejected'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordLoanInstallmentPayment(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { installmentId, paidDate } = req.body;
|
||||||
|
const loan = await hrService.recordLoanInstallmentPayment(req.params.id, installmentId, paidDate ? new Date(paidDate) : new Date(), req.user!.id);
|
||||||
|
res.json(ResponseFormatter.success(loan, 'تم تسجيل الدفعة - Payment recorded'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== PURCHASE REQUESTS ==========
|
||||||
|
|
||||||
|
async findAllPurchaseRequests(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
||||||
|
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20));
|
||||||
|
const employeeId = req.query.employeeId as string | undefined;
|
||||||
|
const status = req.query.status as string | undefined;
|
||||||
|
const result = await hrService.findAllPurchaseRequests({ employeeId, status }, page, pageSize);
|
||||||
|
res.json(ResponseFormatter.paginated(result.purchaseRequests, result.total, result.page, result.pageSize));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPurchaseRequestById(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const pr = await hrService.findPurchaseRequestById(req.params.id);
|
||||||
|
res.json(ResponseFormatter.success(pr));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const pr = await hrService.createPurchaseRequest(req.body, req.user!.id);
|
||||||
|
res.status(201).json(ResponseFormatter.success(pr, 'تم إنشاء طلب الشراء - Purchase request created'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async approvePurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const pr = await hrService.approvePurchaseRequest(req.params.id, req.user!.id, req.user!.id);
|
||||||
|
res.json(ResponseFormatter.success(pr, 'تمت الموافقة على طلب الشراء - Purchase request approved'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { rejectedReason } = req.body;
|
||||||
|
const pr = await hrService.rejectPurchaseRequest(req.params.id, rejectedReason || '', req.user!.id);
|
||||||
|
res.json(ResponseFormatter.success(pr, 'تم رفض طلب الشراء - Purchase request rejected'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LEAVE ENTITLEMENTS ==========
|
||||||
|
|
||||||
|
async getLeaveBalance(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const employeeId = req.params.employeeId || req.query.employeeId as string;
|
||||||
|
const year = parseInt(req.query.year as string) || new Date().getFullYear();
|
||||||
|
const balance = await hrService.getLeaveBalance(employeeId, year);
|
||||||
|
res.json(ResponseFormatter.success(balance));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllLeaveEntitlements(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const employeeId = req.query.employeeId as string | undefined;
|
||||||
|
const year = req.query.year ? parseInt(req.query.year as string) : undefined;
|
||||||
|
const list = await hrService.findAllLeaveEntitlements(employeeId, year);
|
||||||
|
res.json(ResponseFormatter.success(list));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertLeaveEntitlement(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const ent = await hrService.upsertLeaveEntitlement(req.body, req.user!.id);
|
||||||
|
res.json(ResponseFormatter.success(ent, 'تم حفظ رصيد الإجازة - Leave entitlement saved'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== EMPLOYEE CONTRACTS ==========
|
||||||
|
|
||||||
|
async findAllEmployeeContracts(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
||||||
|
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20));
|
||||||
|
const employeeId = req.query.employeeId as string | undefined;
|
||||||
|
const status = req.query.status as string | undefined;
|
||||||
|
const result = await hrService.findAllEmployeeContracts({ employeeId, status }, page, pageSize);
|
||||||
|
res.json(ResponseFormatter.paginated(result.contracts, result.total, result.page, result.pageSize));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findEmployeeContractById(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const c = await hrService.findEmployeeContractById(req.params.id);
|
||||||
|
res.json(ResponseFormatter.success(c));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createEmployeeContract(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const data = { ...req.body, startDate: new Date(req.body.startDate), endDate: req.body.endDate ? new Date(req.body.endDate) : undefined };
|
||||||
|
const c = await hrService.createEmployeeContract(data, req.user!.id);
|
||||||
|
res.status(201).json(ResponseFormatter.success(c, 'تم إنشاء العقد - Contract created'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateEmployeeContract(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const data = { ...req.body, endDate: req.body.endDate ? new Date(req.body.endDate) : undefined };
|
||||||
|
const c = await hrService.updateEmployeeContract(req.params.id, data, req.user!.id);
|
||||||
|
res.json(ResponseFormatter.success(c, 'تم تحديث العقد - Contract updated'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const hrController = new HRController();
|
export const hrController = new HRController();
|
||||||
|
|||||||
@@ -1,12 +1,64 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { body, param } from 'express-validator';
|
|
||||||
import { hrController } from './hr.controller';
|
import { hrController } from './hr.controller';
|
||||||
|
import { portalController } from './portal.controller';
|
||||||
import { authenticate, authorize } from '../../shared/middleware/auth';
|
import { authenticate, authorize } from '../../shared/middleware/auth';
|
||||||
import { validate } from '../../shared/middleware/validation';
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// ========== EMPLOYEE PORTAL (authenticate only, scoped by employeeId) ==========
|
||||||
|
|
||||||
|
router.get('/portal/me', portalController.getMe);
|
||||||
|
router.get('/portal/loans', portalController.getMyLoans);
|
||||||
|
router.post('/portal/loans', portalController.submitLoanRequest);
|
||||||
|
router.get('/portal/leave-balance', portalController.getMyLeaveBalance);
|
||||||
|
router.get('/portal/leaves', portalController.getMyLeaves);
|
||||||
|
router.post('/portal/leaves', portalController.submitLeaveRequest);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/portal/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 ==========
|
// ========== EMPLOYEES ==========
|
||||||
|
|
||||||
router.get('/employees', authorize('hr', 'employees', 'read'), hrController.findAllEmployees);
|
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.post('/attendance', authorize('hr', 'attendance', 'create'), hrController.recordAttendance);
|
||||||
router.get('/attendance/:employeeId', authorize('hr', 'attendance', 'read'), hrController.getAttendance);
|
router.get('/attendance/:employeeId', authorize('hr', 'attendance', 'read'), hrController.getAttendance);
|
||||||
|
router.post('/attendance/sync', authorize('hr', 'attendance', 'create'), hrController.bulkSyncAttendance);
|
||||||
|
|
||||||
// ========== LEAVES ==========
|
// ========== LEAVES ==========
|
||||||
|
|
||||||
|
router.get('/leaves', authorize('hr', 'leaves', 'read'), hrController.findAllLeaves);
|
||||||
router.post('/leaves', authorize('hr', 'leaves', 'create'), hrController.createLeaveRequest);
|
router.post('/leaves', authorize('hr', 'leaves', 'create'), hrController.createLeaveRequest);
|
||||||
router.post('/leaves/:id/approve', authorize('hr', 'leaves', 'approve'), hrController.approveLeave);
|
router.post('/leaves/:id/approve', authorize('hr', 'leaves', 'approve'), hrController.approveLeave);
|
||||||
|
router.post('/leaves/:id/reject', authorize('hr', 'leaves', 'approve'), hrController.rejectLeave);
|
||||||
|
|
||||||
// ========== SALARIES ==========
|
// ========== SALARIES ==========
|
||||||
|
|
||||||
@@ -41,5 +96,34 @@ router.delete('/departments/:id', authorize('hr', 'all', 'delete'), hrController
|
|||||||
|
|
||||||
router.get('/positions', authorize('hr', 'all', 'read'), hrController.findAllPositions);
|
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;
|
||||||
@@ -227,12 +227,74 @@ class HRService {
|
|||||||
|
|
||||||
async recordAttendance(data: any, userId: string) {
|
async recordAttendance(data: any, userId: string) {
|
||||||
const attendance = await prisma.attendance.create({
|
const attendance = await prisma.attendance.create({
|
||||||
data,
|
data: {
|
||||||
|
...data,
|
||||||
|
sourceDeviceId: data.sourceDeviceId ?? undefined,
|
||||||
|
externalId: data.externalId ?? undefined,
|
||||||
|
rawData: data.rawData ?? undefined,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return attendance;
|
return attendance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async bulkSyncAttendanceFromDevice(deviceId: string, records: Array<{ employeePin: string; checkIn?: string; checkOut?: string; date: string }>, userId: string) {
|
||||||
|
const results: { created: number; updated: number; skipped: number } = { created: 0, updated: 0, skipped: 0 };
|
||||||
|
for (const rec of records) {
|
||||||
|
const emp = await prisma.employee.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ attendancePin: rec.employeePin },
|
||||||
|
{ uniqueEmployeeId: rec.employeePin },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!emp) {
|
||||||
|
results.skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const date = new Date(rec.date);
|
||||||
|
const existing = await prisma.attendance.findUnique({
|
||||||
|
where: { employeeId_date: { employeeId: emp.id, date } },
|
||||||
|
});
|
||||||
|
const checkIn = rec.checkIn ? new Date(rec.checkIn) : null;
|
||||||
|
const checkOut = rec.checkOut ? new Date(rec.checkOut) : null;
|
||||||
|
const workHours = checkIn && checkOut ? (checkOut.getTime() - checkIn.getTime()) / (1000 * 60 * 60) : null;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await prisma.attendance.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
checkIn: checkIn ?? existing.checkIn,
|
||||||
|
checkOut: checkOut ?? existing.checkOut,
|
||||||
|
workHours: workHours ?? existing.workHours,
|
||||||
|
status: rec.checkIn ? 'PRESENT' : existing.status,
|
||||||
|
sourceDeviceId: deviceId,
|
||||||
|
externalId: `${deviceId}-${emp.id}-${rec.date}`,
|
||||||
|
rawData: rec as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
results.updated++;
|
||||||
|
} else {
|
||||||
|
await prisma.attendance.create({
|
||||||
|
data: {
|
||||||
|
employeeId: emp.id,
|
||||||
|
date,
|
||||||
|
checkIn,
|
||||||
|
checkOut,
|
||||||
|
workHours,
|
||||||
|
status: rec.checkIn ? 'PRESENT' : 'ABSENT',
|
||||||
|
sourceDeviceId: deviceId,
|
||||||
|
externalId: `${deviceId}-${emp.id}-${rec.date}`,
|
||||||
|
rawData: rec as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
results.created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await AuditLogger.log({ entityType: 'ATTENDANCE', entityId: deviceId, action: 'BULK_SYNC', userId, changes: results });
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
async getAttendance(employeeId: string, month: number, year: number) {
|
async getAttendance(employeeId: string, month: number, year: number) {
|
||||||
return prisma.attendance.findMany({
|
return prisma.attendance.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -251,26 +313,54 @@ class HRService {
|
|||||||
// ========== LEAVES ==========
|
// ========== LEAVES ==========
|
||||||
|
|
||||||
async createLeaveRequest(data: any, userId: string) {
|
async createLeaveRequest(data: any, userId: string) {
|
||||||
const leave = await prisma.leave.create({
|
const allowedLeaveTypes = ['ANNUAL', 'HOURLY'];
|
||||||
data: {
|
|
||||||
...data,
|
|
||||||
days: this.calculateLeaveDays(data.startDate, data.endDate),
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
employee: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await AuditLogger.log({
|
if (!allowedLeaveTypes.includes(String(data.leaveType || '').toUpperCase())) {
|
||||||
entityType: 'LEAVE',
|
throw new AppError(400, 'نوع الإجازة غير مدعوم - Only ANNUAL and HOURLY leave types are allowed');
|
||||||
entityId: leave.id,
|
|
||||||
action: 'CREATE',
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return leave;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedLeaveType = String(data.leaveType).toUpperCase();
|
||||||
|
const days = this.calculateLeaveDays(data.startDate, data.endDate);
|
||||||
|
const startDate = new Date(data.startDate);
|
||||||
|
const year = startDate.getFullYear();
|
||||||
|
|
||||||
|
const ent = await prisma.leaveEntitlement.findUnique({
|
||||||
|
where: {
|
||||||
|
employeeId_year_leaveType: {
|
||||||
|
employeeId: data.employeeId,
|
||||||
|
year,
|
||||||
|
leaveType: normalizedLeaveType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ent) {
|
||||||
|
const available = ent.totalDays + ent.carriedOver - ent.usedDays;
|
||||||
|
if (days > available) {
|
||||||
|
throw new AppError(400, `رصيد الإجازة غير كافٍ - Insufficient leave balance. Available: ${available}, Requested: ${days}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const leave = await prisma.leave.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
leaveType: normalizedLeaveType,
|
||||||
|
days,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
employee: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await AuditLogger.log({
|
||||||
|
entityType: 'LEAVE',
|
||||||
|
entityId: leave.id,
|
||||||
|
action: 'CREATE',
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return leave;
|
||||||
|
}
|
||||||
async approveLeave(id: string, approvedBy: string, userId: string) {
|
async approveLeave(id: string, approvedBy: string, userId: string) {
|
||||||
const leave = await prisma.leave.update({
|
const leave = await prisma.leave.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -278,12 +368,16 @@ class HRService {
|
|||||||
status: 'APPROVED',
|
status: 'APPROVED',
|
||||||
approvedBy,
|
approvedBy,
|
||||||
approvedAt: new Date(),
|
approvedAt: new Date(),
|
||||||
|
rejectedReason: null,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
employee: true,
|
employee: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const year = new Date(leave.startDate).getFullYear();
|
||||||
|
await this.updateLeaveEntitlementUsed(leave.employeeId, year, leave.leaveType, leave.days);
|
||||||
|
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
entityType: 'LEAVE',
|
entityType: 'LEAVE',
|
||||||
entityId: leave.id,
|
entityId: leave.id,
|
||||||
@@ -294,6 +388,251 @@ class HRService {
|
|||||||
return leave;
|
return leave;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async rejectLeave(id: string, rejectedReason: string, userId: string) {
|
||||||
|
const leave = await prisma.leave.findUnique({ where: { id } });
|
||||||
|
if (!leave) throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found');
|
||||||
|
|
||||||
|
const updated = await prisma.leave.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'REJECTED',
|
||||||
|
rejectedReason,
|
||||||
|
approvedBy: null,
|
||||||
|
approvedAt: null,
|
||||||
|
},
|
||||||
|
include: { employee: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await AuditLogger.log({
|
||||||
|
entityType: 'LEAVE',
|
||||||
|
entityId: id,
|
||||||
|
action: 'REJECT',
|
||||||
|
userId,
|
||||||
|
reason: rejectedReason,
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllLeaves(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) {
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
const where: any = {};
|
||||||
|
if (filters.employeeId) where.employeeId = filters.employeeId;
|
||||||
|
if (filters.status && filters.status !== 'all') where.status = filters.status;
|
||||||
|
|
||||||
|
const [total, leaves] = await Promise.all([
|
||||||
|
prisma.leave.count({ where }),
|
||||||
|
prisma.leave.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
include: { employee: { select: { id: true, firstName: true, lastName: true, uniqueEmployeeId: true } } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return { leaves, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private calculateLeaveHours(startDate: Date, endDate: Date) {
|
||||||
|
const diffMs = new Date(endDate).getTime() - new Date(startDate).getTime();
|
||||||
|
const diffHours = diffMs / (1000 * 60 * 60);
|
||||||
|
return diffHours > 0 ? diffHours : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async canManagerApproveLeave(leave: any) {
|
||||||
|
if (leave.leaveType === 'ANNUAL') {
|
||||||
|
const yearStart = new Date(new Date(leave.startDate).getFullYear(), 0, 1);
|
||||||
|
const yearEnd = new Date(new Date(leave.startDate).getFullYear(), 11, 31, 23, 59, 59, 999);
|
||||||
|
|
||||||
|
const approvedAnnualLeaves = await prisma.leave.findMany({
|
||||||
|
where: {
|
||||||
|
employeeId: leave.employeeId,
|
||||||
|
leaveType: 'ANNUAL',
|
||||||
|
status: 'APPROVED',
|
||||||
|
startDate: {
|
||||||
|
gte: yearStart,
|
||||||
|
lte: yearEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
days: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const usedDays = approvedAnnualLeaves.reduce((sum, item) => sum + Number(item.days || 0), 0);
|
||||||
|
return usedDays + Number(leave.days || 0) <= 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leave.leaveType === 'HOURLY') {
|
||||||
|
const start = new Date(leave.startDate);
|
||||||
|
const monthStart = new Date(start.getFullYear(), start.getMonth(), 1);
|
||||||
|
const monthEnd = new Date(start.getFullYear(), start.getMonth() + 1, 0, 23, 59, 59, 999);
|
||||||
|
|
||||||
|
const approvedHourlyLeaves = await prisma.leave.findMany({
|
||||||
|
where: {
|
||||||
|
employeeId: leave.employeeId,
|
||||||
|
leaveType: 'HOURLY',
|
||||||
|
status: 'APPROVED',
|
||||||
|
startDate: {
|
||||||
|
gte: monthStart,
|
||||||
|
lte: monthEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const usedHours = approvedHourlyLeaves.reduce((sum, item) => {
|
||||||
|
return sum + this.calculateLeaveHours(item.startDate, item.endDate);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const requestedHours = this.calculateLeaveHours(leave.startDate, leave.endDate);
|
||||||
|
return usedHours + requestedHours <= 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findManagedLeaves(status?: string) {
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (status && status !== 'all') {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.leave.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
reportingToId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async managerApproveLeave(id: string, userId: string) {
|
||||||
|
const leave = await prisma.leave.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
reportingToId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!leave) {
|
||||||
|
throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leave.status !== 'PENDING') {
|
||||||
|
throw new AppError(400, 'لا يمكن اعتماد طلب غير معلق - Only pending leave can be approved');
|
||||||
|
}
|
||||||
|
|
||||||
|
const canApprove = await this.canManagerApproveLeave(leave);
|
||||||
|
if (!canApprove) {
|
||||||
|
throw new AppError(403, 'الطلب يتجاوز صلاحية مدير القسم ويحتاج موافقة HR - This leave exceeds manager approval limits and requires HR approval');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.leave.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'APPROVED',
|
||||||
|
approvedBy: userId,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
rejectedReason: null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
employee: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const year = new Date(updated.startDate).getFullYear();
|
||||||
|
await this.updateLeaveEntitlementUsed(updated.employeeId, year, updated.leaveType, updated.days);
|
||||||
|
|
||||||
|
await AuditLogger.log({
|
||||||
|
entityType: 'LEAVE',
|
||||||
|
entityId: updated.id,
|
||||||
|
action: 'MANAGER_APPROVE',
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async managerRejectLeave(id: string, rejectedReason: string, userId: string) {
|
||||||
|
const leave = await prisma.leave.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
reportingToId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!leave) {
|
||||||
|
throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leave.status !== 'PENDING') {
|
||||||
|
throw new AppError(400, 'لا يمكن رفض طلب غير معلق - Only pending leave can be rejected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.leave.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'REJECTED',
|
||||||
|
rejectedReason,
|
||||||
|
approvedBy: null,
|
||||||
|
approvedAt: null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
employee: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await AuditLogger.log({
|
||||||
|
entityType: 'LEAVE',
|
||||||
|
entityId: updated.id,
|
||||||
|
action: 'MANAGER_REJECT',
|
||||||
|
userId,
|
||||||
|
reason: rejectedReason,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateLeaveEntitlementUsed(employeeId: string, year: number, leaveType: string, days: number) {
|
||||||
|
const ent = await prisma.leaveEntitlement.findUnique({
|
||||||
|
where: { employeeId_year_leaveType: { employeeId, year, leaveType } },
|
||||||
|
});
|
||||||
|
if (ent) {
|
||||||
|
await prisma.leaveEntitlement.update({
|
||||||
|
where: { employeeId_year_leaveType: { employeeId, year, leaveType } },
|
||||||
|
data: { usedDays: { increment: days } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========== SALARIES ==========
|
// ========== SALARIES ==========
|
||||||
|
|
||||||
async processSalary(employeeId: string, month: number, year: number, userId: string) {
|
async processSalary(employeeId: string, month: number, year: number, userId: string) {
|
||||||
@@ -380,6 +719,514 @@ class HRService {
|
|||||||
return salary;
|
return salary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== LOANS ==========
|
||||||
|
|
||||||
|
private async generateLoanNumber(): Promise<string> {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const prefix = `LN-${year}-`;
|
||||||
|
const last = await prisma.loan.findFirst({
|
||||||
|
where: { loanNumber: { startsWith: prefix } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: { loanNumber: true },
|
||||||
|
});
|
||||||
|
let next = 1;
|
||||||
|
if (last) {
|
||||||
|
const parts = last.loanNumber.split('-');
|
||||||
|
next = parseInt(parts[2] || '0') + 1;
|
||||||
|
}
|
||||||
|
return `${prefix}${next.toString().padStart(4, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllLoans(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) {
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
const where: any = {};
|
||||||
|
if (filters.employeeId) where.employeeId = filters.employeeId;
|
||||||
|
if (filters.status && filters.status !== 'all') where.status = filters.status;
|
||||||
|
|
||||||
|
const [total, loans] = await Promise.all([
|
||||||
|
prisma.loan.count({ where }),
|
||||||
|
prisma.loan.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
basicSalary: true,
|
||||||
|
position: {
|
||||||
|
select: {
|
||||||
|
code: true,
|
||||||
|
title: true,
|
||||||
|
titleAr: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
installmentsList: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { loans, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findLoanById(id: string) {
|
||||||
|
const loan = await prisma.loan.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
basicSalary: true,
|
||||||
|
position: {
|
||||||
|
select: {
|
||||||
|
code: true,
|
||||||
|
title: true,
|
||||||
|
titleAr: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
installmentsList: { orderBy: { installmentNumber: 'asc' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loan) throw new AppError(404, 'القرض غير موجود - Loan not found');
|
||||||
|
return loan;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSystemAdminUser(user: any) {
|
||||||
|
const positionCode = user?.employee?.position?.code?.toUpperCase?.() || '';
|
||||||
|
const positionTitle = user?.employee?.position?.title?.toUpperCase?.() || '';
|
||||||
|
const positionTitleAr = user?.employee?.position?.titleAr || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
positionCode === 'SYS_ADMIN' ||
|
||||||
|
positionCode === 'SYSTEM_ADMIN' ||
|
||||||
|
positionTitle === 'SYSTEM ADMINISTRATOR' ||
|
||||||
|
positionTitleAr === 'مدير النظام'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLoan(data: { employeeId: string; type: string; amount: number; installments?: number; reason?: string }, userId: string) {
|
||||||
|
if (!data.reason || !data.reason.trim()) {
|
||||||
|
throw new AppError(400, 'سبب القرض مطلوب - Loan reason is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.amount || Number(data.amount) <= 0) {
|
||||||
|
throw new AppError(400, 'مبلغ القرض غير صالح - Invalid loan amount');
|
||||||
|
}
|
||||||
|
|
||||||
|
const loanNumber = await this.generateLoanNumber();
|
||||||
|
const installments = data.installments || 1;
|
||||||
|
const monthlyAmount = data.amount / installments;
|
||||||
|
|
||||||
|
const loan = await prisma.loan.create({
|
||||||
|
data: {
|
||||||
|
loanNumber,
|
||||||
|
employeeId: data.employeeId,
|
||||||
|
type: data.type,
|
||||||
|
amount: data.amount,
|
||||||
|
installments,
|
||||||
|
monthlyAmount,
|
||||||
|
reason: data.reason.trim(),
|
||||||
|
status: 'PENDING_HR',
|
||||||
|
},
|
||||||
|
include: { employee: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId });
|
||||||
|
return loan;
|
||||||
|
}
|
||||||
|
|
||||||
|
async approveLoan(id: string, approvedBy: string, startDate: Date, userId: string) {
|
||||||
|
const loan = await prisma.loan.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
installmentsList: true,
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
basicSalary: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loan) {
|
||||||
|
throw new AppError(404, 'القرض غير موجود - Loan not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['PENDING_HR', 'PENDING_ADMIN'].includes(loan.status)) {
|
||||||
|
throw new AppError(400, 'لا يمكن الموافقة على هذا القرض بهذه الحالة - Cannot approve this loan in current status');
|
||||||
|
}
|
||||||
|
|
||||||
|
const approverUser = await prisma.user.findUnique({
|
||||||
|
where: { id: approvedBy },
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
include: {
|
||||||
|
position: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!approverUser) {
|
||||||
|
throw new AppError(404, 'المستخدم غير موجود - User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSystemAdmin = this.isSystemAdminUser(approverUser);
|
||||||
|
const basicSalary = Number(loan.employee?.basicSalary || 0);
|
||||||
|
const loanAmount = Number(loan.amount || 0);
|
||||||
|
const needsAdminApproval = basicSalary > 0 && loanAmount > basicSalary * 0.5;
|
||||||
|
|
||||||
|
// المرحلة الأولى: HR approval
|
||||||
|
if (loan.status === 'PENDING_HR') {
|
||||||
|
if (needsAdminApproval) {
|
||||||
|
const updatedLoan = await prisma.loan.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'PENDING_ADMIN',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await AuditLogger.log({
|
||||||
|
entityType: 'LOAN',
|
||||||
|
entityId: id,
|
||||||
|
action: 'HR_APPROVE_FORWARD_TO_ADMIN',
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedLoan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// المرحلة الثانية: Admin approval إذا تجاوز 50%
|
||||||
|
if (loan.status === 'PENDING_ADMIN' && !isSystemAdmin) {
|
||||||
|
throw new AppError(403, 'هذا الطلب يحتاج موافقة مدير النظام - System Administrator approval required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthlyAmount = Number(loan.monthlyAmount || loan.amount) / loan.installments;
|
||||||
|
const installments: { installmentNumber: number; dueDate: Date; amount: number }[] = [];
|
||||||
|
let d = new Date(startDate);
|
||||||
|
|
||||||
|
for (let i = 1; i <= loan.installments; i++) {
|
||||||
|
installments.push({
|
||||||
|
installmentNumber: i,
|
||||||
|
dueDate: new Date(d),
|
||||||
|
amount: monthlyAmount,
|
||||||
|
});
|
||||||
|
d.setMonth(d.getMonth() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const endDate = installments.length ? installments[installments.length - 1].dueDate : null;
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.loan.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'ACTIVE',
|
||||||
|
approvedBy,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...installments.map((inst) =>
|
||||||
|
prisma.loanInstallment.create({
|
||||||
|
data: {
|
||||||
|
loanId: id,
|
||||||
|
installmentNumber: inst.installmentNumber,
|
||||||
|
dueDate: inst.dueDate,
|
||||||
|
amount: inst.amount,
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await AuditLogger.log({
|
||||||
|
entityType: 'LOAN',
|
||||||
|
entityId: id,
|
||||||
|
action: loan.status === 'PENDING_ADMIN' ? 'ADMIN_APPROVE' : 'APPROVE',
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.findLoanById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectLoan(id: string, rejectedReason: string, userId: string) {
|
||||||
|
const existing = await prisma.loan.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new AppError(404, 'القرض غير موجود - Loan not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['PENDING_HR', 'PENDING_ADMIN'].includes(existing.status)) {
|
||||||
|
throw new AppError(400, 'لا يمكن رفض هذا القرض بهذه الحالة - Cannot reject this loan in current status');
|
||||||
|
}
|
||||||
|
|
||||||
|
const loan = await prisma.loan.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'REJECTED', rejectedReason },
|
||||||
|
include: { employee: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await AuditLogger.log({
|
||||||
|
entityType: 'LOAN',
|
||||||
|
entityId: id,
|
||||||
|
action: 'REJECT',
|
||||||
|
userId,
|
||||||
|
reason: rejectedReason,
|
||||||
|
});
|
||||||
|
|
||||||
|
return loan;
|
||||||
|
}
|
||||||
|
async recordLoanInstallmentPayment(loanId: string, installmentId: string, paidDate: Date, userId: string) {
|
||||||
|
await prisma.loanInstallment.update({
|
||||||
|
where: { id: installmentId },
|
||||||
|
data: { status: 'PAID', paidDate },
|
||||||
|
});
|
||||||
|
const allPaid = (await prisma.loanInstallment.count({ where: { loanId, status: 'PENDING' } })) === 0;
|
||||||
|
if (allPaid) {
|
||||||
|
await prisma.loan.update({ where: { id: loanId }, data: { status: 'PAID_OFF' } });
|
||||||
|
}
|
||||||
|
await AuditLogger.log({ entityType: 'LOAN_INSTALLMENT', entityId: installmentId, action: 'PAY', userId });
|
||||||
|
return this.findLoanById(loanId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== PURCHASE REQUESTS ==========
|
||||||
|
|
||||||
|
private async generatePurchaseRequestNumber(): Promise<string> {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const prefix = `PR-${year}-`;
|
||||||
|
const last = await prisma.purchaseRequest.findFirst({
|
||||||
|
where: { requestNumber: { startsWith: prefix } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: { requestNumber: true },
|
||||||
|
});
|
||||||
|
let next = 1;
|
||||||
|
if (last) {
|
||||||
|
const parts = last.requestNumber.split('-');
|
||||||
|
next = parseInt(parts[2] || '0') + 1;
|
||||||
|
}
|
||||||
|
return `${prefix}${next.toString().padStart(4, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllPurchaseRequests(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) {
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
const where: any = {};
|
||||||
|
if (filters.employeeId) where.employeeId = filters.employeeId;
|
||||||
|
if (filters.status && filters.status !== 'all') where.status = filters.status;
|
||||||
|
const [total, requests] = await Promise.all([
|
||||||
|
prisma.purchaseRequest.count({ where }),
|
||||||
|
prisma.purchaseRequest.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
include: { employee: { select: { id: true, firstName: true, lastName: true, uniqueEmployeeId: true } } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return { purchaseRequests: requests, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPurchaseRequestById(id: string) {
|
||||||
|
const req = await prisma.purchaseRequest.findUnique({ where: { id }, include: { employee: true } });
|
||||||
|
if (!req) throw new AppError(404, 'طلب الشراء غير موجود - Purchase request not found');
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPurchaseRequest(data: { employeeId: string; items: any[]; reason?: string; priority?: string }, userId: string) {
|
||||||
|
const requestNumber = await this.generatePurchaseRequestNumber();
|
||||||
|
const totalAmount = Array.isArray(data.items)
|
||||||
|
? data.items.reduce((s: number, i: any) => s + (Number(i.estimatedPrice || 0) * Number(i.quantity || 1)), 0)
|
||||||
|
: 0;
|
||||||
|
const req = await prisma.purchaseRequest.create({
|
||||||
|
data: {
|
||||||
|
requestNumber,
|
||||||
|
employeeId: data.employeeId,
|
||||||
|
items: data.items,
|
||||||
|
totalAmount,
|
||||||
|
reason: data.reason,
|
||||||
|
priority: data.priority || 'NORMAL',
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
include: { employee: true },
|
||||||
|
});
|
||||||
|
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: req.id, action: 'CREATE', userId });
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
async approvePurchaseRequest(id: string, approvedBy: string, userId: string) {
|
||||||
|
const req = await prisma.purchaseRequest.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'APPROVED', approvedBy, approvedAt: new Date(), rejectedReason: null },
|
||||||
|
include: { employee: true },
|
||||||
|
});
|
||||||
|
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'APPROVE', userId });
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectPurchaseRequest(id: string, rejectedReason: string, userId: string) {
|
||||||
|
const req = await prisma.purchaseRequest.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'REJECTED', rejectedReason },
|
||||||
|
include: { employee: true },
|
||||||
|
});
|
||||||
|
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'REJECT', userId, reason: rejectedReason });
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LEAVE ENTITLEMENTS ==========
|
||||||
|
|
||||||
|
async getLeaveBalance(employeeId: string, year: number) {
|
||||||
|
const entitlements = await prisma.leaveEntitlement.findMany({
|
||||||
|
where: { employeeId, year },
|
||||||
|
});
|
||||||
|
const approvedLeaves = await prisma.leave.findMany({
|
||||||
|
where: { employeeId, status: 'APPROVED', startDate: { gte: new Date(year, 0, 1) }, endDate: { lte: new Date(year, 11, 31) } },
|
||||||
|
});
|
||||||
|
const usedByType: Record<string, number> = {};
|
||||||
|
for (const l of approvedLeaves) {
|
||||||
|
usedByType[l.leaveType] = (usedByType[l.leaveType] || 0) + l.days;
|
||||||
|
}
|
||||||
|
return entitlements.map((e) => ({
|
||||||
|
leaveType: e.leaveType,
|
||||||
|
totalDays: e.totalDays,
|
||||||
|
carriedOver: e.carriedOver,
|
||||||
|
usedDays: usedByType[e.leaveType] ?? e.usedDays,
|
||||||
|
available: e.totalDays + e.carriedOver - (usedByType[e.leaveType] ?? e.usedDays),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllLeaveEntitlements(employeeId?: string, year?: number) {
|
||||||
|
const where: any = {};
|
||||||
|
if (employeeId) where.employeeId = employeeId;
|
||||||
|
if (year) where.year = year;
|
||||||
|
return prisma.leaveEntitlement.findMany({
|
||||||
|
where,
|
||||||
|
include: { employee: { select: { id: true, firstName: true, lastName: true } } },
|
||||||
|
orderBy: [{ employeeId: 'asc' }, { year: 'desc' }, { leaveType: 'asc' } ],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertLeaveEntitlement(data: { employeeId: string; year: number; leaveType: string; totalDays: number; carriedOver?: number; notes?: string }, userId: string) {
|
||||||
|
const allowedLeaveTypes = ['ANNUAL', 'HOURLY'];
|
||||||
|
const normalizedLeaveType = String(data.leaveType || '').toUpperCase();
|
||||||
|
|
||||||
|
if (!allowedLeaveTypes.includes(normalizedLeaveType)) {
|
||||||
|
throw new AppError(400, 'نوع رصيد الإجازة غير مدعوم - Only ANNUAL and HOURLY leave entitlement types are allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ent = await prisma.leaveEntitlement.upsert({
|
||||||
|
where: {
|
||||||
|
employeeId_year_leaveType: {
|
||||||
|
employeeId: data.employeeId,
|
||||||
|
year: data.year,
|
||||||
|
leaveType: normalizedLeaveType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
employeeId: data.employeeId,
|
||||||
|
year: data.year,
|
||||||
|
leaveType: normalizedLeaveType,
|
||||||
|
totalDays: data.totalDays,
|
||||||
|
carriedOver: data.carriedOver || 0,
|
||||||
|
notes: data.notes,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
totalDays: data.totalDays,
|
||||||
|
carriedOver: data.carriedOver ?? undefined,
|
||||||
|
notes: data.notes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await AuditLogger.log({ entityType: 'LEAVE_ENTITLEMENT', entityId: ent.id, action: 'UPSERT', userId });
|
||||||
|
return ent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== EMPLOYEE CONTRACTS ==========
|
||||||
|
|
||||||
|
private async generateContractNumber(): Promise<string> {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const prefix = `ECT-${year}-`;
|
||||||
|
const last = await prisma.employeeContract.findFirst({
|
||||||
|
where: { contractNumber: { startsWith: prefix } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: { contractNumber: true },
|
||||||
|
});
|
||||||
|
let next = 1;
|
||||||
|
if (last) {
|
||||||
|
const parts = last.contractNumber.split('-');
|
||||||
|
next = parseInt(parts[2] || '0') + 1;
|
||||||
|
}
|
||||||
|
return `${prefix}${next.toString().padStart(4, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllEmployeeContracts(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) {
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
const where: any = {};
|
||||||
|
if (filters.employeeId) where.employeeId = filters.employeeId;
|
||||||
|
if (filters.status && filters.status !== 'all') where.status = filters.status;
|
||||||
|
const [total, contracts] = await Promise.all([
|
||||||
|
prisma.employeeContract.count({ where }),
|
||||||
|
prisma.employeeContract.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
include: { employee: { select: { id: true, firstName: true, lastName: true, uniqueEmployeeId: true } } },
|
||||||
|
orderBy: { startDate: 'desc' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return { contracts, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findEmployeeContractById(id: string) {
|
||||||
|
const c = await prisma.employeeContract.findUnique({ where: { id }, include: { employee: true } });
|
||||||
|
if (!c) throw new AppError(404, 'العقد غير موجود - Contract not found');
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createEmployeeContract(data: { employeeId: string; type: string; startDate: Date; endDate?: Date; salary: number; documentUrl?: string; notes?: string }, userId: string) {
|
||||||
|
const contractNumber = await this.generateContractNumber();
|
||||||
|
const contract = await prisma.employeeContract.create({
|
||||||
|
data: {
|
||||||
|
contractNumber,
|
||||||
|
employeeId: data.employeeId,
|
||||||
|
type: data.type,
|
||||||
|
startDate: data.startDate,
|
||||||
|
endDate: data.endDate,
|
||||||
|
salary: data.salary,
|
||||||
|
documentUrl: data.documentUrl,
|
||||||
|
notes: data.notes,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
include: { employee: true },
|
||||||
|
});
|
||||||
|
await AuditLogger.log({ entityType: 'EMPLOYEE_CONTRACT', entityId: contract.id, action: 'CREATE', userId });
|
||||||
|
return contract;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateEmployeeContract(id: string, data: { type?: string; endDate?: Date; salary?: number; documentUrl?: string; status?: string; notes?: string }, userId: string) {
|
||||||
|
const contract = await prisma.employeeContract.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
include: { employee: true },
|
||||||
|
});
|
||||||
|
await AuditLogger.log({ entityType: 'EMPLOYEE_CONTRACT', entityId: id, action: 'UPDATE', userId });
|
||||||
|
return contract;
|
||||||
|
}
|
||||||
|
|
||||||
// ========== HELPERS ==========
|
// ========== HELPERS ==========
|
||||||
|
|
||||||
private async generateEmployeeId(): Promise<string> {
|
private async generateEmployeeId(): Promise<string> {
|
||||||
|
|||||||
200
backend/src/modules/hr/portal.controller.ts
Normal file
200
backend/src/modules/hr/portal.controller.ts
Normal 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();
|
||||||
466
backend/src/modules/hr/portal.service.ts
Normal file
466
backend/src/modules/hr/portal.service.ts
Normal 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();
|
||||||
265
backend/src/modules/tenders/tenders.controller.ts
Normal file
265
backend/src/modules/tenders/tenders.controller.ts
Normal 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();
|
||||||
|
|
||||||
|
|
||||||
195
backend/src/modules/tenders/tenders.routes.ts
Normal file
195
backend/src/modules/tenders/tenders.routes.ts
Normal 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
|
||||||
|
)
|
||||||
740
backend/src/modules/tenders/tenders.service.ts
Normal file
740
backend/src/modules/tenders/tenders.service.ts
Normal 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();
|
||||||
@@ -8,6 +8,7 @@ import hrRoutes from '../modules/hr/hr.routes';
|
|||||||
import inventoryRoutes from '../modules/inventory/inventory.routes';
|
import inventoryRoutes from '../modules/inventory/inventory.routes';
|
||||||
import projectsRoutes from '../modules/projects/projects.routes';
|
import projectsRoutes from '../modules/projects/projects.routes';
|
||||||
import marketingRoutes from '../modules/marketing/marketing.routes';
|
import marketingRoutes from '../modules/marketing/marketing.routes';
|
||||||
|
import tendersRoutes from '../modules/tenders/tenders.routes';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ router.use('/hr', hrRoutes);
|
|||||||
router.use('/inventory', inventoryRoutes);
|
router.use('/inventory', inventoryRoutes);
|
||||||
router.use('/projects', projectsRoutes);
|
router.use('/projects', projectsRoutes);
|
||||||
router.use('/marketing', marketingRoutes);
|
router.use('/marketing', marketingRoutes);
|
||||||
|
router.use('/tenders', tendersRoutes);
|
||||||
|
|
||||||
// API info
|
// API info
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
@@ -36,6 +38,7 @@ router.get('/', (req, res) => {
|
|||||||
'Inventory & Assets',
|
'Inventory & Assets',
|
||||||
'Tasks & Projects',
|
'Tasks & Projects',
|
||||||
'Marketing',
|
'Marketing',
|
||||||
|
'Tender Management',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ services:
|
|||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: zerp_backend
|
container_name: zerp_backend
|
||||||
restart: unless-stopped
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
PORT: 5001
|
PORT: 5001
|
||||||
NODE_ENV: production
|
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_SECRET: ${JWT_SECRET:-z-crm-jwt-secret-change-in-production-NOW}
|
||||||
JWT_EXPIRES_IN: 7d
|
JWT_EXPIRES_IN: 7d
|
||||||
JWT_REFRESH_EXPIRES_IN: 30d
|
JWT_REFRESH_EXPIRES_IN: 30d
|
||||||
@@ -39,6 +41,12 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "5001:5001"
|
- "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"
|
command: sh -c "npx prisma migrate deploy && node dist/server.js"
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
|
|||||||
259
docs/SRS_TENDER_MANAGEMENT.md
Normal file
259
docs/SRS_TENDER_MANAGEMENT.md
Normal 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 module’s 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 Module’s 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). |
|
||||||
@@ -37,10 +37,10 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
RUN addgroup --system --gid 1001 nodejs && \
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
adduser --system --uid 1001 nextjs
|
adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
# Copy necessary files
|
# Standalone first, then static assets; public last so it is not overwritten by any nested folder in standalone.
|
||||||
COPY --from=builder /app/public ./public
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/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/.next/static ./.next/static
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
|
|||||||
903
frontend/package-lock.json
generated
903
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@swc/helpers": "^0.5.21",
|
||||||
"@tanstack/react-query": "^5.17.9",
|
"@tanstack/react-query": "^5.17.9",
|
||||||
"axios": "^1.6.5",
|
"axios": "^1.6.5",
|
||||||
"date-fns": "^3.0.6",
|
"date-fns": "^3.0.6",
|
||||||
|
|||||||
BIN
frontend/public/favicon.ico
Normal file
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
BIN
frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
@@ -10,9 +10,13 @@ import LoadingSpinner from '@/components/LoadingSpinner';
|
|||||||
const MODULES = [
|
const MODULES = [
|
||||||
{ id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' },
|
{ id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' },
|
||||||
{ id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' },
|
{ id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' },
|
||||||
|
{ id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' },
|
||||||
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
|
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
|
||||||
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
|
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
|
||||||
{ id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' },
|
{ 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: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
|
||||||
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
|
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -11,9 +11,13 @@ import LoadingSpinner from '@/components/LoadingSpinner';
|
|||||||
const MODULES = [
|
const MODULES = [
|
||||||
{ id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' },
|
{ id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' },
|
||||||
{ id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' },
|
{ id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' },
|
||||||
|
{ id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' },
|
||||||
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
|
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
|
||||||
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
|
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
|
||||||
{ id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' },
|
{ 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: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
|
||||||
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
|
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -98,7 +98,15 @@ function ContactDetailContent() {
|
|||||||
INDIVIDUAL: 'bg-blue-100 text-blue-700',
|
INDIVIDUAL: 'bg-blue-100 text-blue-700',
|
||||||
COMPANY: 'bg-green-100 text-green-700',
|
COMPANY: 'bg-green-100 text-green-700',
|
||||||
HOLDING: 'bg-purple-100 text-purple-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'
|
return colors[type] || 'bg-gray-100 text-gray-700'
|
||||||
}
|
}
|
||||||
@@ -108,7 +116,15 @@ function ContactDetailContent() {
|
|||||||
INDIVIDUAL: 'فرد - Individual',
|
INDIVIDUAL: 'فرد - Individual',
|
||||||
COMPANY: 'شركة - Company',
|
COMPANY: 'شركة - Company',
|
||||||
HOLDING: 'مجموعة - Holding',
|
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
|
return labels[type] || type
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,20 +41,17 @@ function flattenCategories(cats: Category[], result: Category[] = []): Category[
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ContactsContent() {
|
function ContactsContent() {
|
||||||
// State Management
|
|
||||||
const [contacts, setContacts] = useState<Contact[]>([])
|
const [contacts, setContacts] = useState<Contact[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [selectedContacts, setSelectedContacts] = useState<Set<string>>(new Set())
|
const [selectedContacts, setSelectedContacts] = useState<Set<string>>(new Set())
|
||||||
const [showBulkActions, setShowBulkActions] = useState(false)
|
const [showBulkActions, setShowBulkActions] = useState(false)
|
||||||
|
|
||||||
// Pagination
|
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
const [totalPages, setTotalPages] = useState(1)
|
const [totalPages, setTotalPages] = useState(1)
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const pageSize = 10
|
const pageSize = 10
|
||||||
|
|
||||||
// Filters
|
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const [selectedType, setSelectedType] = useState('all')
|
const [selectedType, setSelectedType] = useState('all')
|
||||||
const [selectedStatus, setSelectedStatus] = useState('all')
|
const [selectedStatus, setSelectedStatus] = useState('all')
|
||||||
@@ -64,7 +61,6 @@ function ContactsContent() {
|
|||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
|
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
|
||||||
|
|
||||||
// Modals
|
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
const [showEditModal, setShowEditModal] = useState(false)
|
const [showEditModal, setShowEditModal] = useState(false)
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
@@ -75,7 +71,6 @@ function ContactsContent() {
|
|||||||
const [exporting, setExporting] = useState(false)
|
const [exporting, setExporting] = useState(false)
|
||||||
const [exportExcludeCompanyEmployees, setExportExcludeCompanyEmployees] = useState(false)
|
const [exportExcludeCompanyEmployees, setExportExcludeCompanyEmployees] = useState(false)
|
||||||
|
|
||||||
// Fetch Contacts (with debouncing for search)
|
|
||||||
const fetchContacts = useCallback(async () => {
|
const fetchContacts = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -104,21 +99,18 @@ function ContactsContent() {
|
|||||||
}
|
}
|
||||||
}, [currentPage, searchTerm, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory])
|
}, [currentPage, searchTerm, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory])
|
||||||
|
|
||||||
// Debounced search
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const debounce = setTimeout(() => {
|
const debounce = setTimeout(() => {
|
||||||
setCurrentPage(1) // Reset to page 1 on new search
|
setCurrentPage(1)
|
||||||
fetchContacts()
|
fetchContacts()
|
||||||
}, 500)
|
}, 500)
|
||||||
return () => clearTimeout(debounce)
|
return () => clearTimeout(debounce)
|
||||||
}, [searchTerm])
|
}, [searchTerm])
|
||||||
|
|
||||||
// Fetch on filter/page change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchContacts()
|
fetchContacts()
|
||||||
}, [currentPage, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory])
|
}, [currentPage, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory])
|
||||||
|
|
||||||
// Create Contact
|
|
||||||
const handleCreate = async (data: CreateContactData) => {
|
const handleCreate = async (data: CreateContactData) => {
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
@@ -136,7 +128,6 @@ function ContactsContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit Contact
|
|
||||||
const handleEdit = async (data: UpdateContactData) => {
|
const handleEdit = async (data: UpdateContactData) => {
|
||||||
if (!selectedContact) return
|
if (!selectedContact) return
|
||||||
|
|
||||||
@@ -156,7 +147,6 @@ function ContactsContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete Contact
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!selectedContact) return
|
if (!selectedContact) return
|
||||||
|
|
||||||
@@ -175,7 +165,6 @@ function ContactsContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility Functions
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setSelectedContact(null)
|
setSelectedContact(null)
|
||||||
}
|
}
|
||||||
@@ -195,7 +184,15 @@ function ContactsContent() {
|
|||||||
INDIVIDUAL: 'bg-blue-100 text-blue-700',
|
INDIVIDUAL: 'bg-blue-100 text-blue-700',
|
||||||
COMPANY: 'bg-green-100 text-green-700',
|
COMPANY: 'bg-green-100 text-green-700',
|
||||||
HOLDING: 'bg-purple-100 text-purple-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'
|
return colors[type] || 'bg-gray-100 text-gray-700'
|
||||||
}
|
}
|
||||||
@@ -209,19 +206,53 @@ function ContactsContent() {
|
|||||||
INDIVIDUAL: 'فرد',
|
INDIVIDUAL: 'فرد',
|
||||||
COMPANY: 'شركة',
|
COMPANY: 'شركة',
|
||||||
HOLDING: 'مجموعة',
|
HOLDING: 'مجموعة',
|
||||||
GOVERNMENT: 'حكومي'
|
GOVERNMENT: 'حكومي',
|
||||||
|
ORGANIZATION: 'منظمات',
|
||||||
|
EMBASSIES: 'سفارات',
|
||||||
|
BANK: 'بنوك',
|
||||||
|
UNIVERSITY: 'جامعات',
|
||||||
|
SCHOOL: 'مدارس',
|
||||||
|
UN: 'UN',
|
||||||
|
NGO: 'NGO',
|
||||||
|
INSTITUTION: 'مؤسسة'
|
||||||
}
|
}
|
||||||
return labels[type] || type
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Header */}
|
|
||||||
<header className="bg-white shadow-sm border-b">
|
<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="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 justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
@@ -289,7 +320,6 @@ function ContactsContent() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<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="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="bg-white rounded-xl shadow-sm p-6 border border-gray-200">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -344,12 +374,9 @@ function ContactsContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters and Search */}
|
|
||||||
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200 mb-6">
|
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200 mb-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Main Filters Row */}
|
|
||||||
<div className="flex flex-col md:flex-row gap-4">
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
{/* Search */}
|
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<Search className="absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
<Search className="absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
@@ -361,7 +388,6 @@ function ContactsContent() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Type Filter */}
|
|
||||||
<select
|
<select
|
||||||
value={selectedType}
|
value={selectedType}
|
||||||
onChange={(e) => setSelectedType(e.target.value)}
|
onChange={(e) => setSelectedType(e.target.value)}
|
||||||
@@ -372,9 +398,16 @@ function ContactsContent() {
|
|||||||
<option value="COMPANY">Companies</option>
|
<option value="COMPANY">Companies</option>
|
||||||
<option value="HOLDING">Holdings</option>
|
<option value="HOLDING">Holdings</option>
|
||||||
<option value="GOVERNMENT">Government</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>
|
</select>
|
||||||
|
|
||||||
{/* Status Filter */}
|
|
||||||
<select
|
<select
|
||||||
value={selectedStatus}
|
value={selectedStatus}
|
||||||
onChange={(e) => setSelectedStatus(e.target.value)}
|
onChange={(e) => setSelectedStatus(e.target.value)}
|
||||||
@@ -385,7 +418,6 @@ function ContactsContent() {
|
|||||||
<option value="INACTIVE">Inactive</option>
|
<option value="INACTIVE">Inactive</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{/* Advanced Filters Toggle */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
|
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
|
||||||
className={`flex items-center gap-2 px-4 py-3 border rounded-lg transition-colors ${
|
className={`flex items-center gap-2 px-4 py-3 border rounded-lg transition-colors ${
|
||||||
@@ -399,11 +431,9 @@ function ContactsContent() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Advanced Filters */}
|
|
||||||
{showAdvancedFilters && (
|
{showAdvancedFilters && (
|
||||||
<div className="pt-4 border-t border-gray-200">
|
<div className="pt-4 border-t border-gray-200">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
{/* Source Filter */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Source</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">Source</label>
|
||||||
<select
|
<select
|
||||||
@@ -423,7 +453,6 @@ function ContactsContent() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rating Filter */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Rating</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">Rating</label>
|
||||||
<select
|
<select
|
||||||
@@ -440,7 +469,6 @@ function ContactsContent() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category Filter */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Category</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">Category</label>
|
||||||
<select
|
<select
|
||||||
@@ -455,7 +483,6 @@ function ContactsContent() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Clear Filters */}
|
|
||||||
<div className="flex items-end">
|
<div className="flex items-end">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -478,7 +505,6 @@ function ContactsContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contacts Table */}
|
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-12">
|
<div className="p-12">
|
||||||
@@ -525,9 +551,9 @@ function ContactsContent() {
|
|||||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
</th>
|
</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">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">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">Status</th>
|
||||||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Actions</th>
|
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Actions</th>
|
||||||
@@ -537,105 +563,119 @@ function ContactsContent() {
|
|||||||
{contacts.map((contact) => {
|
{contacts.map((contact) => {
|
||||||
const isSelected = selectedContacts.has(contact.id)
|
const isSelected = selectedContacts.has(contact.id)
|
||||||
return (
|
return (
|
||||||
<tr key={contact.id} className={`hover:bg-gray-50 transition-colors ${isSelected ? 'bg-blue-50' : ''}`}>
|
<tr key={contact.id} className={`hover:bg-gray-50 transition-colors ${isSelected ? 'bg-blue-50' : ''}`}>
|
||||||
<td className="px-6 py-4 text-center">
|
<td className="px-6 py-4 text-center">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newSelected = new Set(selectedContacts)
|
const newSelected = new Set(selectedContacts)
|
||||||
if (e.target.checked) {
|
if (e.target.checked) {
|
||||||
newSelected.add(contact.id)
|
newSelected.add(contact.id)
|
||||||
} else {
|
} else {
|
||||||
newSelected.delete(contact.id)
|
newSelected.delete(contact.id)
|
||||||
}
|
}
|
||||||
setSelectedContacts(newSelected)
|
setSelectedContacts(newSelected)
|
||||||
}}
|
}}
|
||||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{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 && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
{contact.email}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(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.mobile}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<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)}
|
{getListContactName(contact).charAt(0)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-gray-900">{contact.name}</p>
|
<p className="font-semibold text-gray-900">
|
||||||
{contact.nameAr && <p className="text-sm text-gray-600">{contact.nameAr}</p>}
|
{getListContactName(contact)}
|
||||||
|
</p>
|
||||||
|
{getListContactNameAr(contact) && (
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{getListContactNameAr(contact)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="space-y-1">
|
<td className="px-6 py-4">
|
||||||
{contact.email && (
|
<span className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium ${getTypeColor(contact.type)}`}>
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
<Tag className="h-3 w-3" />
|
||||||
<Mail className="h-4 w-4" />
|
{getTypeLabel(contact.type)}
|
||||||
{contact.email}
|
</span>
|
||||||
</div>
|
</td>
|
||||||
)}
|
|
||||||
{contact.phone && (
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
|
||||||
<Phone className="h-4 w-4" />
|
contact.status === 'ACTIVE'
|
||||||
{contact.phone}
|
? 'bg-green-100 text-green-700'
|
||||||
</div>
|
: 'bg-gray-100 text-gray-700'
|
||||||
)}
|
}`}>
|
||||||
</div>
|
{contact.status === 'ACTIVE' ? 'Active' : 'Inactive'}
|
||||||
</td>
|
</span>
|
||||||
<td className="px-6 py-4">
|
</td>
|
||||||
{contact.companyName && (
|
|
||||||
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Building2 className="h-4 w-4 text-gray-400" />
|
<Link
|
||||||
<span className="text-sm text-gray-900">{contact.companyName}</span>
|
href={`/contacts/${contact.id}`}
|
||||||
|
className="p-2 hover:bg-blue-50 text-blue-600 rounded-lg transition-colors"
|
||||||
|
title="View"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => openEditModal(contact)}
|
||||||
|
className="p-2 hover:bg-green-50 text-green-600 rounded-lg transition-colors"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openDeleteDialog(contact)}
|
||||||
|
className="p-2 hover:bg-red-50 text-red-600 rounded-lg transition-colors"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
<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'
|
|
||||||
? 'bg-green-100 text-green-700'
|
|
||||||
: 'bg-gray-100 text-gray-700'
|
|
||||||
}`}>
|
|
||||||
{contact.status === 'ACTIVE' ? 'Active' : 'Inactive'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Link
|
|
||||||
href={`/contacts/${contact.id}`}
|
|
||||||
className="p-2 hover:bg-blue-50 text-blue-600 rounded-lg transition-colors"
|
|
||||||
title="View"
|
|
||||||
>
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
onClick={() => openEditModal(contact)}
|
|
||||||
className="p-2 hover:bg-green-50 text-green-600 rounded-lg transition-colors"
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => openDeleteDialog(contact)}
|
|
||||||
className="p-2 hover:bg-red-50 text-red-600 rounded-lg transition-colors"
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Showing <span className="font-semibold">{((currentPage - 1) * pageSize) + 1}</span> to{' '}
|
Showing <span className="font-semibold">{((currentPage - 1) * pageSize) + 1}</span> to{' '}
|
||||||
@@ -681,7 +721,6 @@ function ContactsContent() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Create Modal */}
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showCreateModal}
|
isOpen={showCreateModal}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -692,6 +731,7 @@ function ContactsContent() {
|
|||||||
size="xl"
|
size="xl"
|
||||||
>
|
>
|
||||||
<ContactForm
|
<ContactForm
|
||||||
|
key="create-contact"
|
||||||
onSubmit={async (data) => {
|
onSubmit={async (data) => {
|
||||||
await handleCreate(data as CreateContactData)
|
await handleCreate(data as CreateContactData)
|
||||||
}}
|
}}
|
||||||
@@ -703,7 +743,6 @@ function ContactsContent() {
|
|||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Edit Modal */}
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showEditModal}
|
isOpen={showEditModal}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -714,6 +753,7 @@ function ContactsContent() {
|
|||||||
size="xl"
|
size="xl"
|
||||||
>
|
>
|
||||||
<ContactForm
|
<ContactForm
|
||||||
|
key={selectedContact?.id || 'edit-contact'}
|
||||||
contact={selectedContact || undefined}
|
contact={selectedContact || undefined}
|
||||||
onSubmit={async (data) => {
|
onSubmit={async (data) => {
|
||||||
await handleEdit(data as UpdateContactData)
|
await handleEdit(data as UpdateContactData)
|
||||||
@@ -726,7 +766,6 @@ function ContactsContent() {
|
|||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Export Modal */}
|
|
||||||
{showExportModal && (
|
{showExportModal && (
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowExportModal(false)} />
|
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowExportModal(false)} />
|
||||||
@@ -820,7 +859,6 @@ function ContactsContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
|
||||||
{showDeleteDialog && selectedContact && (
|
{showDeleteDialog && selectedContact && (
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowDeleteDialog(false)} />
|
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowDeleteDialog(false)} />
|
||||||
@@ -869,7 +907,6 @@ function ContactsContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Import Modal */}
|
|
||||||
{showImportModal && (
|
{showImportModal && (
|
||||||
<ContactImport
|
<ContactImport
|
||||||
onClose={() => setShowImportModal(false)}
|
onClose={() => setShowImportModal(false)}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { toast } from 'react-hot-toast'
|
|||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Edit,
|
Edit,
|
||||||
Archive,
|
|
||||||
History,
|
History,
|
||||||
Award,
|
Award,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
@@ -15,15 +14,23 @@ import {
|
|||||||
Target,
|
Target,
|
||||||
Calendar,
|
Calendar,
|
||||||
User,
|
User,
|
||||||
Building2,
|
|
||||||
FileText,
|
FileText,
|
||||||
Clock,
|
Clock,
|
||||||
Loader2
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Receipt,
|
||||||
|
FileSignature
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||||
import LoadingSpinner from '@/components/LoadingSpinner'
|
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||||
|
import Modal from '@/components/Modal'
|
||||||
import { dealsAPI, Deal } from '@/lib/api/deals'
|
import { dealsAPI, Deal } from '@/lib/api/deals'
|
||||||
import { quotesAPI, Quote } from '@/lib/api/quotes'
|
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'
|
import { useLanguage } from '@/contexts/LanguageContext'
|
||||||
|
|
||||||
function DealDetailContent() {
|
function DealDetailContent() {
|
||||||
@@ -34,15 +41,165 @@ function DealDetailContent() {
|
|||||||
|
|
||||||
const [deal, setDeal] = useState<Deal | null>(null)
|
const [deal, setDeal] = useState<Deal | null>(null)
|
||||||
const [quotes, setQuotes] = useState<Quote[]>([])
|
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 [history, setHistory] = useState<any[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [activeTab, setActiveTab] = useState<'info' | 'quotes' | 'history'>('info')
|
const [activeTab, setActiveTab] = useState<'info' | 'quotes' | 'costSheets' | 'contracts' | 'invoices' | 'history'>('info')
|
||||||
const [showWinDialog, setShowWinDialog] = useState(false)
|
const [showWinDialog, setShowWinDialog] = useState(false)
|
||||||
const [showLoseDialog, setShowLoseDialog] = 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 [winData, setWinData] = useState({ actualValue: 0, wonReason: '' })
|
||||||
const [loseData, setLoseData] = useState({ lostReason: '' })
|
const [loseData, setLoseData] = useState({ lostReason: '' })
|
||||||
const [submitting, setSubmitting] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
fetchDeal()
|
fetchDeal()
|
||||||
@@ -51,6 +208,9 @@ function DealDetailContent() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (deal) {
|
if (deal) {
|
||||||
fetchQuotes()
|
fetchQuotes()
|
||||||
|
fetchCostSheets()
|
||||||
|
fetchContracts()
|
||||||
|
fetchInvoices()
|
||||||
fetchHistory()
|
fetchHistory()
|
||||||
}
|
}
|
||||||
}, [deal])
|
}, [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 getStatusColor = (status: string) => {
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
ACTIVE: 'bg-green-100 text-green-700',
|
ACTIVE: 'bg-green-100 text-green-700',
|
||||||
@@ -272,18 +459,18 @@ function DealDetailContent() {
|
|||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
|
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||||
<div className="border-b border-gray-200">
|
<div className="border-b border-gray-200">
|
||||||
<nav className="flex gap-4 px-6">
|
<nav className="flex gap-4 px-6 overflow-x-auto">
|
||||||
{(['info', 'quotes', 'history'] as const).map((tab) => (
|
{(['info', 'quotes', 'costSheets', 'contracts', 'invoices', 'history'] as const).map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setActiveTab(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
|
activeTab === tab
|
||||||
? 'border-green-600 text-green-600'
|
? 'border-green-600 text-green-600'
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
: '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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -377,6 +564,135 @@ function DealDetailContent() {
|
|||||||
</div>
|
</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' && (
|
{activeTab === 'history' && (
|
||||||
<div>
|
<div>
|
||||||
{history.length === 0 ? (
|
{history.length === 0 ? (
|
||||||
@@ -527,6 +843,179 @@ function DealDetailContent() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import logoImage from '@/assets/logo.png'
|
||||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||||
import { useAuth } from '@/contexts/AuthContext'
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
import { useLanguage } from '@/contexts/LanguageContext'
|
import { useLanguage } from '@/contexts/LanguageContext'
|
||||||
@@ -8,6 +10,7 @@ import LanguageSwitcher from '@/components/LanguageSwitcher'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
|
User,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Package,
|
Package,
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
@@ -17,7 +20,8 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
Settings,
|
Settings,
|
||||||
Bell,
|
Bell,
|
||||||
Shield
|
Shield,
|
||||||
|
FileText
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { dashboardAPI } from '@/lib/api'
|
import { dashboardAPI } from '@/lib/api'
|
||||||
|
|
||||||
@@ -55,6 +59,16 @@ function DashboardContent() {
|
|||||||
description: 'الفرص التجارية والعروض والصفقات',
|
description: 'الفرص التجارية والعروض والصفقات',
|
||||||
permission: 'crm'
|
permission: 'crm'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'tenders',
|
||||||
|
name: 'إدارة المناقصات',
|
||||||
|
nameEn: 'Tender Management',
|
||||||
|
icon: FileText,
|
||||||
|
color: 'bg-indigo-500',
|
||||||
|
href: '/tenders',
|
||||||
|
description: 'تسجيل ومتابعة المناقصات وتحويلها إلى فرص',
|
||||||
|
permission: 'tenders'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'inventory',
|
id: 'inventory',
|
||||||
name: 'المخزون والأصول',
|
name: 'المخزون والأصول',
|
||||||
@@ -85,6 +99,16 @@ function DashboardContent() {
|
|||||||
description: 'الموظفين والإجازات والرواتب',
|
description: 'الموظفين والإجازات والرواتب',
|
||||||
permission: 'hr'
|
permission: 'hr'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'portal',
|
||||||
|
name: 'البوابة الذاتية',
|
||||||
|
nameEn: 'My Portal',
|
||||||
|
icon: User,
|
||||||
|
color: 'bg-cyan-500',
|
||||||
|
href: '/portal',
|
||||||
|
description: 'قروضي، إجازاتي، طلبات الشراء والرواتب',
|
||||||
|
permission: 'portal'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'marketing',
|
id: 'marketing',
|
||||||
name: 'التسويق',
|
name: 'التسويق',
|
||||||
@@ -119,11 +143,16 @@ function DashboardContent() {
|
|||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
<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 justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-primary-600 p-2 rounded-lg">
|
<Image
|
||||||
<Building2 className="h-8 w-8 text-white" />
|
src={logoImage}
|
||||||
</div>
|
alt="Company Logo"
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
className="object-contain"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
<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>
|
<p className="text-sm text-gray-600">نظام إدارة علاقات العملاء</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,10 +24,14 @@ import {
|
|||||||
User,
|
User,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
Network
|
Network,
|
||||||
|
Banknote,
|
||||||
|
ShoppingCart,
|
||||||
|
FileText
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { employeesAPI, Employee, CreateEmployeeData, UpdateEmployeeData, EmployeeFilters, departmentsAPI, positionsAPI, type Department } from '@/lib/api/employees'
|
import { employeesAPI, Employee, CreateEmployeeData, UpdateEmployeeData, EmployeeFilters, departmentsAPI, positionsAPI, type Department } from '@/lib/api/employees'
|
||||||
|
import { hrAdminAPI } from '@/lib/api/hrAdmin'
|
||||||
|
|
||||||
const OrgChart = dynamic(() => import('@/components/hr/OrgChart'), { ssr: false })
|
const OrgChart = dynamic(() => import('@/components/hr/OrgChart'), { ssr: false })
|
||||||
|
|
||||||
@@ -273,7 +277,7 @@ function HRContent() {
|
|||||||
mobile: '',
|
mobile: '',
|
||||||
dateOfBirth: '',
|
dateOfBirth: '',
|
||||||
gender: '',
|
gender: '',
|
||||||
nationality: 'Saudi Arabia',
|
nationality: 'Syria',
|
||||||
nationalId: '',
|
nationalId: '',
|
||||||
employmentType: 'FULL_TIME',
|
employmentType: 'FULL_TIME',
|
||||||
contractType: 'UNLIMITED',
|
contractType: 'UNLIMITED',
|
||||||
@@ -291,8 +295,8 @@ function HRContent() {
|
|||||||
const [positions, setPositions] = useState<any[]>([])
|
const [positions, setPositions] = useState<any[]>([])
|
||||||
const [loadingDepts, setLoadingDepts] = useState(false)
|
const [loadingDepts, setLoadingDepts] = useState(false)
|
||||||
|
|
||||||
// Tabs: employees | departments | orgchart
|
// Tabs: employees | departments | orgchart | leaves | loans | purchases | contracts
|
||||||
const [activeTab, setActiveTab] = useState<'employees' | 'departments' | 'orgchart'>('employees')
|
const [activeTab, setActiveTab] = useState<'employees' | 'departments' | 'orgchart' | 'leaves' | 'loans' | 'purchases' | 'contracts'>('employees')
|
||||||
const [hierarchy, setHierarchy] = useState<Department[]>([])
|
const [hierarchy, setHierarchy] = useState<Department[]>([])
|
||||||
const [loadingHierarchy, setLoadingHierarchy] = useState(false)
|
const [loadingHierarchy, setLoadingHierarchy] = useState(false)
|
||||||
|
|
||||||
@@ -303,6 +307,13 @@ function HRContent() {
|
|||||||
const [deptFormErrors, setDeptFormErrors] = useState<Record<string, string>>({})
|
const [deptFormErrors, setDeptFormErrors] = useState<Record<string, string>>({})
|
||||||
const [deptDeleteConfirm, setDeptDeleteConfirm] = useState<string | null>(null)
|
const [deptDeleteConfirm, setDeptDeleteConfirm] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// HR Admin tabs data
|
||||||
|
const [leavesData, setLeavesData] = useState<any[]>([])
|
||||||
|
const [loansData, setLoansData] = useState<any[]>([])
|
||||||
|
const [purchasesData, setPurchasesData] = useState<any[]>([])
|
||||||
|
const [contractsData, setContractsData] = useState<any[]>([])
|
||||||
|
const [loadingHRTab, setLoadingHRTab] = useState(false)
|
||||||
|
|
||||||
const fetchDepartments = useCallback(async () => {
|
const fetchDepartments = useCallback(async () => {
|
||||||
setLoadingDepts(true)
|
setLoadingDepts(true)
|
||||||
try {
|
try {
|
||||||
@@ -352,6 +363,34 @@ function HRContent() {
|
|||||||
if (activeTab === 'orgchart') fetchHierarchy()
|
if (activeTab === 'orgchart') fetchHierarchy()
|
||||||
}, [activeTab, fetchDepartments, fetchHierarchy])
|
}, [activeTab, fetchDepartments, fetchHierarchy])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'leaves' || activeTab === 'loans' || activeTab === 'purchases' || activeTab === 'contracts') {
|
||||||
|
setLoadingHRTab(true)
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
if (activeTab === 'leaves') {
|
||||||
|
const { leaves } = await hrAdminAPI.getLeaves({ status: 'PENDING', pageSize: 50 })
|
||||||
|
setLeavesData(leaves)
|
||||||
|
} else if (activeTab === 'loans') {
|
||||||
|
const { loans } = await hrAdminAPI.getLoans({ pageSize: 50 })
|
||||||
|
setLoansData(loans.filter((loan: any) => ['PENDING_HR', 'PENDING_ADMIN'].includes(loan.status)))
|
||||||
|
} else if (activeTab === 'purchases') {
|
||||||
|
const { purchaseRequests } = await hrAdminAPI.getPurchaseRequests({ status: 'PENDING', pageSize: 50 })
|
||||||
|
setPurchasesData(purchaseRequests)
|
||||||
|
} else if (activeTab === 'contracts') {
|
||||||
|
const { contracts } = await hrAdminAPI.getContracts({ pageSize: 50 })
|
||||||
|
setContractsData(contracts)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to load data')
|
||||||
|
} finally {
|
||||||
|
setLoadingHRTab(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
}, [activeTab])
|
||||||
|
|
||||||
// Fetch Employees (with debouncing for search)
|
// Fetch Employees (with debouncing for search)
|
||||||
const fetchEmployees = useCallback(async () => {
|
const fetchEmployees = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -720,6 +759,58 @@ function HRContent() {
|
|||||||
الهيكل التنظيمي / Org Chart
|
الهيكل التنظيمي / Org Chart
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('leaves')}
|
||||||
|
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||||
|
activeTab === 'leaves'
|
||||||
|
? 'border-red-600 text-red-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
الإجازات / Leaves
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('loans')}
|
||||||
|
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||||
|
activeTab === 'loans'
|
||||||
|
? 'border-red-600 text-red-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Banknote className="h-4 w-4" />
|
||||||
|
القروض / Loans
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('purchases')}
|
||||||
|
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||||
|
activeTab === 'purchases'
|
||||||
|
? 'border-red-600 text-red-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<ShoppingCart className="h-4 w-4" />
|
||||||
|
طلبات الشراء / Purchases
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('contracts')}
|
||||||
|
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||||
|
activeTab === 'contracts'
|
||||||
|
? 'border-red-600 text-red-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
العقود / Contracts
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1061,6 +1152,164 @@ function HRContent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'leaves' && (
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">طلبات الإجازة المعلقة / Pending Leave Requests</h2>
|
||||||
|
{loadingHRTab ? <LoadingSpinner /> : leavesData.length === 0 ? (
|
||||||
|
<p className="text-gray-500">No pending leaves</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{leavesData.map((l: any) => (
|
||||||
|
<div key={l.id} className="flex justify-between items-center py-3 border-b">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{l.employee?.firstName} {l.employee?.lastName}</p>
|
||||||
|
<p className="text-sm text-gray-600">{l.leaveType} - {l.days} days ({new Date(l.startDate).toLocaleDateString()} - {new Date(l.endDate).toLocaleDateString()})</p>
|
||||||
|
{l.reason && <p className="text-xs text-gray-500">{l.reason}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={async () => { try { await hrAdminAPI.approveLeave(l.id); toast.success('Approved'); setLeavesData((p: any[]) => p.filter((x: any) => x.id !== l.id)) } catch { toast.error('Failed') } }} className="px-3 py-1 bg-green-600 text-white rounded text-sm">Approve</button>
|
||||||
|
<button onClick={async () => { const r = prompt('Rejection reason?'); if (r) { try { await hrAdminAPI.rejectLeave(l.id, r); toast.success('Rejected'); setLeavesData((p: any[]) => p.filter((x: any) => x.id !== l.id)) } catch { toast.error('Failed') } } }} className="px-3 py-1 bg-red-600 text-white rounded text-sm">Reject</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'loans' && (
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">طلبات القروض المعلقة / Pending Loan Requests</h2>
|
||||||
|
{loadingHRTab ? <LoadingSpinner /> : loansData.length === 0 ? (
|
||||||
|
<p className="text-gray-500">No pending loans</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{loansData.map((l: any) => {
|
||||||
|
const salary = Number(l.employee?.basicSalary || 0)
|
||||||
|
const amount = Number(l.amount || 0)
|
||||||
|
const needsAdmin = salary > 0 && amount > salary * 0.5
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={l.id} className="flex justify-between items-center py-3 border-b">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{l.employee?.firstName} {l.employee?.lastName}</p>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{l.loanNumber} - {l.type} - {amount.toLocaleString()} SAR ({l.installments} installments)
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
الراتب الأساسي: {salary.toLocaleString()} SAR
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
الحالة: {l.status === 'PENDING_HR' ? 'بانتظار HR' : 'بانتظار مدير النظام'}
|
||||||
|
</p>
|
||||||
|
{needsAdmin && (
|
||||||
|
<p className="text-xs text-orange-600">
|
||||||
|
هذا الطلب يتجاوز 50% من الراتب الأساسي ويحتاج موافقة مدير النظام
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{l.reason && <p className="text-xs text-gray-500 mt-1">{l.reason}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const response = await hrAdminAPI.approveLoan(l.id)
|
||||||
|
const updatedLoan = response?.data?.data || response?.data || response
|
||||||
|
|
||||||
|
if (updatedLoan?.status === 'PENDING_ADMIN') {
|
||||||
|
toast.success('تمت موافقة HR وتحويل الطلب إلى مدير النظام')
|
||||||
|
} else {
|
||||||
|
toast.success('Approved')
|
||||||
|
setLoansData((p: any[]) => p.filter((x: any) => x.id !== l.id))
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.response?.data?.message || 'Failed')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 bg-green-600 text-white rounded text-sm"
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const r = prompt('Rejection reason?')
|
||||||
|
if (r) {
|
||||||
|
try {
|
||||||
|
await hrAdminAPI.rejectLoan(l.id, r)
|
||||||
|
toast.success('Rejected')
|
||||||
|
setLoansData((p: any[]) => p.filter((x: any) => x.id !== l.id))
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.response?.data?.message || 'Failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 bg-red-600 text-white rounded text-sm"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'purchases' && (
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">طلبات الشراء المعلقة / Pending Purchase Requests</h2>
|
||||||
|
{loadingHRTab ? <LoadingSpinner /> : purchasesData.length === 0 ? (
|
||||||
|
<p className="text-gray-500">No pending purchase requests</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{purchasesData.map((pr: any) => (
|
||||||
|
<div key={pr.id} className="flex justify-between items-center py-3 border-b">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{pr.employee?.firstName} {pr.employee?.lastName}</p>
|
||||||
|
<p className="text-sm text-gray-600">{pr.requestNumber} - {pr.totalAmount != null ? Number(pr.totalAmount).toLocaleString() + ' SAR' : '-'}</p>
|
||||||
|
{pr.reason && <p className="text-xs text-gray-500">{pr.reason}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={async () => { try { await hrAdminAPI.approvePurchaseRequest(pr.id); toast.success('Approved'); setPurchasesData((p: any[]) => p.filter((x: any) => x.id !== pr.id)) } catch { toast.error('Failed') } }} className="px-3 py-1 bg-green-600 text-white rounded text-sm">Approve</button>
|
||||||
|
<button onClick={async () => { const r = prompt('Rejection reason?'); if (r) { try { await hrAdminAPI.rejectPurchaseRequest(pr.id, r); toast.success('Rejected'); setPurchasesData((p: any[]) => p.filter((x: any) => x.id !== pr.id)) } catch { toast.error('Failed') } } }} className="px-3 py-1 bg-red-600 text-white rounded text-sm">Reject</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'contracts' && (
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">عقود الموظفين / Employee Contracts</h2>
|
||||||
|
{loadingHRTab ? <LoadingSpinner /> : contractsData.length === 0 ? (
|
||||||
|
<p className="text-gray-500">No contracts</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead><tr className="border-b"><th className="text-right py-2">Contract</th><th className="text-right py-2">Employee</th><th className="text-right py-2">Type</th><th className="text-right py-2">Salary</th><th className="text-right py-2">Period</th><th className="text-right py-2">Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{contractsData.map((c: any) => (
|
||||||
|
<tr key={c.id} className="border-b">
|
||||||
|
<td className="py-2">{c.contractNumber}</td>
|
||||||
|
<td className="py-2">{c.employee?.firstName} {c.employee?.lastName}</td>
|
||||||
|
<td className="py-2">{c.type}</td>
|
||||||
|
<td className="py-2">{Number(c.salary).toLocaleString()} SAR</td>
|
||||||
|
<td className="py-2">{new Date(c.startDate).toLocaleDateString()}{c.endDate ? ' - ' + new Date(c.endDate).toLocaleDateString() : ''}</td>
|
||||||
|
<td className="py-2"><span className={`px-2 py-0.5 rounded text-xs ${c.status === 'ACTIVE' ? 'bg-green-100' : 'bg-gray-100'}`}>{c.status}</span></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Create Modal */}
|
{/* Create Modal */}
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
||||||
<rect width="32" height="32" rx="4" fill="#2563eb"/>
|
|
||||||
<text x="16" y="22" text-anchor="middle" fill="white" font-size="14" font-family="sans-serif" font-weight="bold">Z</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 247 B |
@@ -19,8 +19,11 @@ const readexPro = Readex_Pro({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Z.CRM - نظام إدارة علاقات العملاء',
|
title: 'ATMATA - نظام إدارة علاقات العملاء',
|
||||||
description: 'Enterprise CRM System for Contact Management, Sales, HR, Inventory, Projects, and Marketing',
|
description: 'Enterprise CRM System for Contact Management, Sales, HR, Inventory, Projects, and Marketing',
|
||||||
|
icons: {
|
||||||
|
icon: '/favicon.ico',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { LogIn, Mail, Lock, Building2, AlertCircle } from 'lucide-react'
|
|||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
const [email, setEmail] = useState('')
|
const [emailOrUsername, setEmailOrUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
@@ -18,7 +18,7 @@ export default function LoginPage() {
|
|||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await login(email, password)
|
await login(emailOrUsername, password)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'فشل تسجيل الدخول. الرجاء المحاولة مرة أخرى.')
|
setError(err.message || 'فشل تسجيل الدخول. الرجاء المحاولة مرة أخرى.')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -37,7 +37,7 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">تسجيل الدخول</h1>
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">تسجيل الدخول</h1>
|
||||||
<p className="text-gray-600">Z.CRM - نظام إدارة علاقات العملاء</p>
|
<p className="text-gray-600">ATMATA - نظام إدارة علاقات العملاء</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Login Form */}
|
{/* Login Form */}
|
||||||
@@ -50,20 +50,21 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* Email Field */}
|
{/* Email or Username Field */}
|
||||||
<div>
|
<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>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
|
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="emailOrUsername"
|
||||||
type="email"
|
type="text"
|
||||||
value={email}
|
value={emailOrUsername}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmailOrUsername(e.target.value)}
|
||||||
required
|
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"
|
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}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
@@ -110,13 +111,14 @@ export default function LoginPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* System Administrator */}
|
{/* System Administrator
|
||||||
<div className="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
<div className="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||||
<h3 className="text-sm font-semibold text-blue-900 mb-2">مدير النظام:</h3>
|
<h3 className="text-sm font-semibold text-blue-900 mb-2">مدير النظام:</h3>
|
||||||
<div className="text-sm text-blue-800">
|
<div className="text-sm text-blue-800">
|
||||||
<p><strong>admin@system.local</strong> / Admin@123</p>
|
<p><strong>admin@system.local</strong> / Admin@123</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
*/}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Back to Home */}
|
{/* Back to Home */}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export default function Home() {
|
|||||||
<Building2 className="h-8 w-8 text-white" />
|
<Building2 className="h-8 w-8 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p className="text-sm text-gray-600">نظام إدارة علاقات العملاء</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,7 +144,7 @@ export default function Home() {
|
|||||||
جاهز لتحويل إدارة أعمالك؟
|
جاهز لتحويل إدارة أعمالك؟
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xl mb-8 text-primary-100">
|
<p className="text-xl mb-8 text-primary-100">
|
||||||
ابدأ باستخدام Z.CRM اليوم وشاهد الفرق
|
ابدأ باستخدام Atmata System اليوم وشاهد الفرق
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
@@ -162,10 +162,10 @@ export default function Home() {
|
|||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="flex items-center justify-center gap-2 mb-3">
|
<div className="flex items-center justify-center gap-2 mb-3">
|
||||||
<Building2 className="h-6 w-6 text-primary-600" />
|
<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>
|
</div>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
© 2024 Z.CRM. جميع الحقوق محفوظة.
|
© 2026 ATMATA جميع الحقوق محفوظة.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
94
frontend/src/app/portal/attendance/page.tsx
Normal file
94
frontend/src/app/portal/attendance/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { portalAPI, type Attendance } from '@/lib/api/portal'
|
||||||
|
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||||
|
import { Clock } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function PortalAttendancePage() {
|
||||||
|
const [attendance, setAttendance] = useState<Attendance[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [month, setMonth] = useState(new Date().getMonth() + 1)
|
||||||
|
const [year, setYear] = useState(new Date().getFullYear())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true)
|
||||||
|
portalAPI.getAttendance(month, year)
|
||||||
|
.then(setAttendance)
|
||||||
|
.catch(() => setAttendance([]))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [month, year])
|
||||||
|
|
||||||
|
const months = Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleDateString('ar-SA', { month: 'long' }) }))
|
||||||
|
const years = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i)
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">حضوري</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={month}
|
||||||
|
onChange={(e) => setMonth(parseInt(e.target.value))}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
>
|
||||||
|
{months.map((m) => (
|
||||||
|
<option key={m.value} value={m.value}>{m.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={year}
|
||||||
|
onChange={(e) => setYear(parseInt(e.target.value))}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
>
|
||||||
|
{years.map((y) => (
|
||||||
|
<option key={y} value={y}>{y}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{attendance.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-xl shadow p-12 text-center text-gray-500">
|
||||||
|
<Clock className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||||
|
<p>لا توجد سجلات حضور لهذا الشهر</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white rounded-xl shadow overflow-hidden border border-gray-100">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="text-right py-3 px-4">التاريخ</th>
|
||||||
|
<th className="text-right py-3 px-4">دخول</th>
|
||||||
|
<th className="text-right py-3 px-4">خروج</th>
|
||||||
|
<th className="text-right py-3 px-4">ساعات العمل</th>
|
||||||
|
<th className="text-right py-3 px-4">الحالة</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{attendance.map((a) => (
|
||||||
|
<tr key={a.id} className="border-t">
|
||||||
|
<td className="py-3 px-4">{new Date(a.date).toLocaleDateString('ar-SA')}</td>
|
||||||
|
<td className="py-3 px-4">{a.checkIn ? new Date(a.checkIn).toLocaleTimeString('ar-SA', { hour: '2-digit', minute: '2-digit' }) : '-'}</td>
|
||||||
|
<td className="py-3 px-4">{a.checkOut ? new Date(a.checkOut).toLocaleTimeString('ar-SA', { hour: '2-digit', minute: '2-digit' }) : '-'}</td>
|
||||||
|
<td className="py-3 px-4">{a.workHours != null ? Number(a.workHours).toFixed(1) : '-'}</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<span className={`px-2 py-1 rounded text-xs ${
|
||||||
|
a.status === 'PRESENT' ? 'bg-green-100 text-green-800' :
|
||||||
|
a.status === 'ABSENT' ? 'bg-red-100 text-red-800' :
|
||||||
|
a.status === 'LATE' ? 'bg-amber-100 text-amber-800' : 'bg-gray-100'
|
||||||
|
}`}>
|
||||||
|
{a.status === 'PRESENT' ? 'حاضر' : a.status === 'ABSENT' ? 'غائب' : a.status === 'LATE' ? 'متأخر' : a.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
120
frontend/src/app/portal/layout.tsx
Normal file
120
frontend/src/app/portal/layout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
292
frontend/src/app/portal/leave/page.tsx
Normal file
292
frontend/src/app/portal/leave/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
187
frontend/src/app/portal/loans/page.tsx
Normal file
187
frontend/src/app/portal/loans/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
191
frontend/src/app/portal/managed-leaves/page.tsx
Normal file
191
frontend/src/app/portal/managed-leaves/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
134
frontend/src/app/portal/managed-overtime-requests/page.tsx
Normal file
134
frontend/src/app/portal/managed-overtime-requests/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
191
frontend/src/app/portal/overtime/page.tsx
Normal file
191
frontend/src/app/portal/overtime/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
181
frontend/src/app/portal/page.tsx
Normal file
181
frontend/src/app/portal/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
210
frontend/src/app/portal/purchase-requests/page.tsx
Normal file
210
frontend/src/app/portal/purchase-requests/page.tsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { portalAPI, type PurchaseRequest } from '@/lib/api/portal'
|
||||||
|
import Modal from '@/components/Modal'
|
||||||
|
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||||
|
import { toast } from 'react-hot-toast'
|
||||||
|
import { ShoppingCart, Plus } from 'lucide-react'
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||||
|
PENDING: { label: 'قيد المراجعة', color: 'bg-amber-100 text-amber-800' },
|
||||||
|
APPROVED: { label: 'معتمد', color: 'bg-green-100 text-green-800' },
|
||||||
|
REJECTED: { label: 'مرفوض', color: 'bg-red-100 text-red-800' },
|
||||||
|
ORDERED: { label: 'تم الطلب', color: 'bg-blue-100 text-blue-800' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PortalPurchaseRequestsPage() {
|
||||||
|
const [requests, setRequests] = useState<PurchaseRequest[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
items: [{ description: '', quantity: 1, estimatedPrice: '' }],
|
||||||
|
reason: '',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
portalAPI.getPurchaseRequests()
|
||||||
|
.then(setRequests)
|
||||||
|
.catch(() => toast.error('فشل تحميل الطلبات'))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const addItem = () => setForm((p) => ({ ...p, items: [...p.items, { description: '', quantity: 1, estimatedPrice: '' }] }))
|
||||||
|
const removeItem = (i: number) =>
|
||||||
|
setForm((p) => ({ ...p, items: p.items.filter((_, idx) => idx !== i) }))
|
||||||
|
const updateItem = (i: number, key: string, value: string | number) =>
|
||||||
|
setForm((p) => ({
|
||||||
|
...p,
|
||||||
|
items: p.items.map((it, idx) => (idx === i ? { ...it, [key]: value } : it)),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const items = form.items
|
||||||
|
.filter((it) => it.description.trim())
|
||||||
|
.map((it) => ({
|
||||||
|
description: it.description,
|
||||||
|
quantity: it.quantity || 1,
|
||||||
|
estimatedPrice: parseFloat(String(it.estimatedPrice)) || 0,
|
||||||
|
}))
|
||||||
|
if (items.length === 0) {
|
||||||
|
toast.error('أضف صنفاً واحداً على الأقل')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitting(true)
|
||||||
|
portalAPI.submitPurchaseRequest({
|
||||||
|
items,
|
||||||
|
reason: form.reason || undefined,
|
||||||
|
priority: form.priority,
|
||||||
|
})
|
||||||
|
.then((pr) => {
|
||||||
|
setRequests((prev) => [pr, ...prev])
|
||||||
|
setShowModal(false)
|
||||||
|
setForm({ items: [{ description: '', quantity: 1, estimatedPrice: '' }], reason: '', priority: 'NORMAL' })
|
||||||
|
toast.success('تم إرسال طلب الشراء')
|
||||||
|
})
|
||||||
|
.catch(() => toast.error('فشل إرسال الطلب'))
|
||||||
|
.finally(() => setSubmitting(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">طلبات الشراء</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
طلب شراء جديد
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{requests.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-xl shadow p-12 text-center text-gray-500">
|
||||||
|
<ShoppingCart className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||||
|
<p>لا توجد طلبات شراء</p>
|
||||||
|
<button onClick={() => setShowModal(true)} className="mt-4 text-teal-600 hover:underline">
|
||||||
|
تقديم طلب شراء
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{requests.map((pr) => {
|
||||||
|
const statusInfo = STATUS_MAP[pr.status] || { label: pr.status, color: 'bg-gray-100 text-gray-800' }
|
||||||
|
const items = Array.isArray(pr.items) ? pr.items : []
|
||||||
|
return (
|
||||||
|
<div key={pr.id} className="bg-white rounded-xl shadow p-6 border border-gray-100">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-gray-900">{pr.requestNumber}</p>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
{pr.totalAmount != null ? `${Number(pr.totalAmount).toLocaleString()} ر.س` : '-'}
|
||||||
|
</p>
|
||||||
|
{items.length > 0 && (
|
||||||
|
<ul className="mt-2 text-sm text-gray-600 list-disc list-inside">
|
||||||
|
{items.slice(0, 3).map((it: any, i: number) => (
|
||||||
|
<li key={i}>
|
||||||
|
{it.description} × {it.quantity || 1}
|
||||||
|
{it.estimatedPrice ? ` (${Number(it.estimatedPrice).toLocaleString()} ر.س)` : ''}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{items.length > 3 && <li>... و {items.length - 3} أصناف أخرى</li>}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{pr.rejectedReason && (
|
||||||
|
<p className="mt-2 text-sm text-red-600">سبب الرفض: {pr.rejectedReason}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
|
||||||
|
{statusInfo.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب شراء جديد">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 max-h-[70vh] overflow-y-auto">
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<label className="text-sm font-medium text-gray-700">الأصناف</label>
|
||||||
|
<button type="button" onClick={addItem} className="text-teal-600 text-sm hover:underline">
|
||||||
|
+ إضافة صنف
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{form.items.map((it, i) => (
|
||||||
|
<div key={i} className="flex gap-2 items-start border p-2 rounded">
|
||||||
|
<input
|
||||||
|
placeholder="الوصف"
|
||||||
|
value={it.description}
|
||||||
|
onChange={(e) => updateItem(i, 'description', e.target.value)}
|
||||||
|
className="flex-1 px-2 py-1 border rounded text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
placeholder="الكمية"
|
||||||
|
value={it.quantity}
|
||||||
|
onChange={(e) => updateItem(i, 'quantity', parseInt(e.target.value) || 1)}
|
||||||
|
className="w-20 px-2 py-1 border rounded text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="السعر"
|
||||||
|
value={it.estimatedPrice}
|
||||||
|
onChange={(e) => updateItem(i, 'estimatedPrice', e.target.value)}
|
||||||
|
className="w-24 px-2 py-1 border rounded text-sm"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => removeItem(i)} className="text-red-600 text-sm">
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">الأولوية</label>
|
||||||
|
<select
|
||||||
|
value={form.priority}
|
||||||
|
onChange={(e) => setForm((p) => ({ ...p, priority: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
>
|
||||||
|
<option value="LOW">منخفضة</option>
|
||||||
|
<option value="NORMAL">عادية</option>
|
||||||
|
<option value="HIGH">عالية</option>
|
||||||
|
<option value="URGENT">عاجلة</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">السبب / التوضيح</label>
|
||||||
|
<textarea
|
||||||
|
value={form.reason}
|
||||||
|
onChange={(e) => setForm((p) => ({ ...p, reason: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button type="button" onClick={() => setShowModal(false)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">
|
||||||
|
إلغاء
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={submitting} className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50">
|
||||||
|
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
65
frontend/src/app/portal/salaries/page.tsx
Normal file
65
frontend/src/app/portal/salaries/page.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { portalAPI, type Salary } from '@/lib/api/portal'
|
||||||
|
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||||
|
import { DollarSign } from 'lucide-react'
|
||||||
|
|
||||||
|
const MONTHS_AR = ['يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر']
|
||||||
|
|
||||||
|
export default function PortalSalariesPage() {
|
||||||
|
const [salaries, setSalaries] = useState<Salary[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
portalAPI.getSalaries()
|
||||||
|
.then(setSalaries)
|
||||||
|
.catch(() => setSalaries([]))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">رواتبي</h1>
|
||||||
|
|
||||||
|
{salaries.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-xl shadow p-12 text-center text-gray-500">
|
||||||
|
<DollarSign className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||||
|
<p>لا توجد سجلات رواتب</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{salaries.map((s) => (
|
||||||
|
<div key={s.id} className="bg-white rounded-xl shadow p-6 border border-gray-100">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-gray-900">
|
||||||
|
{MONTHS_AR[s.month - 1]} {s.year}
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-teal-600 mt-1">
|
||||||
|
{Number(s.netSalary).toLocaleString()} ر.س
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 text-sm text-gray-600 space-y-1">
|
||||||
|
<p>الأساس: {Number(s.basicSalary).toLocaleString()} | البدلات: {Number(s.allowances).toLocaleString()} | الخصومات: {Number(s.deductions).toLocaleString()}</p>
|
||||||
|
<p>عمولة: {Number(s.commissions).toLocaleString()} | إضافي: {Number(s.overtimePay).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||||
|
s.status === 'PAID' ? 'bg-green-100 text-green-800' :
|
||||||
|
s.status === 'APPROVED' ? 'bg-blue-100 text-blue-800' : 'bg-amber-100 text-amber-800'
|
||||||
|
}`}>
|
||||||
|
{s.status === 'PAID' ? 'مدفوع' : s.status === 'APPROVED' ? 'معتمد' : 'قيد المعالجة'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{s.paidDate && (
|
||||||
|
<p className="text-xs text-gray-500 mt-2">تاريخ الدفع: {new Date(s.paidDate).toLocaleDateString('ar-SA')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
723
frontend/src/app/tenders/[id]/page.tsx
Normal file
723
frontend/src/app/tenders/[id]/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
674
frontend/src/app/tenders/page.tsx
Normal file
674
frontend/src/app/tenders/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
BIN
frontend/src/assets/logo.png
Normal file
BIN
frontend/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
@@ -15,40 +15,47 @@ interface ContactFormProps {
|
|||||||
submitting?: boolean
|
submitting?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildInitialFormData = (contact?: Contact): CreateContactData => ({
|
||||||
|
type: contact?.type || 'INDIVIDUAL',
|
||||||
|
name: contact?.name || '',
|
||||||
|
nameAr: contact?.nameAr,
|
||||||
|
email: contact?.email,
|
||||||
|
phone: contact?.phone,
|
||||||
|
mobile: contact?.mobile,
|
||||||
|
website: contact?.website,
|
||||||
|
companyName: contact?.companyName,
|
||||||
|
companyNameAr: contact?.companyNameAr,
|
||||||
|
taxNumber: contact?.taxNumber,
|
||||||
|
commercialRegister: contact?.commercialRegister,
|
||||||
|
address: contact?.address,
|
||||||
|
city: contact?.city,
|
||||||
|
country: contact?.country || 'Syria',
|
||||||
|
postalCode: contact?.postalCode,
|
||||||
|
source: contact?.source || 'WEBSITE',
|
||||||
|
categories: contact?.categories?.map((c: any) => c.id || c) || [],
|
||||||
|
tags: contact?.tags || [],
|
||||||
|
parentId: contact?.parent?.id,
|
||||||
|
employeeId: contact?.employeeId ?? undefined,
|
||||||
|
customFields: contact?.customFields
|
||||||
|
})
|
||||||
|
|
||||||
export default function ContactForm({ contact, onSubmit, onCancel, submitting = false }: ContactFormProps) {
|
export default function ContactForm({ contact, onSubmit, onCancel, submitting = false }: ContactFormProps) {
|
||||||
const isEdit = !!contact
|
const isEdit = !!contact
|
||||||
|
|
||||||
// Form state
|
const [formData, setFormData] = useState<CreateContactData>(buildInitialFormData(contact))
|
||||||
const [formData, setFormData] = useState<CreateContactData>({
|
|
||||||
type: contact?.type || 'INDIVIDUAL',
|
|
||||||
name: contact?.name || '',
|
|
||||||
nameAr: contact?.nameAr,
|
|
||||||
email: contact?.email,
|
|
||||||
phone: contact?.phone,
|
|
||||||
mobile: contact?.mobile,
|
|
||||||
website: contact?.website,
|
|
||||||
companyName: contact?.companyName,
|
|
||||||
companyNameAr: contact?.companyNameAr,
|
|
||||||
taxNumber: contact?.taxNumber,
|
|
||||||
commercialRegister: contact?.commercialRegister,
|
|
||||||
address: contact?.address,
|
|
||||||
city: contact?.city,
|
|
||||||
country: contact?.country || 'Saudi Arabia',
|
|
||||||
postalCode: contact?.postalCode,
|
|
||||||
source: contact?.source || 'WEBSITE',
|
|
||||||
categories: contact?.categories?.map((c: any) => c.id || c) || [],
|
|
||||||
tags: contact?.tags || [],
|
|
||||||
parentId: contact?.parent?.id,
|
|
||||||
employeeId: contact?.employeeId ?? undefined,
|
|
||||||
customFields: contact?.customFields
|
|
||||||
})
|
|
||||||
|
|
||||||
const [rating, setRating] = useState<number>(contact?.rating || 0)
|
const [rating, setRating] = useState<number>(contact?.rating || 0)
|
||||||
const [newTag, setNewTag] = useState('')
|
const [newTag, setNewTag] = useState('')
|
||||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
|
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
const [employees, setEmployees] = useState<Employee[]>([])
|
const [employees, setEmployees] = useState<Employee[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFormData(buildInitialFormData(contact))
|
||||||
|
setRating(contact?.rating || 0)
|
||||||
|
setNewTag('')
|
||||||
|
setFormErrors({})
|
||||||
|
}, [contact])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
categoriesAPI.getTree().then(setCategories).catch(() => {})
|
categoriesAPI.getTree().then(setCategories).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
@@ -72,7 +79,23 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
|
|
||||||
const isCompanyEmployeeSelected = companyEmployeeCategoryId && (formData.categories || []).includes(companyEmployeeCategoryId)
|
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 validateForm = (): boolean => {
|
||||||
const errors: Record<string, string> = {}
|
const errors: Record<string, string> = {}
|
||||||
|
|
||||||
@@ -103,28 +126,49 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!validateForm()) return
|
if (!validateForm()) return
|
||||||
|
const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, value]) => {
|
||||||
|
const requiredFields = ['type', 'name', 'source', 'country']
|
||||||
|
|
||||||
// Clean up empty strings to undefined for optional fields
|
// keep required fields as-is
|
||||||
const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, value]) => {
|
if (requiredFields.includes(key)) {
|
||||||
// Keep the value if it's not an empty string, or if it's a required field
|
acc[key] = value
|
||||||
if (value !== '' || ['type', 'name', 'source', 'country'].includes(key)) {
|
|
||||||
acc[key] = value
|
|
||||||
}
|
|
||||||
return acc
|
return acc
|
||||||
}, {} as any)
|
}
|
||||||
|
|
||||||
|
// 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) {
|
if (!cleanData.parentId) {
|
||||||
delete cleanData.parentId
|
delete cleanData.parentId
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove categories if empty array
|
|
||||||
if (cleanData.categories && cleanData.categories.length === 0) {
|
if (cleanData.categories && cleanData.categories.length === 0) {
|
||||||
delete cleanData.categories
|
delete cleanData.categories
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove employeeId if empty
|
if (!cleanData.parentId) {
|
||||||
if (!cleanData.employeeId) {
|
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
|
delete cleanData.employeeId
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,16 +196,12 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const showCompanyFields = ['COMPANY', 'HOLDING', 'GOVERNMENT'].includes(formData.type)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} noValidate className="space-y-6">
|
||||||
{/* Basic Information Section */}
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Basic Information</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Basic Information</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{/* Contact Type */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Contact Type <span className="text-red-500">*</span>
|
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="COMPANY">Company - شركة</option>
|
||||||
<option value="HOLDING">Holding - مجموعة</option>
|
<option value="HOLDING">Holding - مجموعة</option>
|
||||||
<option value="GOVERNMENT">Government - حكومي</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>
|
</select>
|
||||||
{formErrors.type && <p className="text-red-500 text-xs mt-1">{formErrors.type}</p>}
|
{formErrors.type && <p className="text-red-500 text-xs mt-1">{formErrors.type}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Source */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Source <span className="text-red-500">*</span>
|
Source <span className="text-red-500">*</span>
|
||||||
@@ -202,37 +249,20 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Name */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
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"
|
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>}
|
{formErrors.name && <p className="text-red-500 text-xs mt-1">{formErrors.name}</p>}
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Rating
|
Rating
|
||||||
@@ -268,12 +298,10 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contact Methods Section */}
|
|
||||||
<div className="pt-6 border-t">
|
<div className="pt-6 border-t">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Contact Methods</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Contact Methods</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{/* Email */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Email
|
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>}
|
{formErrors.email && <p className="text-red-500 text-xs mt-1">{formErrors.email}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Phone */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Phone
|
Phone
|
||||||
@@ -305,7 +332,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{/* Mobile */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Mobile
|
Mobile
|
||||||
@@ -319,7 +345,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Website */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Website
|
Website
|
||||||
@@ -336,44 +361,27 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Company Information Section (conditional) */}
|
|
||||||
{showCompanyFields && (
|
{showCompanyFields && (
|
||||||
<div className="pt-6 border-t">
|
<div className="pt-6 border-t">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Company Information</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Company Information</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{/* Company Name */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Company Name
|
{formData.type === 'EMBASSIES' ? 'Embassy Name' : 'Company / Organization Name'}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.companyName || ''}
|
value={formData.companyName || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, companyName: e.target.value })}
|
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"
|
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>
|
</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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{/* Tax Number */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Tax Number
|
Tax Number
|
||||||
@@ -387,7 +395,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Commercial Register */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Commercial Register
|
Commercial Register
|
||||||
@@ -405,11 +412,9 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Address Section */}
|
|
||||||
<div className="pt-6 border-t">
|
<div className="pt-6 border-t">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Address Information</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Address Information</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Address */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Street Address
|
Street Address
|
||||||
@@ -424,7 +429,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
{/* City */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
City
|
City
|
||||||
@@ -438,7 +442,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Country */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Country
|
Country
|
||||||
@@ -452,7 +455,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Postal Code */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Postal Code
|
Postal Code
|
||||||
@@ -469,7 +471,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Categories Section */}
|
|
||||||
<div className="pt-6 border-t">
|
<div className="pt-6 border-t">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Categories</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Categories</h3>
|
||||||
<CategorySelector
|
<CategorySelector
|
||||||
@@ -479,7 +480,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Employee Link - when Company Employee category is selected */}
|
|
||||||
{isCompanyEmployeeSelected && (
|
{isCompanyEmployeeSelected && (
|
||||||
<div className="pt-6 border-t">
|
<div className="pt-6 border-t">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Link to Employee (Optional)</h3>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tags Section */}
|
|
||||||
<div className="pt-6 border-t">
|
<div className="pt-6 border-t">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Tags</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Tags</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Tag input */}
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -524,7 +522,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tags display */}
|
|
||||||
{formData.tags && formData.tags.length > 0 && (
|
{formData.tags && formData.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{formData.tags.map((tag, index) => (
|
{formData.tags.map((tag, index) => (
|
||||||
@@ -547,7 +544,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Duplicate Detection */}
|
|
||||||
<DuplicateAlert
|
<DuplicateAlert
|
||||||
email={formData.email}
|
email={formData.email}
|
||||||
phone={formData.phone}
|
phone={formData.phone}
|
||||||
@@ -556,14 +552,12 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
commercialRegister={formData.commercialRegister}
|
commercialRegister={formData.commercialRegister}
|
||||||
excludeId={contact?.id}
|
excludeId={contact?.id}
|
||||||
onMerge={(contactId) => {
|
onMerge={(contactId) => {
|
||||||
// Navigate to merge page with pre-selected contacts
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.location.href = `/contacts/merge?sourceId=${contactId}`
|
window.location.href = `/contacts/merge?sourceId=${contactId}`
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Form Actions */}
|
|
||||||
<div className="flex items-center justify-end gap-3 pt-6 border-t">
|
<div className="flex items-center justify-end gap-3 pt-6 border-t">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -99,12 +99,17 @@ const translations = {
|
|||||||
active: 'Active',
|
active: 'Active',
|
||||||
inactive: 'Inactive',
|
inactive: 'Inactive',
|
||||||
archived: 'Archived',
|
archived: 'Archived',
|
||||||
deleted: 'Deleted'
|
deleted: 'Deleted',
|
||||||
|
all: 'All',
|
||||||
|
view: 'View',
|
||||||
|
showing: 'Showing',
|
||||||
|
of: 'of'
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
dashboard: 'Dashboard',
|
dashboard: 'Dashboard',
|
||||||
contacts: 'Contacts',
|
contacts: 'Contacts',
|
||||||
crm: 'CRM',
|
crm: 'CRM',
|
||||||
|
tenders: 'Tenders',
|
||||||
projects: 'Projects',
|
projects: 'Projects',
|
||||||
inventory: 'Inventory',
|
inventory: 'Inventory',
|
||||||
hr: 'HR',
|
hr: 'HR',
|
||||||
@@ -274,7 +279,79 @@ const translations = {
|
|||||||
processing: 'Processing...',
|
processing: 'Processing...',
|
||||||
deleting: 'Deleting...',
|
deleting: 'Deleting...',
|
||||||
deleteDealConfirm: 'Are you sure you want to delete',
|
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: {
|
import: {
|
||||||
title: 'Import Contacts',
|
title: 'Import Contacts',
|
||||||
@@ -339,6 +416,7 @@ const translations = {
|
|||||||
dashboard: 'لوحة التحكم',
|
dashboard: 'لوحة التحكم',
|
||||||
contacts: 'جهات الاتصال',
|
contacts: 'جهات الاتصال',
|
||||||
crm: 'إدارة العملاء',
|
crm: 'إدارة العملاء',
|
||||||
|
tenders: 'المناقصات',
|
||||||
projects: 'المشاريع',
|
projects: 'المشاريع',
|
||||||
inventory: 'المخزون',
|
inventory: 'المخزون',
|
||||||
hr: 'الموارد البشرية',
|
hr: 'الموارد البشرية',
|
||||||
@@ -346,6 +424,36 @@ const translations = {
|
|||||||
settings: 'الإعدادات',
|
settings: 'الإعدادات',
|
||||||
logout: 'تسجيل الخروج'
|
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: {
|
contacts: {
|
||||||
title: 'جهات الاتصال',
|
title: 'جهات الاتصال',
|
||||||
addContact: 'إضافة جهة اتصال',
|
addContact: 'إضافة جهة اتصال',
|
||||||
@@ -508,7 +616,49 @@ const translations = {
|
|||||||
processing: 'جاري المعالجة...',
|
processing: 'جاري المعالجة...',
|
||||||
deleting: 'جاري الحذف...',
|
deleting: 'جاري الحذف...',
|
||||||
deleteDealConfirm: 'هل أنت متأكد من حذف',
|
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: {
|
import: {
|
||||||
title: 'استيراد جهات الاتصال',
|
title: 'استيراد جهات الاتصال',
|
||||||
|
|||||||
65
frontend/src/lib/api/contracts.ts
Normal file
65
frontend/src/lib/api/contracts.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
60
frontend/src/lib/api/costSheets.ts
Normal file
60
frontend/src/lib/api/costSheets.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
76
frontend/src/lib/api/hrAdmin.ts
Normal file
76
frontend/src/lib/api/hrAdmin.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
|
export const hrAdminAPI = {
|
||||||
|
// Leaves
|
||||||
|
getLeaves: async (params?: { employeeId?: string; status?: string; page?: number; pageSize?: number }) => {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
if (params?.employeeId) q.append('employeeId', params.employeeId)
|
||||||
|
if (params?.status) q.append('status', params.status)
|
||||||
|
if (params?.page) q.append('page', String(params.page))
|
||||||
|
if (params?.pageSize) q.append('pageSize', String(params.pageSize))
|
||||||
|
const res = await api.get(`/hr/leaves?${q}`)
|
||||||
|
return { leaves: res.data.data || [], pagination: res.data.pagination }
|
||||||
|
},
|
||||||
|
approveLeave: (id: string) => api.post(`/hr/leaves/${id}/approve`),
|
||||||
|
rejectLeave: (id: string, rejectedReason: string) => api.post(`/hr/leaves/${id}/reject`, { rejectedReason }),
|
||||||
|
|
||||||
|
// Loans
|
||||||
|
getLoans: async (params?: { employeeId?: string; status?: string; page?: number; pageSize?: number }) => {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
if (params?.employeeId) q.append('employeeId', params.employeeId)
|
||||||
|
if (params?.status) q.append('status', params.status)
|
||||||
|
if (params?.page) q.append('page', String(params.page))
|
||||||
|
if (params?.pageSize) q.append('pageSize', String(params.pageSize))
|
||||||
|
const res = await api.get(`/hr/loans?${q}`)
|
||||||
|
return { loans: res.data.data || [], pagination: res.data.pagination }
|
||||||
|
},
|
||||||
|
getLoanById: (id: string) => api.get(`/hr/loans/${id}`),
|
||||||
|
createLoan: (data: { employeeId: string; type: string; amount: number; installments?: number; reason?: string }) =>
|
||||||
|
api.post('/hr/loans', data),
|
||||||
|
approveLoan: (id: string, startDate?: string) => api.post(`/hr/loans/${id}/approve`, { startDate: startDate || new Date().toISOString().split('T')[0] }),
|
||||||
|
rejectLoan: (id: string, rejectedReason: string) => api.post(`/hr/loans/${id}/reject`, { rejectedReason }),
|
||||||
|
payInstallment: (loanId: string, installmentId: string, paidDate?: string) =>
|
||||||
|
api.post(`/hr/loans/${loanId}/pay-installment`, { installmentId, paidDate: paidDate || new Date().toISOString().split('T')[0] }),
|
||||||
|
|
||||||
|
// Purchase Requests
|
||||||
|
getPurchaseRequests: async (params?: { employeeId?: string; status?: string; page?: number; pageSize?: number }) => {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
if (params?.employeeId) q.append('employeeId', params.employeeId)
|
||||||
|
if (params?.status) q.append('status', params.status)
|
||||||
|
if (params?.page) q.append('page', String(params.page))
|
||||||
|
if (params?.pageSize) q.append('pageSize', String(params.pageSize))
|
||||||
|
const res = await api.get(`/hr/purchase-requests?${q}`)
|
||||||
|
return { purchaseRequests: res.data.data || [], pagination: res.data.pagination }
|
||||||
|
},
|
||||||
|
approvePurchaseRequest: (id: string) => api.post(`/hr/purchase-requests/${id}/approve`),
|
||||||
|
rejectPurchaseRequest: (id: string, rejectedReason: string) => api.post(`/hr/purchase-requests/${id}/reject`, { rejectedReason }),
|
||||||
|
|
||||||
|
// Leave Entitlements
|
||||||
|
getLeaveBalance: (employeeId: string, year?: number) => {
|
||||||
|
const q = year ? `?year=${year}` : ''
|
||||||
|
return api.get(`/hr/leave-balance/${employeeId}${q}`).then((r) => r.data.data)
|
||||||
|
},
|
||||||
|
getLeaveEntitlements: (params?: { employeeId?: string; year?: number }) => {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
if (params?.employeeId) q.append('employeeId', params.employeeId)
|
||||||
|
if (params?.year) q.append('year', String(params.year))
|
||||||
|
return api.get(`/hr/leave-entitlements?${q}`).then((r) => r.data.data)
|
||||||
|
},
|
||||||
|
upsertLeaveEntitlement: (data: { employeeId: string; year: number; leaveType: string; totalDays: number; carriedOver?: number; notes?: string }) =>
|
||||||
|
api.post('/hr/leave-entitlements', data),
|
||||||
|
|
||||||
|
// Employee Contracts
|
||||||
|
getContracts: async (params?: { employeeId?: string; status?: string; page?: number; pageSize?: number }) => {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
if (params?.employeeId) q.append('employeeId', params.employeeId)
|
||||||
|
if (params?.status) q.append('status', params.status)
|
||||||
|
if (params?.page) q.append('page', String(params.page))
|
||||||
|
if (params?.pageSize) q.append('pageSize', String(params.pageSize))
|
||||||
|
const res = await api.get(`/hr/contracts?${q}`)
|
||||||
|
return { contracts: res.data.data || [], pagination: res.data.pagination }
|
||||||
|
},
|
||||||
|
createContract: (data: { employeeId: string; type: string; startDate: string; endDate?: string; salary: number; documentUrl?: string; notes?: string }) =>
|
||||||
|
api.post('/hr/contracts', data),
|
||||||
|
updateContract: (id: string, data: Partial<{ type: string; endDate: string; salary: number; status: string; notes: string }>) =>
|
||||||
|
api.put(`/hr/contracts/${id}`, data),
|
||||||
|
}
|
||||||
64
frontend/src/lib/api/invoices.ts
Normal file
64
frontend/src/lib/api/invoices.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
251
frontend/src/lib/api/portal.ts
Normal file
251
frontend/src/lib/api/portal.ts
Normal 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 || []
|
||||||
|
},
|
||||||
|
}
|
||||||
208
frontend/src/lib/api/tenders.ts
Normal file
208
frontend/src/lib/api/tenders.ts
Normal 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
|
||||||
|
},
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user