Compare commits
31 Commits
69d3d21487
...
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 | ||
|
|
ae890ca1c5 | ||
|
|
8edeaf10f5 |
@@ -6,3 +6,6 @@ JWT_SECRET=your-super-secure-jwt-secret-change-this-now-2024
|
||||
|
||||
# Domain
|
||||
DOMAIN=zerp.atmata-group.com
|
||||
|
||||
# Prisma / Database pooling
|
||||
DATABASE_URL=postgresql://postgres:SecurePassword123!ChangeMe@postgres:5432/mind14_crm?schema=public&connection_limit=5&pool_timeout=20&connect_timeout=20
|
||||
174
DEVELOPER_STAGING_DEPLOY.md
Normal file
@@ -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**.
|
||||
BIN
assets/capture--admin-1771758081055.png
Normal file
|
After Width: | Height: | Size: 233 KiB |
BIN
assets/capture--dashboard-1771758051783.png
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
assets/capture--dashboard-1771758179639.png
Normal file
|
After Width: | Height: | Size: 328 KiB |
BIN
assets/capture--dashboard-1771759025109.png
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
assets/capture-latest.png
Normal file
|
After Width: | Height: | Size: 300 KiB |
1
backend/package-lock.json
generated
@@ -2909,7 +2909,6 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
"start": "node dist/server.js",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "ts-node prisma/seed.ts",
|
||||
"prisma:seed": "node prisma/seed.js",
|
||||
"prisma:studio": "prisma studio",
|
||||
"db:clean-and-seed": "node prisma/clean-and-seed.js",
|
||||
"test": "jest"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node prisma/seed.ts"
|
||||
"seed": "node prisma/seed.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.8.0",
|
||||
|
||||
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'
|
||||
);
|
||||
@@ -83,7 +83,7 @@ async function main() {
|
||||
|
||||
console.log('\n🌱 Running seed...\n');
|
||||
const backendDir = path.resolve(__dirname, '..');
|
||||
execSync('node prisma/seed-prod.js', {
|
||||
execSync('node prisma/seed.js', {
|
||||
stdio: 'inherit',
|
||||
cwd: backendDir,
|
||||
env: process.env,
|
||||
|
||||
12
backend/prisma/ensure-gm-permissions.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- Ensure GM has all module permissions
|
||||
-- Run: npx prisma db execute --file prisma/ensure-gm-permissions.sql
|
||||
|
||||
INSERT INTO position_permissions (id, "positionId", module, resource, actions, "createdAt", "updatedAt")
|
||||
SELECT gen_random_uuid(), p.id, m.module, '*', '["*"]', NOW(), NOW()
|
||||
FROM positions p
|
||||
CROSS JOIN (VALUES ('contacts'), ('crm'), ('inventory'), ('projects'), ('hr'), ('marketing'), ('admin')) AS m(module)
|
||||
WHERE p.code = 'GM'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM position_permissions pp
|
||||
WHERE pp."positionId" = p.id AND pp.module = m.module AND pp.resource = '*'
|
||||
);
|
||||
@@ -0,0 +1,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;
|
||||
@@ -0,0 +1,59 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "roles" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"nameAr" TEXT,
|
||||
"description" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "role_permissions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"roleId" TEXT NOT NULL,
|
||||
"module" TEXT NOT NULL,
|
||||
"resource" TEXT NOT NULL,
|
||||
"actions" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_roles" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"roleId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "user_roles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "roles_name_key" ON "roles"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "role_permissions_roleId_module_resource_key" ON "role_permissions"("roleId", "module", "resource");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_roles_userId_idx" ON "user_roles"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_roles_roleId_idx" ON "user_roles"("roleId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_roles_userId_roleId_key" ON "user_roles"("userId", "roleId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -42,7 +42,7 @@ model AuditLog {
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
email String
|
||||
username String @unique
|
||||
password String
|
||||
isActive Boolean @default(true)
|
||||
@@ -69,10 +69,62 @@ model User {
|
||||
assignedTasks Task[]
|
||||
projectMembers ProjectMember[]
|
||||
campaigns Campaign[]
|
||||
userRoles UserRole[]
|
||||
tendersCreated Tender[]
|
||||
tenderDirectivesIssued TenderDirective[]
|
||||
tenderDirectivesCompleted TenderDirective[] @relation("TenderDirectiveCompletedBy")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
// Optional roles - user can belong to multiple permission groups (Phase 3 multi-group)
|
||||
model Role {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
nameAr String?
|
||||
description String?
|
||||
isActive Boolean @default(true)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
permissions RolePermission[]
|
||||
userRoles UserRole[]
|
||||
|
||||
@@map("roles")
|
||||
}
|
||||
|
||||
model RolePermission {
|
||||
id String @id @default(uuid())
|
||||
roleId String
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
module String
|
||||
resource String
|
||||
actions Json // ["read", "create", "update", "delete", ...]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([roleId, module, resource])
|
||||
@@map("role_permissions")
|
||||
}
|
||||
|
||||
model UserRole {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
roleId String
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([userId, roleId])
|
||||
@@index([userId])
|
||||
@@index([roleId])
|
||||
@@map("user_roles")
|
||||
}
|
||||
|
||||
model Employee {
|
||||
id String @id @default(uuid())
|
||||
uniqueEmployeeId String @unique // رقم الموظف الموحد
|
||||
@@ -129,6 +181,9 @@ model Employee {
|
||||
// Documents
|
||||
documents Json? // Array of document references
|
||||
|
||||
// ZK Tico / Attendance device - maps to employee pin on device
|
||||
attendancePin String? @unique
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -143,6 +198,11 @@ model Employee {
|
||||
disciplinaryActions DisciplinaryAction[]
|
||||
allowances Allowance[]
|
||||
commissions Commission[]
|
||||
loans Loan[]
|
||||
purchaseRequests PurchaseRequest[]
|
||||
leaveEntitlements LeaveEntitlement[]
|
||||
employeeContracts EmployeeContract[]
|
||||
tenderDirectivesAssigned TenderDirective[]
|
||||
|
||||
@@index([departmentId])
|
||||
@@index([positionId])
|
||||
@@ -221,12 +281,18 @@ model Attendance {
|
||||
status String // PRESENT, ABSENT, LATE, HALF_DAY, etc.
|
||||
notes String?
|
||||
|
||||
// ZK Tico / External device sync
|
||||
sourceDeviceId String?
|
||||
externalId String?
|
||||
rawData Json?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([employeeId, date])
|
||||
@@index([employeeId])
|
||||
@@index([date])
|
||||
@@index([sourceDeviceId])
|
||||
@@map("attendances")
|
||||
}
|
||||
|
||||
@@ -369,6 +435,115 @@ model DisciplinaryAction {
|
||||
@@map("disciplinary_actions")
|
||||
}
|
||||
|
||||
model Loan {
|
||||
id String @id @default(uuid())
|
||||
employeeId String
|
||||
employee Employee @relation(fields: [employeeId], references: [id])
|
||||
loanNumber String @unique
|
||||
type String // SALARY_ADVANCE, EQUIPMENT, PERSONAL, etc.
|
||||
amount Decimal @db.Decimal(12, 2)
|
||||
currency String @default("SAR")
|
||||
installments Int @default(1)
|
||||
monthlyAmount Decimal? @db.Decimal(12, 2)
|
||||
reason String?
|
||||
status String @default("PENDING") // PENDING, APPROVED, REJECTED, ACTIVE, PAID_OFF
|
||||
approvedBy String?
|
||||
approvedAt DateTime?
|
||||
rejectedReason String?
|
||||
startDate DateTime? @db.Date
|
||||
endDate DateTime? @db.Date
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
installmentsList LoanInstallment[]
|
||||
|
||||
@@index([employeeId])
|
||||
@@index([status])
|
||||
@@map("loans")
|
||||
}
|
||||
|
||||
model LoanInstallment {
|
||||
id String @id @default(uuid())
|
||||
loanId String
|
||||
loan Loan @relation(fields: [loanId], references: [id], onDelete: Cascade)
|
||||
installmentNumber Int
|
||||
dueDate DateTime @db.Date
|
||||
amount Decimal @db.Decimal(12, 2)
|
||||
paidDate DateTime? @db.Date
|
||||
status String @default("PENDING") // PENDING, PAID, OVERDUE
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([loanId, installmentNumber])
|
||||
@@index([loanId])
|
||||
@@map("loan_installments")
|
||||
}
|
||||
|
||||
model PurchaseRequest {
|
||||
id String @id @default(uuid())
|
||||
requestNumber String @unique
|
||||
employeeId String
|
||||
employee Employee @relation(fields: [employeeId], references: [id])
|
||||
items Json // Array of { description, quantity, estimatedPrice, etc. }
|
||||
totalAmount Decimal? @db.Decimal(12, 2)
|
||||
reason String?
|
||||
priority String @default("NORMAL") // LOW, NORMAL, HIGH, URGENT
|
||||
status String @default("PENDING") // PENDING, APPROVED, REJECTED, ORDERED
|
||||
approvedBy String?
|
||||
approvedAt DateTime?
|
||||
rejectedReason String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([employeeId])
|
||||
@@index([status])
|
||||
@@map("purchase_requests")
|
||||
}
|
||||
|
||||
model LeaveEntitlement {
|
||||
id String @id @default(uuid())
|
||||
employeeId String
|
||||
employee Employee @relation(fields: [employeeId], references: [id])
|
||||
year Int
|
||||
leaveType String // ANNUAL, SICK, EMERGENCY, etc.
|
||||
totalDays Int @default(0)
|
||||
usedDays Int @default(0)
|
||||
carriedOver Int @default(0)
|
||||
notes String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([employeeId, year, leaveType])
|
||||
@@index([employeeId])
|
||||
@@map("leave_entitlements")
|
||||
}
|
||||
|
||||
model EmployeeContract {
|
||||
id String @id @default(uuid())
|
||||
employeeId String
|
||||
employee Employee @relation(fields: [employeeId], references: [id])
|
||||
contractNumber String @unique
|
||||
type String // FIXED, UNLIMITED, PROBATION, etc.
|
||||
startDate DateTime @db.Date
|
||||
endDate DateTime? @db.Date
|
||||
salary Decimal @db.Decimal(12, 2)
|
||||
currency String @default("SAR")
|
||||
documentUrl String?
|
||||
status String @default("ACTIVE") // DRAFT, ACTIVE, EXPIRED, TERMINATED
|
||||
notes String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([employeeId])
|
||||
@@index([status])
|
||||
@@map("employee_contracts")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MODULE 1: CONTACT MANAGEMENT
|
||||
// ============================================
|
||||
@@ -439,6 +614,7 @@ model Contact {
|
||||
deals Deal[]
|
||||
attachments Attachment[]
|
||||
notes Note[]
|
||||
tenders Tender[]
|
||||
|
||||
@@index([type])
|
||||
@@index([status])
|
||||
@@ -535,6 +711,10 @@ model Deal {
|
||||
// Status
|
||||
status String @default("ACTIVE") // ACTIVE, WON, LOST, CANCELLED, ARCHIVED
|
||||
|
||||
// Source (when converted from Tender)
|
||||
sourceTenderId String? @unique
|
||||
sourceTender Tender? @relation(fields: [sourceTenderId], references: [id])
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -702,6 +882,66 @@ model Invoice {
|
||||
@@map("invoices")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TENDER MANAGEMENT - إدارة المناقصات
|
||||
// ============================================
|
||||
|
||||
model Tender {
|
||||
id String @id @default(uuid())
|
||||
tenderNumber String @unique
|
||||
issuingBodyName String
|
||||
title String
|
||||
termsValue Decimal @db.Decimal(15, 2)
|
||||
bondValue Decimal @db.Decimal(15, 2)
|
||||
announcementDate DateTime @db.Date
|
||||
closingDate DateTime @db.Date
|
||||
announcementLink String?
|
||||
source String // GOVERNMENT_SITE, OFFICIAL_GAZETTE, PERSONAL, PARTNER, WHATSAPP_TELEGRAM, PORTAL, EMAIL, MANUAL
|
||||
sourceOther String? // Free text when source is MANUAL or other
|
||||
announcementType String // FIRST, RE_ANNOUNCEMENT_2, RE_ANNOUNCEMENT_3, RE_ANNOUNCEMENT_4
|
||||
notes String?
|
||||
status String @default("ACTIVE") // ACTIVE, CONVERTED_TO_DEAL, CANCELLED
|
||||
contactId String? // Optional link to Contact (issuing body)
|
||||
contact Contact? @relation(fields: [contactId], references: [id])
|
||||
createdById String
|
||||
createdBy User @relation(fields: [createdById], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
directives TenderDirective[]
|
||||
attachments Attachment[]
|
||||
convertedDeal Deal?
|
||||
@@index([tenderNumber])
|
||||
@@index([status])
|
||||
@@index([createdById])
|
||||
@@index([announcementDate])
|
||||
@@index([closingDate])
|
||||
@@map("tenders")
|
||||
}
|
||||
|
||||
model TenderDirective {
|
||||
id String @id @default(uuid())
|
||||
tenderId String
|
||||
tender Tender @relation(fields: [tenderId], references: [id], onDelete: Cascade)
|
||||
type String // BUY_TERMS, VISIT_CLIENT, MEET_COMMITTEE, PREPARE_TO_BID
|
||||
notes String?
|
||||
assignedToEmployeeId String
|
||||
assignedToEmployee Employee @relation(fields: [assignedToEmployeeId], references: [id])
|
||||
issuedById String
|
||||
issuedBy User @relation(fields: [issuedById], references: [id])
|
||||
status String @default("PENDING") // PENDING, IN_PROGRESS, COMPLETED, CANCELLED
|
||||
completedAt DateTime?
|
||||
completionNotes String?
|
||||
completedById String?
|
||||
completedBy User? @relation("TenderDirectiveCompletedBy", fields: [completedById], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
attachments Attachment[]
|
||||
@@index([tenderId])
|
||||
@@index([assignedToEmployeeId])
|
||||
@@index([status])
|
||||
@@map("tender_directives")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MODULE 3: INVENTORY & ASSETS
|
||||
// ============================================
|
||||
@@ -1263,6 +1503,10 @@ model Attachment {
|
||||
project Project? @relation(fields: [projectId], references: [id])
|
||||
taskId String?
|
||||
task Task? @relation(fields: [taskId], references: [id])
|
||||
tenderId String?
|
||||
tender Tender? @relation(fields: [tenderId], references: [id], onDelete: Cascade)
|
||||
tenderDirectiveId String?
|
||||
tenderDirective TenderDirective? @relation(fields: [tenderDirectiveId], references: [id], onDelete: Cascade)
|
||||
|
||||
// File Info
|
||||
fileName String
|
||||
@@ -1284,6 +1528,8 @@ model Attachment {
|
||||
@@index([dealId])
|
||||
@@index([projectId])
|
||||
@@index([taskId])
|
||||
@@index([tenderId])
|
||||
@@index([tenderDirectiveId])
|
||||
@@map("attachments")
|
||||
}
|
||||
|
||||
|
||||
148
backend/prisma/seed.js
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Minimal seed - System Administrator only.
|
||||
* Run with: node prisma/seed.js
|
||||
*/
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('🌱 Starting database seeding (minimal - System Administrator only)...');
|
||||
|
||||
const adminDept = await prisma.department.create({
|
||||
data: {
|
||||
name: 'Administration',
|
||||
nameAr: 'الإدارة',
|
||||
code: 'ADMIN',
|
||||
description: 'System administration and configuration',
|
||||
},
|
||||
});
|
||||
|
||||
const sysAdminPosition = await prisma.position.create({
|
||||
data: {
|
||||
title: 'System Administrator',
|
||||
titleAr: 'مدير النظام',
|
||||
code: 'SYS_ADMIN',
|
||||
departmentId: adminDept.id,
|
||||
level: 1,
|
||||
description: 'Full system access - configure and manage all modules',
|
||||
},
|
||||
});
|
||||
|
||||
const modules = ['contacts', 'crm', 'tenders', 'inventory', 'projects', 'hr', 'marketing', 'admin'];
|
||||
for (const module of modules) {
|
||||
await prisma.positionPermission.create({
|
||||
data: {
|
||||
positionId: sysAdminPosition.id,
|
||||
module,
|
||||
resource: '*',
|
||||
actions: ['*'],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Create Sales Department and restricted positions
|
||||
const salesDept = await prisma.department.create({
|
||||
data: {
|
||||
name: 'Sales',
|
||||
nameAr: 'المبيعات',
|
||||
code: 'SALES',
|
||||
description: 'Sales and business development',
|
||||
},
|
||||
});
|
||||
|
||||
const salesRepPosition = await prisma.position.create({
|
||||
data: {
|
||||
title: 'Sales Representative',
|
||||
titleAr: 'مندوب مبيعات',
|
||||
code: 'SALES_REP',
|
||||
departmentId: salesDept.id,
|
||||
level: 3,
|
||||
description: 'Limited access - Contacts and CRM deals',
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.positionPermission.createMany({
|
||||
data: [
|
||||
{ positionId: salesRepPosition.id, module: 'contacts', resource: '*', actions: ['read', 'create', 'update'] },
|
||||
{ positionId: salesRepPosition.id, module: 'crm', resource: 'deals', actions: ['read', 'create', 'update'] },
|
||||
{ positionId: salesRepPosition.id, module: 'tenders', resource: 'tenders', actions: ['read', 'create', 'update'] },
|
||||
{ positionId: salesRepPosition.id, module: 'tenders', resource: 'directives', actions: ['read', 'create', 'update'] },
|
||||
],
|
||||
});
|
||||
|
||||
const accountantPosition = await prisma.position.create({
|
||||
data: {
|
||||
title: 'Accountant',
|
||||
titleAr: 'محاسب',
|
||||
code: 'ACCOUNTANT',
|
||||
departmentId: adminDept.id,
|
||||
level: 2,
|
||||
description: 'HR read, inventory read, contacts read',
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.positionPermission.createMany({
|
||||
data: [
|
||||
{ positionId: accountantPosition.id, module: 'contacts', resource: '*', actions: ['read'] },
|
||||
{ positionId: accountantPosition.id, module: 'crm', resource: '*', actions: ['read'] },
|
||||
{ positionId: accountantPosition.id, module: 'inventory', resource: '*', actions: ['read'] },
|
||||
{ positionId: accountantPosition.id, module: 'hr', resource: '*', actions: ['read'] },
|
||||
],
|
||||
});
|
||||
|
||||
console.log('✅ Created position and permissions');
|
||||
|
||||
const sysAdminEmployee = await prisma.employee.create({
|
||||
data: {
|
||||
uniqueEmployeeId: 'SYS-001',
|
||||
firstName: 'System',
|
||||
lastName: 'Administrator',
|
||||
firstNameAr: 'مدير',
|
||||
lastNameAr: 'النظام',
|
||||
email: 'admin@system.local',
|
||||
mobile: '+966500000000',
|
||||
dateOfBirth: new Date('1990-01-01'),
|
||||
gender: 'MALE',
|
||||
nationality: 'Saudi',
|
||||
employmentType: 'Full-time',
|
||||
contractType: 'Unlimited',
|
||||
hireDate: new Date(),
|
||||
departmentId: adminDept.id,
|
||||
positionId: sysAdminPosition.id,
|
||||
basicSalary: 0,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
const hashedPassword = await bcrypt.hash('Admin@123', 10);
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: 'admin@system.local',
|
||||
username: 'admin',
|
||||
password: hashedPassword,
|
||||
employeeId: sysAdminEmployee.id,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ Created System Administrator');
|
||||
console.log('\n🎉 Database seeding completed!\n');
|
||||
console.log('📋 System Administrator:');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log(' Email: admin@system.local');
|
||||
console.log(' Username: admin');
|
||||
console.log(' Password: Admin@123');
|
||||
console.log(' Access: Full system access (all modules)');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ Error seeding database:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -4,58 +4,50 @@ import bcrypt from 'bcryptjs';
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('🌱 Starting database seeding...');
|
||||
console.log('🌱 Starting database seeding (minimal - System Administrator only)...');
|
||||
|
||||
// Create Departments
|
||||
// Create Administration Department
|
||||
const adminDept = await prisma.department.create({
|
||||
data: {
|
||||
name: 'Administration',
|
||||
nameAr: 'الإدارة',
|
||||
code: 'ADMIN',
|
||||
description: 'System administration and configuration',
|
||||
},
|
||||
});
|
||||
|
||||
// Create System Administrator Position
|
||||
const sysAdminPosition = await prisma.position.create({
|
||||
data: {
|
||||
title: 'System Administrator',
|
||||
titleAr: 'مدير النظام',
|
||||
code: 'SYS_ADMIN',
|
||||
departmentId: adminDept.id,
|
||||
level: 1,
|
||||
description: 'Full system access - configure and manage all modules',
|
||||
},
|
||||
});
|
||||
|
||||
// Create full permissions for all modules
|
||||
const modules = ['contacts', 'crm', 'inventory', 'projects', 'hr', 'marketing', 'admin'];
|
||||
for (const module of modules) {
|
||||
await prisma.positionPermission.create({
|
||||
data: {
|
||||
positionId: sysAdminPosition.id,
|
||||
module,
|
||||
resource: '*',
|
||||
actions: ['*'],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Create Sales Department and restricted positions
|
||||
const salesDept = await prisma.department.create({
|
||||
data: {
|
||||
name: 'Sales Department',
|
||||
nameAr: 'قسم المبيعات',
|
||||
name: 'Sales',
|
||||
nameAr: 'المبيعات',
|
||||
code: 'SALES',
|
||||
description: 'Sales and Business Development',
|
||||
},
|
||||
});
|
||||
|
||||
const itDept = await prisma.department.create({
|
||||
data: {
|
||||
name: 'IT Department',
|
||||
nameAr: 'قسم تقنية المعلومات',
|
||||
code: 'IT',
|
||||
description: 'Information Technology',
|
||||
},
|
||||
});
|
||||
|
||||
const hrDept = await prisma.department.create({
|
||||
data: {
|
||||
name: 'HR Department',
|
||||
nameAr: 'قسم الموارد البشرية',
|
||||
code: 'HR',
|
||||
description: 'Human Resources',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ Created departments');
|
||||
|
||||
// Create Positions
|
||||
const gmPosition = await prisma.position.create({
|
||||
data: {
|
||||
title: 'General Manager',
|
||||
titleAr: 'المدير العام',
|
||||
code: 'GM',
|
||||
departmentId: salesDept.id,
|
||||
level: 1,
|
||||
description: 'Chief Executive - Full Access',
|
||||
},
|
||||
});
|
||||
|
||||
const salesManagerPosition = await prisma.position.create({
|
||||
data: {
|
||||
title: 'Sales Manager',
|
||||
titleAr: 'مدير المبيعات',
|
||||
code: 'SALES_MGR',
|
||||
departmentId: salesDept.id,
|
||||
level: 2,
|
||||
description: 'Sales Department Manager',
|
||||
description: 'Sales and business development',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -66,342 +58,83 @@ async function main() {
|
||||
code: 'SALES_REP',
|
||||
departmentId: salesDept.id,
|
||||
level: 3,
|
||||
description: 'Sales Representative',
|
||||
description: 'Limited access - Contacts and CRM deals',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ Created positions');
|
||||
|
||||
const itSupportPosition = await prisma.position.create({
|
||||
data: {
|
||||
title: 'IT Support',
|
||||
titleAr: 'دعم فني',
|
||||
code: 'IT_SUPPORT',
|
||||
departmentId: itDept.id,
|
||||
level: 4,
|
||||
description: 'IT Support Technician',
|
||||
},
|
||||
});
|
||||
|
||||
const itDeveloperPosition = await prisma.position.create({
|
||||
data: {
|
||||
title: 'Developer',
|
||||
titleAr: 'مطور',
|
||||
code: 'IT_DEV',
|
||||
departmentId: itDept.id,
|
||||
level: 4,
|
||||
description: 'Software Developer',
|
||||
},
|
||||
});
|
||||
|
||||
// Employee position for ALL departments (added)
|
||||
const salesEmployeePosition = await prisma.position.create({
|
||||
data: {
|
||||
title: 'Employee',
|
||||
titleAr: 'موظف',
|
||||
code: 'SALES_EMPLOYEE',
|
||||
departmentId: salesDept.id,
|
||||
level: 5,
|
||||
description: 'General employee - Sales Department',
|
||||
},
|
||||
});
|
||||
|
||||
const itEmployeePosition = await prisma.position.create({
|
||||
data: {
|
||||
title: 'Employee',
|
||||
titleAr: 'موظف',
|
||||
code: 'IT_EMPLOYEE',
|
||||
departmentId: itDept.id,
|
||||
level: 5,
|
||||
description: 'General employee - IT Department',
|
||||
},
|
||||
});
|
||||
|
||||
const hrEmployeePosition = await prisma.position.create({
|
||||
data: {
|
||||
title: 'Employee',
|
||||
titleAr: 'موظف',
|
||||
code: 'HR_EMPLOYEE',
|
||||
departmentId: hrDept.id,
|
||||
level: 5,
|
||||
description: 'General employee - HR Department',
|
||||
},
|
||||
});
|
||||
|
||||
// Create Permissions for GM (Full Access)
|
||||
const modules = ['contacts', 'crm', 'inventory', 'projects', 'hr', 'marketing'];
|
||||
const resources = ['*'];
|
||||
const actions = ['*'];
|
||||
|
||||
for (const module of modules) {
|
||||
await prisma.positionPermission.create({
|
||||
data: {
|
||||
positionId: gmPosition.id,
|
||||
module,
|
||||
resource: resources[0],
|
||||
actions,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Admin permission for GM
|
||||
await prisma.positionPermission.create({
|
||||
data: {
|
||||
positionId: gmPosition.id,
|
||||
module: 'admin',
|
||||
resource: '*',
|
||||
actions: ['*'],
|
||||
},
|
||||
});
|
||||
|
||||
// Create Permissions for Sales Manager
|
||||
await prisma.positionPermission.createMany({
|
||||
data: [
|
||||
{
|
||||
positionId: salesManagerPosition.id,
|
||||
module: 'contacts',
|
||||
resource: 'contacts',
|
||||
actions: ['create', 'read', 'update', 'merge'],
|
||||
},
|
||||
{
|
||||
positionId: salesManagerPosition.id,
|
||||
module: 'crm',
|
||||
resource: 'deals',
|
||||
actions: ['create', 'read', 'update', 'approve'],
|
||||
},
|
||||
{
|
||||
positionId: salesManagerPosition.id,
|
||||
module: 'crm',
|
||||
resource: 'quotes',
|
||||
actions: ['create', 'read', 'update', 'approve'],
|
||||
},
|
||||
{ positionId: salesRepPosition.id, module: 'contacts', resource: '*', actions: ['read', 'create', 'update'] },
|
||||
{ positionId: salesRepPosition.id, module: 'crm', resource: 'deals', actions: ['read', 'create', 'update'] },
|
||||
],
|
||||
});
|
||||
|
||||
// Create Permissions for Sales Rep
|
||||
const accountantPosition = await prisma.position.create({
|
||||
data: {
|
||||
title: 'Accountant',
|
||||
titleAr: 'محاسب',
|
||||
code: 'ACCOUNTANT',
|
||||
departmentId: adminDept.id,
|
||||
level: 2,
|
||||
description: 'HR read, inventory read, contacts read',
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.positionPermission.createMany({
|
||||
data: [
|
||||
{
|
||||
positionId: salesRepPosition.id,
|
||||
module: 'contacts',
|
||||
resource: 'contacts',
|
||||
actions: ['create', 'read', 'update'],
|
||||
},
|
||||
{
|
||||
positionId: salesRepPosition.id,
|
||||
module: 'crm',
|
||||
resource: 'deals',
|
||||
actions: ['create', 'read', 'update'],
|
||||
},
|
||||
{
|
||||
positionId: salesRepPosition.id,
|
||||
module: 'crm',
|
||||
resource: 'quotes',
|
||||
actions: ['create', 'read'],
|
||||
},
|
||||
{ positionId: accountantPosition.id, module: 'contacts', resource: '*', actions: ['read'] },
|
||||
{ positionId: accountantPosition.id, module: 'crm', resource: '*', actions: ['read'] },
|
||||
{ positionId: accountantPosition.id, module: 'inventory', resource: '*', actions: ['read'] },
|
||||
{ positionId: accountantPosition.id, module: 'hr', resource: '*', actions: ['read'] },
|
||||
],
|
||||
});
|
||||
|
||||
console.log('✅ Created permissions');
|
||||
console.log('✅ Created position and permissions');
|
||||
|
||||
// Create Employees
|
||||
const gmEmployee = await prisma.employee.create({
|
||||
// Create minimal Employee for System Administrator
|
||||
const sysAdminEmployee = await prisma.employee.create({
|
||||
data: {
|
||||
uniqueEmployeeId: 'EMP-2024-0001',
|
||||
firstName: 'Ahmed',
|
||||
lastName: 'Al-Mutairi',
|
||||
firstNameAr: 'أحمد',
|
||||
lastNameAr: 'المطيري',
|
||||
email: 'gm@atmata.com',
|
||||
mobile: '+966500000001',
|
||||
dateOfBirth: new Date('1980-01-01'),
|
||||
uniqueEmployeeId: 'SYS-001',
|
||||
firstName: 'System',
|
||||
lastName: 'Administrator',
|
||||
firstNameAr: 'مدير',
|
||||
lastNameAr: 'النظام',
|
||||
email: 'admin@system.local',
|
||||
mobile: '+966500000000',
|
||||
dateOfBirth: new Date('1990-01-01'),
|
||||
gender: 'MALE',
|
||||
nationality: 'Saudi',
|
||||
employmentType: 'Full-time',
|
||||
contractType: 'Unlimited',
|
||||
hireDate: new Date('2020-01-01'),
|
||||
departmentId: salesDept.id,
|
||||
positionId: gmPosition.id,
|
||||
basicSalary: 50000,
|
||||
hireDate: new Date(),
|
||||
departmentId: adminDept.id,
|
||||
positionId: sysAdminPosition.id,
|
||||
basicSalary: 0,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
const salesManagerEmployee = await prisma.employee.create({
|
||||
data: {
|
||||
uniqueEmployeeId: 'EMP-2024-0002',
|
||||
firstName: 'Fatima',
|
||||
lastName: 'Al-Zahrani',
|
||||
firstNameAr: 'فاطمة',
|
||||
lastNameAr: 'الزهراني',
|
||||
email: 'sales.manager@atmata.com',
|
||||
mobile: '+966500000002',
|
||||
dateOfBirth: new Date('1985-05-15'),
|
||||
gender: 'FEMALE',
|
||||
nationality: 'Saudi',
|
||||
employmentType: 'Full-time',
|
||||
contractType: 'Unlimited',
|
||||
hireDate: new Date('2021-06-01'),
|
||||
departmentId: salesDept.id,
|
||||
positionId: salesManagerPosition.id,
|
||||
reportingToId: gmEmployee.id,
|
||||
basicSalary: 25000,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
const salesRepEmployee = await prisma.employee.create({
|
||||
data: {
|
||||
uniqueEmployeeId: 'EMP-2024-0003',
|
||||
firstName: 'Mohammed',
|
||||
lastName: 'Al-Qahtani',
|
||||
firstNameAr: 'محمد',
|
||||
lastNameAr: 'القحطاني',
|
||||
email: 'sales.rep@atmata.com',
|
||||
mobile: '+966500000003',
|
||||
dateOfBirth: new Date('1992-08-20'),
|
||||
gender: 'MALE',
|
||||
nationality: 'Saudi',
|
||||
employmentType: 'Full-time',
|
||||
contractType: 'Fixed',
|
||||
hireDate: new Date('2023-01-15'),
|
||||
departmentId: salesDept.id,
|
||||
positionId: salesRepPosition.id,
|
||||
reportingToId: salesManagerEmployee.id,
|
||||
basicSalary: 12000,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ Created employees');
|
||||
|
||||
// Create Users
|
||||
// Create System Administrator User
|
||||
const hashedPassword = await bcrypt.hash('Admin@123', 10);
|
||||
|
||||
const gmUser = await prisma.user.create({
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: 'gm@atmata.com',
|
||||
email: 'admin@system.local',
|
||||
username: 'admin',
|
||||
password: hashedPassword,
|
||||
employeeId: gmEmployee.id,
|
||||
employeeId: sysAdminEmployee.id,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
const salesManagerUser = await prisma.user.create({
|
||||
data: {
|
||||
email: 'sales.manager@atmata.com',
|
||||
username: 'salesmanager',
|
||||
password: hashedPassword,
|
||||
employeeId: salesManagerEmployee.id,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
console.log('✅ Created System Administrator');
|
||||
|
||||
const salesRepUser = await prisma.user.create({
|
||||
data: {
|
||||
email: 'sales.rep@atmata.com',
|
||||
username: 'salesrep',
|
||||
password: hashedPassword,
|
||||
employeeId: salesRepEmployee.id,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ Created users');
|
||||
|
||||
// Create Contact Categories
|
||||
await prisma.contactCategory.createMany({
|
||||
data: [
|
||||
{ name: 'Customer', nameAr: 'عميل', description: 'Paying customers' },
|
||||
{ name: 'Supplier', nameAr: 'مورد', description: 'Product/Service suppliers' },
|
||||
{ name: 'Partner', nameAr: 'شريك', description: 'Business partners' },
|
||||
{ name: 'Lead', nameAr: 'عميل محتمل', description: 'Potential customers' },
|
||||
{ name: 'Company Employee', nameAr: 'موظف الشركة', description: 'Internal company staff' },
|
||||
],
|
||||
});
|
||||
|
||||
console.log('✅ Created contact categories');
|
||||
|
||||
// Create Product Categories
|
||||
await prisma.productCategory.createMany({
|
||||
data: [
|
||||
{ name: 'Electronics', nameAr: 'إلكترونيات', code: 'ELEC' },
|
||||
{ name: 'Software', nameAr: 'برمجيات', code: 'SOFT' },
|
||||
{ name: 'Services', nameAr: 'خدمات', code: 'SERV' },
|
||||
],
|
||||
});
|
||||
|
||||
console.log('✅ Created product categories');
|
||||
|
||||
// Create Pipelines
|
||||
await prisma.pipeline.create({
|
||||
data: {
|
||||
name: 'B2B Sales Pipeline',
|
||||
nameAr: 'مسار مبيعات الشركات',
|
||||
structure: 'B2B',
|
||||
stages: [
|
||||
{ name: 'OPEN', nameAr: 'مفتوحة', order: 1 },
|
||||
{ name: 'QUALIFIED', nameAr: 'مؤهلة', order: 2 },
|
||||
{ name: 'NEGOTIATION', nameAr: 'تفاوض', order: 3 },
|
||||
{ name: 'PROPOSAL', nameAr: 'عرض سعر', order: 4 },
|
||||
{ name: 'WON', nameAr: 'فازت', order: 5 },
|
||||
{ name: 'LOST', nameAr: 'خسرت', order: 6 },
|
||||
],
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.pipeline.create({
|
||||
data: {
|
||||
name: 'B2C Sales Pipeline',
|
||||
nameAr: 'مسار مبيعات الأفراد',
|
||||
structure: 'B2C',
|
||||
stages: [
|
||||
{ name: 'LEAD', nameAr: 'عميل محتمل', order: 1 },
|
||||
{ name: 'CONTACTED', nameAr: 'تم التواصل', order: 2 },
|
||||
{ name: 'QUALIFIED', nameAr: 'مؤهل', order: 3 },
|
||||
{ name: 'WON', nameAr: 'بيع', order: 4 },
|
||||
{ name: 'LOST', nameAr: 'خسارة', order: 5 },
|
||||
],
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ Created pipelines');
|
||||
|
||||
// Create sample warehouse
|
||||
await prisma.warehouse.create({
|
||||
data: {
|
||||
code: 'WH-MAIN',
|
||||
name: 'Main Warehouse',
|
||||
nameAr: 'المستودع الرئيسي',
|
||||
type: 'MAIN',
|
||||
city: 'Riyadh',
|
||||
country: 'Saudi Arabia',
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ Created warehouse');
|
||||
|
||||
console.log('\n🎉 Database seeding completed successfully!\n');
|
||||
console.log('📋 Default Users Created:');
|
||||
console.log('\n🎉 Database seeding completed!\n');
|
||||
console.log('📋 System Administrator:');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('1. General Manager');
|
||||
console.log(' Email: gm@atmata.com');
|
||||
console.log(' Email: admin@system.local');
|
||||
console.log(' Username: admin');
|
||||
console.log(' Password: Admin@123');
|
||||
console.log(' Access: Full System Access');
|
||||
console.log('');
|
||||
console.log('2. Sales Manager');
|
||||
console.log(' Email: sales.manager@atmata.com');
|
||||
console.log(' Password: Admin@123');
|
||||
console.log(' Access: Contacts, CRM with approvals');
|
||||
console.log('');
|
||||
console.log('3. Sales Representative');
|
||||
console.log(' Email: sales.rep@atmata.com');
|
||||
console.log(' Password: Admin@123');
|
||||
console.log(' Access: Basic Contacts and CRM');
|
||||
console.log(' Access: Full system access (all modules)');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
||||
}
|
||||
|
||||
@@ -413,4 +146,3 @@ main()
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
|
||||
51
backend/scripts/ensure-gm-permissions.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Ensure GM position has all module permissions.
|
||||
* Adds any missing permissions for: contacts, crm, inventory, projects, hr, marketing, admin
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const GM_MODULES = ['contacts', 'crm', 'inventory', 'projects', 'hr', 'marketing', 'admin'];
|
||||
|
||||
async function main() {
|
||||
const gmPosition = await prisma.position.findFirst({ where: { code: 'GM' } });
|
||||
if (!gmPosition) {
|
||||
console.log('GM position not found.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const existing = await prisma.positionPermission.findMany({
|
||||
where: { positionId: gmPosition.id },
|
||||
select: { module: true },
|
||||
});
|
||||
const existingModules = new Set(existing.map((p) => p.module));
|
||||
|
||||
let added = 0;
|
||||
for (const module of GM_MODULES) {
|
||||
if (existingModules.has(module)) continue;
|
||||
await prisma.positionPermission.create({
|
||||
data: {
|
||||
positionId: gmPosition.id,
|
||||
module,
|
||||
resource: '*',
|
||||
actions: ['*'],
|
||||
},
|
||||
});
|
||||
console.log(`Added permission: ${module}`);
|
||||
added++;
|
||||
}
|
||||
|
||||
if (added === 0) {
|
||||
console.log('All GM permissions already exist.');
|
||||
} else {
|
||||
console.log(`Added ${added} permission(s).`);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -53,4 +53,4 @@ npm run db:clean-and-seed
|
||||
|
||||
echo ""
|
||||
echo "✅ Done. Restart the application so it uses the cleaned database."
|
||||
echo " Default logins: gm@atmata.com / sales.manager@atmata.com / sales.rep@atmata.com (Password: Admin@123)"
|
||||
echo " System Administrator: admin@system.local (Password: Admin@123)"
|
||||
|
||||
@@ -18,7 +18,18 @@ export const config = {
|
||||
},
|
||||
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGIN?.split(',') || ['http://localhost:3000'],
|
||||
origin: (() => {
|
||||
const envOrigins = process.env.CORS_ORIGIN?.split(',').map((o) => o.trim()).filter(Boolean);
|
||||
const defaults = ['http://localhost:3000', 'http://localhost:5173'];
|
||||
const origins = envOrigins?.length ? envOrigins : defaults;
|
||||
// In development, always allow both common dev server origins
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
['http://localhost:3000', 'http://localhost:5173'].forEach((o) => {
|
||||
if (!origins.includes(o)) origins.push(o);
|
||||
});
|
||||
}
|
||||
return origins;
|
||||
})(),
|
||||
},
|
||||
|
||||
upload: {
|
||||
|
||||
@@ -136,17 +136,15 @@ class AdminController {
|
||||
|
||||
async createPosition(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const position = await adminService.createPosition(
|
||||
{
|
||||
const position = await adminService.createPosition({
|
||||
title: req.body.title,
|
||||
titleAr: req.body.titleAr,
|
||||
code: req.body.code,
|
||||
departmentId: req.body.departmentId,
|
||||
level: req.body.level,
|
||||
code: req.body.code,
|
||||
},
|
||||
userId
|
||||
);
|
||||
description: req.body.description,
|
||||
isActive: req.body.isActive,
|
||||
});
|
||||
res.status(201).json(ResponseFormatter.success(position));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -155,15 +153,15 @@ class AdminController {
|
||||
|
||||
async updatePosition(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const position = await adminService.updatePosition(
|
||||
req.params.id,
|
||||
{
|
||||
const position = await adminService.updatePosition(req.params.id, {
|
||||
title: req.body.title,
|
||||
titleAr: req.body.titleAr,
|
||||
},
|
||||
userId
|
||||
);
|
||||
code: req.body.code,
|
||||
departmentId: req.body.departmentId,
|
||||
level: req.body.level,
|
||||
description: req.body.description,
|
||||
isActive: req.body.isActive,
|
||||
});
|
||||
res.json(ResponseFormatter.success(position));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -182,11 +180,69 @@ class AdminController {
|
||||
}
|
||||
}
|
||||
|
||||
async deletePosition(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
// ========== PERMISSION GROUPS (Phase 3) ==========
|
||||
|
||||
async getPermissionGroups(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
await adminService.deletePosition(req.params.id, userId);
|
||||
res.json(ResponseFormatter.success(null, 'Role deleted successfully'));
|
||||
const groups = await adminService.getPermissionGroups();
|
||||
res.json(ResponseFormatter.success(groups));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createPermissionGroup(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const group = await adminService.createPermissionGroup(req.body);
|
||||
res.status(201).json(ResponseFormatter.success(group));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updatePermissionGroup(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const group = await adminService.updatePermissionGroup(req.params.id, req.body);
|
||||
res.json(ResponseFormatter.success(group));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updatePermissionGroupPermissions(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const group = await adminService.updatePermissionGroupPermissions(
|
||||
req.params.id,
|
||||
req.body.permissions
|
||||
);
|
||||
res.json(ResponseFormatter.success(group));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getUserRoles(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const roles = await adminService.getUserRoles(req.params.userId);
|
||||
res.json(ResponseFormatter.success(roles));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async assignUserRole(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const userRole = await adminService.assignUserRole(req.params.userId, req.body.roleId);
|
||||
res.status(201).json(ResponseFormatter.success(userRole));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async removeUserRole(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await adminService.removeUserRole(req.params.userId, req.params.roleId);
|
||||
res.json(ResponseFormatter.success({ success: true }));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -89,43 +89,33 @@ router.get(
|
||||
adminController.getPositions
|
||||
);
|
||||
|
||||
// Create role
|
||||
router.post(
|
||||
'/positions',
|
||||
authorize('admin', 'roles', 'create'),
|
||||
[
|
||||
body('title').notEmpty().trim(),
|
||||
body('titleAr').optional().isString().trim(),
|
||||
body('code').notEmpty().trim(),
|
||||
body('departmentId').isUUID(),
|
||||
body('level').optional().isInt({ min: 1 }),
|
||||
body('code').optional().isString().trim(),
|
||||
],
|
||||
validate,
|
||||
adminController.createPosition
|
||||
);
|
||||
|
||||
// Update role name (title/titleAr)
|
||||
router.put(
|
||||
'/positions/:id',
|
||||
authorize('admin', 'roles', 'update'),
|
||||
[
|
||||
param('id').isUUID(),
|
||||
body('title').optional().notEmpty().trim(),
|
||||
body('titleAr').optional().isString().trim(),
|
||||
body('code').optional().notEmpty().trim(),
|
||||
body('departmentId').optional().isUUID(),
|
||||
body('level').optional().isInt({ min: 1 }),
|
||||
],
|
||||
validate,
|
||||
adminController.updatePosition
|
||||
);
|
||||
|
||||
// Delete (soft delete) a role/position
|
||||
router.delete(
|
||||
'/positions/:id',
|
||||
authorize('admin', 'roles', 'delete'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
adminController.deletePosition
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/positions/:id/permissions',
|
||||
authorize('admin', 'roles', 'update'),
|
||||
@@ -137,4 +127,68 @@ router.put(
|
||||
adminController.updatePositionPermissions
|
||||
);
|
||||
|
||||
// ========== PERMISSION GROUPS (Phase 3 - multi-group) ==========
|
||||
|
||||
router.get(
|
||||
'/permission-groups',
|
||||
authorize('admin', 'roles', 'read'),
|
||||
adminController.getPermissionGroups
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/permission-groups',
|
||||
authorize('admin', 'roles', 'create'),
|
||||
[
|
||||
body('name').notEmpty().trim(),
|
||||
],
|
||||
validate,
|
||||
adminController.createPermissionGroup
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/permission-groups/:id',
|
||||
authorize('admin', 'roles', 'update'),
|
||||
[param('id').isUUID()],
|
||||
validate,
|
||||
adminController.updatePermissionGroup
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/permission-groups/:id/permissions',
|
||||
authorize('admin', 'roles', 'update'),
|
||||
[
|
||||
param('id').isUUID(),
|
||||
body('permissions').isArray(),
|
||||
],
|
||||
validate,
|
||||
adminController.updatePermissionGroupPermissions
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/users/:userId/roles',
|
||||
authorize('admin', 'users', 'read'),
|
||||
[param('userId').isUUID()],
|
||||
validate,
|
||||
adminController.getUserRoles
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/users/:userId/roles',
|
||||
authorize('admin', 'users', 'update'),
|
||||
[
|
||||
param('userId').isUUID(),
|
||||
body('roleId').isUUID(),
|
||||
],
|
||||
validate,
|
||||
adminController.assignUserRole
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/users/:userId/roles/:roleId',
|
||||
authorize('admin', 'users', 'update'),
|
||||
[param('userId').isUUID(), param('roleId').isUUID()],
|
||||
validate,
|
||||
adminController.removeUserRole
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -39,19 +39,6 @@ export interface AuditLogFilters {
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface CreatePositionData {
|
||||
title: string;
|
||||
titleAr?: string;
|
||||
departmentId: string;
|
||||
level?: number;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePositionData {
|
||||
title?: string;
|
||||
titleAr?: string;
|
||||
}
|
||||
|
||||
class AdminService {
|
||||
// ========== USERS ==========
|
||||
|
||||
@@ -107,7 +94,7 @@ class AdminService {
|
||||
]);
|
||||
|
||||
const sanitized = users.map((u) => {
|
||||
const { password: _, ...rest } = u as any;
|
||||
const { password: _, ...rest } = u;
|
||||
return rest;
|
||||
});
|
||||
|
||||
@@ -137,7 +124,7 @@ class AdminService {
|
||||
throw new AppError(404, 'المستخدم غير موجود - User not found');
|
||||
}
|
||||
|
||||
const { password: _, ...rest } = user as any;
|
||||
const { password: _, ...rest } = user;
|
||||
return rest;
|
||||
}
|
||||
|
||||
@@ -161,7 +148,7 @@ class AdminService {
|
||||
throw new AppError(400, 'هذا الموظف مرتبط بحساب مستخدم بالفعل - Employee already has a user account');
|
||||
}
|
||||
|
||||
const emailExists = await prisma.user.findUnique({
|
||||
const emailExists = await prisma.user.findFirst({
|
||||
where: { email: data.email },
|
||||
});
|
||||
if (emailExists) {
|
||||
@@ -219,7 +206,7 @@ class AdminService {
|
||||
}
|
||||
|
||||
if (data.email && data.email !== existing.email) {
|
||||
const emailExists = await prisma.user.findUnique({ where: { email: data.email } });
|
||||
const emailExists = await prisma.user.findFirst({ where: { email: data.email } });
|
||||
if (emailExists) {
|
||||
throw new AppError(400, 'البريد الإلكتروني مستخدم بالفعل - Email already in use');
|
||||
}
|
||||
@@ -236,7 +223,7 @@ class AdminService {
|
||||
...(data.email && { email: data.email }),
|
||||
...(data.username && { username: data.username }),
|
||||
...(data.isActive !== undefined && { isActive: data.isActive }),
|
||||
...(data.employeeId !== undefined && { employeeId: (data.employeeId as any) || null }),
|
||||
...(data.employeeId !== undefined && { employeeId: data.employeeId || null }),
|
||||
};
|
||||
|
||||
if (data.password && data.password.length >= 8) {
|
||||
@@ -255,7 +242,7 @@ class AdminService {
|
||||
},
|
||||
});
|
||||
|
||||
const { password: _, ...sanitized } = user as any;
|
||||
const { password: _, ...sanitized } = user;
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'USER',
|
||||
@@ -286,7 +273,7 @@ class AdminService {
|
||||
},
|
||||
});
|
||||
|
||||
const { password: _, ...sanitized } = updated as any;
|
||||
const { password: _, ...sanitized } = updated;
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'USER',
|
||||
@@ -393,7 +380,7 @@ class AdminService {
|
||||
const positions = await prisma.position.findMany({
|
||||
where: { isActive: true },
|
||||
include: {
|
||||
department: { select: { id: true, name: true, nameAr: true } },
|
||||
department: { select: { name: true, nameAr: true } },
|
||||
permissions: true,
|
||||
_count: {
|
||||
select: {
|
||||
@@ -419,118 +406,100 @@ class AdminService {
|
||||
return withUserCount;
|
||||
}
|
||||
|
||||
private async generateUniqueCode(base: string) {
|
||||
const cleaned = (base || 'ROLE')
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.slice(0, 18) || 'ROLE';
|
||||
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const suffix = Math.floor(1000 + Math.random() * 9000);
|
||||
const code = `${cleaned}_${suffix}`;
|
||||
const exists = await prisma.position.findUnique({ where: { code } });
|
||||
if (!exists) return code;
|
||||
async createPosition(data: {
|
||||
title: string;
|
||||
titleAr?: string;
|
||||
code: string;
|
||||
departmentId: string;
|
||||
level?: number;
|
||||
description?: string;
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const existing = await prisma.position.findUnique({
|
||||
where: { code: data.code },
|
||||
});
|
||||
if (existing) {
|
||||
throw new AppError(400, 'كود الدور مستخدم - Position code already exists');
|
||||
}
|
||||
|
||||
// fallback
|
||||
return `${cleaned}_${Date.now()}`;
|
||||
}
|
||||
|
||||
async createPosition(data: CreatePositionData, createdById: string) {
|
||||
const title = (data.title || '').trim();
|
||||
const titleAr = (data.titleAr || '').trim();
|
||||
|
||||
if (!title && !titleAr) {
|
||||
throw new AppError(400, 'اسم الدور مطلوب - Role name is required');
|
||||
}
|
||||
|
||||
const department = await prisma.department.findUnique({
|
||||
const dept = await prisma.department.findUnique({
|
||||
where: { id: data.departmentId },
|
||||
});
|
||||
|
||||
if (!department || !department.isActive) {
|
||||
if (!dept) {
|
||||
throw new AppError(400, 'القسم غير موجود - Department not found');
|
||||
}
|
||||
|
||||
let code = (data.code || '').trim();
|
||||
if (code) {
|
||||
code = code.toUpperCase().replace(/[^A-Z0-9_]+/g, '_');
|
||||
const exists = await prisma.position.findUnique({ where: { code } });
|
||||
if (exists) {
|
||||
throw new AppError(400, 'الكود مستخدم بالفعل - Code already exists');
|
||||
}
|
||||
} else {
|
||||
code = await this.generateUniqueCode(title || titleAr || 'ROLE');
|
||||
}
|
||||
|
||||
const level = Number.isFinite(data.level as any) ? Math.max(1, Number(data.level)) : 1;
|
||||
|
||||
const created = await prisma.position.create({
|
||||
return prisma.position.create({
|
||||
data: {
|
||||
title: title || titleAr,
|
||||
titleAr: titleAr || null,
|
||||
code,
|
||||
title: data.title,
|
||||
titleAr: data.titleAr,
|
||||
code: data.code.trim().toUpperCase().replace(/\s+/g, '_'),
|
||||
departmentId: data.departmentId,
|
||||
level,
|
||||
level: data.level ?? 5,
|
||||
description: data.description,
|
||||
isActive: data.isActive ?? true,
|
||||
},
|
||||
include: {
|
||||
department: { select: { name: true, nameAr: true } },
|
||||
permissions: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'POSITION',
|
||||
entityId: created.id,
|
||||
action: 'CREATE',
|
||||
userId: createdById,
|
||||
changes: {
|
||||
created: {
|
||||
title: created.title,
|
||||
titleAr: created.titleAr,
|
||||
code: created.code,
|
||||
departmentId: created.departmentId,
|
||||
level: created.level,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const all = await this.getPositions();
|
||||
return all.find((p: any) => p.id === created.id) || created;
|
||||
}
|
||||
|
||||
async updatePosition(positionId: string, data: UpdatePositionData, updatedById: string) {
|
||||
const existing = await prisma.position.findUnique({ where: { id: positionId } });
|
||||
if (!existing) {
|
||||
async updatePosition(
|
||||
positionId: string,
|
||||
data: {
|
||||
title?: string;
|
||||
titleAr?: string;
|
||||
code?: string;
|
||||
departmentId?: string;
|
||||
level?: number;
|
||||
description?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
) {
|
||||
const position = await prisma.position.findUnique({
|
||||
where: { id: positionId },
|
||||
});
|
||||
if (!position) {
|
||||
throw new AppError(404, 'الدور غير موجود - Position not found');
|
||||
}
|
||||
|
||||
const nextTitle = data.title !== undefined ? (data.title || '').trim() : existing.title;
|
||||
const nextTitleAr = data.titleAr !== undefined ? (data.titleAr || '').trim() : (existing.titleAr || '');
|
||||
|
||||
const finalTitle = nextTitle || nextTitleAr;
|
||||
if (!finalTitle) {
|
||||
throw new AppError(400, 'اسم الدور مطلوب - Role name is required');
|
||||
if (data.code && data.code !== position.code) {
|
||||
const existing = await prisma.position.findUnique({
|
||||
where: { code: data.code },
|
||||
});
|
||||
if (existing) {
|
||||
throw new AppError(400, 'كود الدور مستخدم - Position code already exists');
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.position.update({
|
||||
if (data.departmentId && data.departmentId !== position.departmentId) {
|
||||
const dept = await prisma.department.findUnique({
|
||||
where: { id: data.departmentId },
|
||||
});
|
||||
if (!dept) {
|
||||
throw new AppError(400, 'القسم غير موجود - Department not found');
|
||||
}
|
||||
}
|
||||
|
||||
const updateData: Record<string, any> = {};
|
||||
if (data.title !== undefined) updateData.title = data.title;
|
||||
if (data.titleAr !== undefined) updateData.titleAr = data.titleAr;
|
||||
if (data.code !== undefined) updateData.code = data.code.trim().toUpperCase().replace(/\s+/g, '_');
|
||||
if (data.departmentId !== undefined) updateData.departmentId = data.departmentId;
|
||||
if (data.level !== undefined) updateData.level = data.level;
|
||||
if (data.description !== undefined) updateData.description = data.description;
|
||||
if (data.isActive !== undefined) updateData.isActive = data.isActive;
|
||||
|
||||
return prisma.position.update({
|
||||
where: { id: positionId },
|
||||
data: {
|
||||
title: finalTitle,
|
||||
titleAr: nextTitleAr ? nextTitleAr : null,
|
||||
data: updateData,
|
||||
include: {
|
||||
department: { select: { name: true, nameAr: true } },
|
||||
permissions: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'POSITION',
|
||||
entityId: positionId,
|
||||
action: 'UPDATE',
|
||||
userId: updatedById,
|
||||
changes: {
|
||||
before: { title: existing.title, titleAr: existing.titleAr },
|
||||
after: { title: updated.title, titleAr: updated.titleAr },
|
||||
},
|
||||
});
|
||||
|
||||
const all = await this.getPositions();
|
||||
return all.find((p: any) => p.id === positionId) || updated;
|
||||
}
|
||||
|
||||
async updatePositionPermissions(positionId: string, permissions: Array<{ module: string; resource: string; actions: string[] }>) {
|
||||
@@ -554,57 +523,116 @@ class AdminService {
|
||||
});
|
||||
}
|
||||
|
||||
return this.getPositions().then((pos: any) => pos.find((p: any) => p.id === positionId) || position);
|
||||
return this.getPositions().then((pos) => pos.find((p) => p.id === positionId) || position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete a role (Position).
|
||||
* - Prevent deletion if the position is assigned to any employees.
|
||||
* - Clean up position permissions.
|
||||
*/
|
||||
async deletePosition(positionId: string, deletedById: string) {
|
||||
const position = await prisma.position.findUnique({
|
||||
where: { id: positionId },
|
||||
// ========== PERMISSION GROUPS (Phase 3 - optional roles for multi-group) ==========
|
||||
|
||||
async getPermissionGroups() {
|
||||
return prisma.role.findMany({
|
||||
where: { isActive: true },
|
||||
include: {
|
||||
_count: { select: { employees: true } },
|
||||
permissions: true,
|
||||
_count: { select: { userRoles: true } },
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
if (!position) {
|
||||
throw new AppError(404, 'الدور غير موجود - Position not found');
|
||||
}
|
||||
|
||||
if (position._count.employees > 0) {
|
||||
throw new AppError(
|
||||
400,
|
||||
'لا يمكن حذف هذا الدور لأنه مرتبط بموظفين. قم بتغيير دور الموظفين أولاً - Cannot delete: position is assigned to employees'
|
||||
);
|
||||
async createPermissionGroup(data: { name: string; nameAr?: string; description?: string }) {
|
||||
const existing = await prisma.role.findUnique({ where: { name: data.name } });
|
||||
if (existing) {
|
||||
throw new AppError(400, 'اسم المجموعة مستخدم - Group name already exists');
|
||||
}
|
||||
return prisma.role.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
nameAr: data.nameAr,
|
||||
description: data.description,
|
||||
},
|
||||
include: { permissions: true },
|
||||
});
|
||||
}
|
||||
|
||||
// Soft delete the position
|
||||
await prisma.position.update({
|
||||
where: { id: positionId },
|
||||
data: { isActive: false },
|
||||
async updatePermissionGroup(
|
||||
id: string,
|
||||
data: { name?: string; nameAr?: string; description?: string; isActive?: boolean }
|
||||
) {
|
||||
const role = await prisma.role.findUnique({ where: { id } });
|
||||
if (!role) {
|
||||
throw new AppError(404, 'المجموعة غير موجودة - Group not found');
|
||||
}
|
||||
if (data.name && data.name !== role.name) {
|
||||
const existing = await prisma.role.findUnique({ where: { name: data.name } });
|
||||
if (existing) {
|
||||
throw new AppError(400, 'اسم المجموعة مستخدم - Group name already exists');
|
||||
}
|
||||
}
|
||||
return prisma.role.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: { permissions: true },
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up permissions linked to this position
|
||||
await prisma.positionPermission.deleteMany({
|
||||
where: { positionId },
|
||||
async updatePermissionGroupPermissions(
|
||||
roleId: string,
|
||||
permissions: Array<{ module: string; resource: string; actions: string[] }>
|
||||
) {
|
||||
await prisma.rolePermission.deleteMany({ where: { roleId } });
|
||||
if (permissions.length > 0) {
|
||||
await prisma.rolePermission.createMany({
|
||||
data: permissions.map((p) => ({
|
||||
roleId,
|
||||
module: p.module,
|
||||
resource: p.resource,
|
||||
actions: p.actions,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return prisma.role.findUnique({
|
||||
where: { id: roleId },
|
||||
include: { permissions: true },
|
||||
});
|
||||
}
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'POSITION',
|
||||
entityId: positionId,
|
||||
action: 'DELETE',
|
||||
userId: deletedById,
|
||||
changes: {
|
||||
softDeleted: true,
|
||||
title: position.title,
|
||||
titleAr: position.titleAr,
|
||||
code: position.code,
|
||||
async getUserRoles(userId: string) {
|
||||
return prisma.userRole.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
role: { include: { permissions: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async assignUserRole(userId: string, roleId: string) {
|
||||
const [user, role] = await Promise.all([
|
||||
prisma.user.findUnique({ where: { id: userId } }),
|
||||
prisma.role.findFirst({ where: { id: roleId, isActive: true } }),
|
||||
]);
|
||||
if (!user) throw new AppError(404, 'المستخدم غير موجود - User not found');
|
||||
if (!role) throw new AppError(404, 'المجموعة غير موجودة - Group not found');
|
||||
|
||||
const existing = await prisma.userRole.findUnique({
|
||||
where: { userId_roleId: { userId, roleId } },
|
||||
});
|
||||
if (existing) {
|
||||
throw new AppError(400, 'المستخدم منتمي بالفعل لهذه المجموعة - User already in group');
|
||||
}
|
||||
|
||||
return prisma.userRole.create({
|
||||
data: { userId, roleId },
|
||||
include: { role: true },
|
||||
});
|
||||
}
|
||||
|
||||
async removeUserRole(userId: string, roleId: string) {
|
||||
const deleted = await prisma.userRole.deleteMany({
|
||||
where: { userId, roleId },
|
||||
});
|
||||
if (deleted.count === 0) {
|
||||
throw new AppError(404, 'لم يتم العثور على الانتماء - User not in group');
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response } from 'express'
|
||||
import { authService } from './auth.service'
|
||||
import { AuthRequest } from '@/shared/middleware/auth'
|
||||
import { AuthRequest } from '../../shared/middleware/auth'
|
||||
|
||||
export const authController = {
|
||||
register: async (req: Request, res: Response) => {
|
||||
@@ -21,17 +21,27 @@ export const authController = {
|
||||
|
||||
login: async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
const { email, password } = req.body
|
||||
const result = await authService.login(email, password)
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'الرجاء إدخال البريد/اسم المستخدم وكلمة المرور'
|
||||
})
|
||||
}
|
||||
|
||||
const result = await authService.login(String(email).trim(), String(password))
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'تم تسجيل الدخول بنجاح',
|
||||
data: result
|
||||
})
|
||||
} catch (error: any) {
|
||||
res.status(401).json({
|
||||
res.status(error?.statusCode || 401).json({
|
||||
success: false,
|
||||
message: error.message
|
||||
message: error.message || 'بيانات الدخول غير صحيحة'
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -30,7 +30,7 @@ router.post(
|
||||
router.post(
|
||||
'/login',
|
||||
[
|
||||
body('email').isEmail().withMessage('البريد الإلكتروني غير صالح'),
|
||||
body('email').trim().notEmpty().withMessage('البريد الإلكتروني أو اسم المستخدم مطلوب'),
|
||||
body('password').notEmpty().withMessage('كلمة المرور مطلوبة'),
|
||||
],
|
||||
validate,
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt, { Secret, SignOptions } from 'jsonwebtoken';
|
||||
import prisma from '../../config/database';
|
||||
import { config } from '../../config';
|
||||
import { AppError } from '../../shared/middleware/errorHandler';
|
||||
import bcrypt from 'bcryptjs'
|
||||
import jwt, { Secret, SignOptions } from 'jsonwebtoken'
|
||||
import prisma from '../../config/database'
|
||||
import { config } from '../../config'
|
||||
import { AppError } from '../../shared/middleware/errorHandler'
|
||||
|
||||
class AuthService {
|
||||
async register(data: {
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
employeeId?: string;
|
||||
email: string
|
||||
username: string
|
||||
password: string
|
||||
employeeId?: string
|
||||
}) {
|
||||
// Hash password
|
||||
const hashedPassword = await bcrypt.hash(data.password, config.security.bcryptRounds);
|
||||
const hashedPassword = await bcrypt.hash(
|
||||
data.password,
|
||||
config.security.bcryptRounds
|
||||
)
|
||||
|
||||
// Create user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: data.email,
|
||||
username: data.username,
|
||||
password: hashedPassword,
|
||||
employeeId: data.employeeId,
|
||||
employeeId: data.employeeId
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -28,120 +29,140 @@ class AuthService {
|
||||
username: true,
|
||||
employeeId: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
createdAt: true
|
||||
}
|
||||
})
|
||||
|
||||
// Generate tokens
|
||||
const tokens = this.generateTokens(user.id, user.email);
|
||||
const tokens = this.generateTokens(user.id, user.email)
|
||||
|
||||
// Save refresh token
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { refreshToken: tokens.refreshToken },
|
||||
});
|
||||
data: { refreshToken: tokens.refreshToken }
|
||||
})
|
||||
|
||||
return {
|
||||
user,
|
||||
...tokens,
|
||||
};
|
||||
...tokens
|
||||
}
|
||||
}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
// Find user with employee info and permissions
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
|
||||
async login(emailOrUsername: string, password: string) {
|
||||
const identifier = (emailOrUsername || '').trim()
|
||||
const isEmail = identifier.includes('@')
|
||||
|
||||
let user: any = null
|
||||
|
||||
if (isEmail) {
|
||||
const matches = await prisma.user.findMany({
|
||||
where: { email: identifier },
|
||||
include: {
|
||||
employee: {
|
||||
include: {
|
||||
position: {
|
||||
position: { include: { permissions: true } },
|
||||
department: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (matches.length === 0) {
|
||||
throw new AppError(401, 'بيانات الدخول غير صحيحة - Invalid credentials')
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
throw new AppError(
|
||||
400,
|
||||
'هذا البريد مستخدم لأكثر من حساب. الرجاء تسجيل الدخول باسم المستخدم - Use username'
|
||||
)
|
||||
}
|
||||
|
||||
user = matches[0]
|
||||
} else {
|
||||
user = await prisma.user.findUnique({
|
||||
where: { username: identifier },
|
||||
include: {
|
||||
permissions: true,
|
||||
},
|
||||
},
|
||||
department: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
employee: {
|
||||
include: {
|
||||
position: { include: { permissions: true } },
|
||||
department: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(401, 'بيانات الدخول غير صحيحة - Invalid credentials');
|
||||
throw new AppError(401, 'بيانات الدخول غير صحيحة - Invalid credentials')
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if (!user.isActive) {
|
||||
throw new AppError(403, 'الحساب غير مفعل - Account is inactive');
|
||||
throw new AppError(403, 'الحساب غير مفعل - Account is inactive')
|
||||
}
|
||||
|
||||
// Check if account is locked
|
||||
if (user.lockedUntil && user.lockedUntil > new Date()) {
|
||||
throw new AppError(403, 'الحساب مقفل مؤقتاً - Account is temporarily locked');
|
||||
throw new AppError(403, 'الحساب مقفل مؤقتاً - Account is temporarily locked')
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isPasswordValid = await bcrypt.compare(password, user.password);
|
||||
const isPasswordValid = await bcrypt.compare(password, user.password)
|
||||
|
||||
if (!isPasswordValid) {
|
||||
// Increment failed login attempts
|
||||
const failedAttempts = user.failedLoginAttempts + 1;
|
||||
const updateData: any = { failedLoginAttempts: failedAttempts };
|
||||
const failedAttempts = (user.failedLoginAttempts || 0) + 1
|
||||
const updateData: any = { failedLoginAttempts: failedAttempts }
|
||||
|
||||
// Lock account after 5 failed attempts
|
||||
if (failedAttempts >= 5) {
|
||||
updateData.lockedUntil = new Date(Date.now() + 30 * 60 * 1000); // Lock for 30 minutes
|
||||
updateData.lockedUntil = new Date(Date.now() + 30 * 60 * 1000)
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: updateData,
|
||||
});
|
||||
data: updateData
|
||||
})
|
||||
|
||||
throw new AppError(401, 'بيانات الدخول غير صحيحة - Invalid credentials');
|
||||
throw new AppError(401, 'بيانات الدخول غير صحيحة - Invalid credentials')
|
||||
}
|
||||
|
||||
// Check HR requirement: Must have active employee record
|
||||
if (!user.employee || user.employee.status !== 'ACTIVE') {
|
||||
throw new AppError(403, 'الوصول مرفوض - Access denied. Active employee record required.');
|
||||
throw new AppError(
|
||||
403,
|
||||
'الوصول مرفوض - Access denied. Active employee record required.'
|
||||
)
|
||||
}
|
||||
|
||||
// Reset failed attempts
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
failedLoginAttempts: 0,
|
||||
lockedUntil: null,
|
||||
lastLogin: new Date(),
|
||||
},
|
||||
});
|
||||
lastLogin: new Date()
|
||||
}
|
||||
})
|
||||
|
||||
// Generate tokens
|
||||
const tokens = this.generateTokens(user.id, user.email);
|
||||
const tokens = this.generateTokens(user.id, user.email)
|
||||
|
||||
// Save refresh token
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { refreshToken: tokens.refreshToken },
|
||||
});
|
||||
data: { refreshToken: tokens.refreshToken }
|
||||
})
|
||||
|
||||
// Return user data without password, with role info
|
||||
const { password: _, ...userWithoutPassword } = user;
|
||||
const { password: _pw, ...userWithoutPassword } = user
|
||||
|
||||
// Format role and permissions
|
||||
const role = user.employee?.position ? {
|
||||
const role = user.employee?.position
|
||||
? {
|
||||
id: user.employee.position.id,
|
||||
name: user.employee.position.titleAr || user.employee.position.title,
|
||||
nameEn: user.employee.position.title,
|
||||
permissions: user.employee.position.permissions || []
|
||||
} : null;
|
||||
}
|
||||
: null
|
||||
|
||||
return {
|
||||
user: {
|
||||
...userWithoutPassword,
|
||||
role
|
||||
},
|
||||
...tokens,
|
||||
};
|
||||
...tokens
|
||||
}
|
||||
}
|
||||
|
||||
async getUserById(userId: string) {
|
||||
@@ -150,77 +171,57 @@ class AuthService {
|
||||
include: {
|
||||
employee: {
|
||||
include: {
|
||||
position: {
|
||||
include: {
|
||||
permissions: true,
|
||||
},
|
||||
},
|
||||
department: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(404, 'المستخدم غير موجود - User not found');
|
||||
position: { include: { permissions: true } },
|
||||
department: true
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
throw new AppError(403, 'الحساب غير مفعل - Account is inactive');
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Format user data
|
||||
const { password: _, ...userWithoutPassword } = user;
|
||||
if (!user) throw new AppError(404, 'المستخدم غير موجود - User not found')
|
||||
if (!user.isActive) throw new AppError(403, 'الحساب غير مفعل - Account is inactive')
|
||||
|
||||
const role = user.employee?.position ? {
|
||||
const { password: _pw, ...userWithoutPassword } = user
|
||||
|
||||
const role = user.employee?.position
|
||||
? {
|
||||
id: user.employee.position.id,
|
||||
name: user.employee.position.titleAr || user.employee.position.title,
|
||||
nameEn: user.employee.position.title,
|
||||
permissions: user.employee.position.permissions || []
|
||||
} : null;
|
||||
}
|
||||
: null
|
||||
|
||||
return {
|
||||
...userWithoutPassword,
|
||||
role
|
||||
};
|
||||
return { ...userWithoutPassword, role }
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken: string) {
|
||||
try {
|
||||
const decoded = jwt.verify(refreshToken, config.jwt.secret) as {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
// Verify refresh token matches stored token
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.id },
|
||||
});
|
||||
const decoded = jwt.verify(refreshToken, config.jwt.secret) as { id: string; email: string }
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: decoded.id } })
|
||||
if (!user || user.refreshToken !== refreshToken || !user.isActive) {
|
||||
throw new AppError(401, 'رمز غير صالح - Invalid token');
|
||||
throw new AppError(401, 'رمز غير صالح - Invalid token')
|
||||
}
|
||||
|
||||
// Generate new tokens
|
||||
const tokens = this.generateTokens(user.id, user.email);
|
||||
const tokens = this.generateTokens(user.id, user.email)
|
||||
|
||||
// Update refresh token
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { refreshToken: tokens.refreshToken },
|
||||
});
|
||||
data: { refreshToken: tokens.refreshToken }
|
||||
})
|
||||
|
||||
return tokens;
|
||||
} catch (error) {
|
||||
throw new AppError(401, 'رمز غير صالح - Invalid token');
|
||||
return tokens
|
||||
} catch {
|
||||
throw new AppError(401, 'رمز غير صالح - Invalid token')
|
||||
}
|
||||
}
|
||||
|
||||
async logout(userId: string) {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { refreshToken: null },
|
||||
});
|
||||
data: { refreshToken: null }
|
||||
})
|
||||
}
|
||||
|
||||
async getUserProfile(userId: string) {
|
||||
@@ -234,47 +235,35 @@ class AuthService {
|
||||
lastLogin: true,
|
||||
employee: {
|
||||
include: {
|
||||
position: {
|
||||
include: {
|
||||
permissions: true,
|
||||
},
|
||||
},
|
||||
department: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(404, 'المستخدم غير موجود - User not found');
|
||||
position: { include: { permissions: true } },
|
||||
department: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return user;
|
||||
if (!user) throw new AppError(404, 'المستخدم غير موجود - User not found')
|
||||
return user
|
||||
}
|
||||
|
||||
private generateTokens(userId: string, email: string) {
|
||||
const payload = { id: userId, email };
|
||||
const secret = config.jwt.secret as Secret;
|
||||
const payload = { id: userId, email }
|
||||
const secret = config.jwt.secret as Secret
|
||||
|
||||
const accessToken = jwt.sign(
|
||||
payload,
|
||||
secret,
|
||||
{ expiresIn: config.jwt.expiresIn } as SignOptions
|
||||
);
|
||||
const accessToken = jwt.sign(payload, secret, {
|
||||
expiresIn: config.jwt.expiresIn
|
||||
} as SignOptions)
|
||||
|
||||
const refreshToken = jwt.sign(
|
||||
payload,
|
||||
secret,
|
||||
{ expiresIn: config.jwt.refreshExpiresIn } as SignOptions
|
||||
);
|
||||
const refreshToken = jwt.sign(payload, secret, {
|
||||
expiresIn: config.jwt.refreshExpiresIn
|
||||
} as SignOptions)
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: config.jwt.expiresIn,
|
||||
};
|
||||
expiresIn: config.jwt.expiresIn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService();
|
||||
|
||||
export const authService = new AuthService()
|
||||
@@ -42,7 +42,8 @@ router.post(
|
||||
'/',
|
||||
authorize('contacts', 'contacts', 'create'),
|
||||
[
|
||||
body('type').isIn(['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT']),
|
||||
body('type').isIn(['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT','ORGANIZATION','EMBASSIES',
|
||||
'BANK','UNIVERSITY','SCHOOL','UN','NGO','INSTITUTION',]),
|
||||
body('name').notEmpty().trim(),
|
||||
body('email').optional().isEmail(),
|
||||
body('source').notEmpty(),
|
||||
@@ -57,7 +58,29 @@ router.put(
|
||||
authorize('contacts', 'contacts', 'update'),
|
||||
[
|
||||
param('id').isUUID(),
|
||||
body('email').optional().isEmail(),
|
||||
body('type')
|
||||
.optional()
|
||||
.isIn([
|
||||
'INDIVIDUAL',
|
||||
'COMPANY',
|
||||
'HOLDING',
|
||||
'GOVERNMENT',
|
||||
'ORGANIZATION',
|
||||
'EMBASSIES',
|
||||
'BANK',
|
||||
'UNIVERSITY',
|
||||
'SCHOOL',
|
||||
'UN',
|
||||
'NGO',
|
||||
'INSTITUTION',
|
||||
]),
|
||||
body('email')
|
||||
.optional({ values: 'falsy' })
|
||||
.custom((value) => {
|
||||
if (value === null || value === undefined || value === '') return true
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
||||
})
|
||||
.withMessage('Invalid email format'),
|
||||
validate,
|
||||
],
|
||||
contactsController.update
|
||||
|
||||
@@ -330,9 +330,10 @@ class ContactsService {
|
||||
const contact = await prisma.contact.update({
|
||||
where: { id },
|
||||
data: {
|
||||
type: data.type,
|
||||
name: data.name,
|
||||
nameAr: data.nameAr,
|
||||
email: data.email,
|
||||
email: data.email === '' || data.email === undefined ? null : data.email,
|
||||
phone: data.phone,
|
||||
mobile: data.mobile,
|
||||
website: data.website,
|
||||
@@ -344,11 +345,14 @@ class ContactsService {
|
||||
city: data.city,
|
||||
country: data.country,
|
||||
postalCode: data.postalCode,
|
||||
categories: data.categories ? {
|
||||
set: data.categories.map(id => ({ id }))
|
||||
} : undefined,
|
||||
categories: data.categories
|
||||
? {
|
||||
set: data.categories.map((id) => ({ id })),
|
||||
}
|
||||
: undefined,
|
||||
tags: data.tags,
|
||||
employeeId: data.employeeId !== undefined ? (data.employeeId || null) : undefined,
|
||||
employeeId:
|
||||
data.employeeId !== undefined ? (data.employeeId || null) : undefined,
|
||||
source: data.source,
|
||||
status: data.status,
|
||||
rating: data.rating,
|
||||
@@ -367,7 +371,7 @@ class ContactsService {
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Log audit
|
||||
await AuditLogger.log({
|
||||
@@ -679,7 +683,7 @@ class ContactsService {
|
||||
}
|
||||
|
||||
// Validate type
|
||||
if (!['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT'].includes(row.type)) {
|
||||
if (!['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT', 'ORGANIZATION','EMBASSIES','BANK','UNIVERSITY','SCHOOL','UN','NGO','INSTITUTION',].includes(row.type)) {
|
||||
results.errors.push({
|
||||
row: rowNumber,
|
||||
field: 'type',
|
||||
|
||||
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
@@ -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
@@ -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
@@ -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 { body, param } from 'express-validator';
|
||||
import { pipelinesController, dealsController, quotesController } from './crm.controller';
|
||||
import { costSheetsController } from './costSheets.controller';
|
||||
import { contractsController } from './contracts.controller';
|
||||
import { invoicesController } from './invoices.controller';
|
||||
import { authenticate, authorize } from '../../shared/middleware/auth';
|
||||
import { validate } from '../../shared/middleware/validation';
|
||||
|
||||
@@ -171,5 +174,153 @@ router.post(
|
||||
quotesController.send
|
||||
);
|
||||
|
||||
// ============= COST SHEETS =============
|
||||
|
||||
router.get(
|
||||
'/deals/:dealId/cost-sheets',
|
||||
authorize('crm', 'deals', 'read'),
|
||||
param('dealId').isUUID(),
|
||||
validate,
|
||||
costSheetsController.findByDeal
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/cost-sheets/:id',
|
||||
authorize('crm', 'deals', 'read'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
costSheetsController.findById
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/cost-sheets',
|
||||
authorize('crm', 'deals', 'create'),
|
||||
[
|
||||
body('dealId').isUUID(),
|
||||
body('items').isArray(),
|
||||
body('totalCost').isNumeric(),
|
||||
body('suggestedPrice').isNumeric(),
|
||||
body('profitMargin').isNumeric(),
|
||||
validate,
|
||||
],
|
||||
costSheetsController.create
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/cost-sheets/:id/approve',
|
||||
authorize('crm', 'deals', 'update'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
costSheetsController.approve
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/cost-sheets/:id/reject',
|
||||
authorize('crm', 'deals', 'update'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
costSheetsController.reject
|
||||
);
|
||||
|
||||
// ============= CONTRACTS =============
|
||||
|
||||
router.get(
|
||||
'/deals/:dealId/contracts',
|
||||
authorize('crm', 'deals', 'read'),
|
||||
param('dealId').isUUID(),
|
||||
validate,
|
||||
contractsController.findByDeal
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/contracts/:id',
|
||||
authorize('crm', 'deals', 'read'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
contractsController.findById
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/contracts',
|
||||
authorize('crm', 'deals', 'create'),
|
||||
[
|
||||
body('dealId').isUUID(),
|
||||
body('title').notEmpty().trim(),
|
||||
body('type').notEmpty().trim(),
|
||||
body('startDate').isISO8601(),
|
||||
body('value').isNumeric(),
|
||||
body('terms').notEmpty().trim(),
|
||||
validate,
|
||||
],
|
||||
contractsController.create
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/contracts/:id',
|
||||
authorize('crm', 'deals', 'update'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
contractsController.update
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/contracts/:id/sign',
|
||||
authorize('crm', 'deals', 'update'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
contractsController.markSigned
|
||||
);
|
||||
|
||||
// ============= INVOICES =============
|
||||
|
||||
router.get(
|
||||
'/deals/:dealId/invoices',
|
||||
authorize('crm', 'deals', 'read'),
|
||||
param('dealId').isUUID(),
|
||||
validate,
|
||||
invoicesController.findByDeal
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/invoices/:id',
|
||||
authorize('crm', 'deals', 'read'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
invoicesController.findById
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/invoices',
|
||||
authorize('crm', 'deals', 'create'),
|
||||
[
|
||||
body('items').isArray(),
|
||||
body('subtotal').isNumeric(),
|
||||
body('taxAmount').isNumeric(),
|
||||
body('total').isNumeric(),
|
||||
body('dueDate').isISO8601(),
|
||||
validate,
|
||||
],
|
||||
invoicesController.create
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/invoices/:id',
|
||||
authorize('crm', 'deals', 'update'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
invoicesController.update
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/invoices/:id/record-payment',
|
||||
authorize('crm', 'deals', 'update'),
|
||||
[
|
||||
param('id').isUUID(),
|
||||
body('paidAmount').isNumeric(),
|
||||
validate,
|
||||
],
|
||||
invoicesController.recordPayment
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
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
@@ -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();
|
||||
39
backend/src/modules/dashboard/dashboard.controller.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Response } from 'express';
|
||||
import prisma from '../../config/database';
|
||||
import { AuthRequest } from '../../shared/middleware/auth';
|
||||
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
|
||||
|
||||
class DashboardController {
|
||||
async getStats(req: AuthRequest, res: Response) {
|
||||
const userId = req.user!.id;
|
||||
|
||||
const [contactsCount, activeTasksCount, unreadNotificationsCount] = await Promise.all([
|
||||
prisma.contact.count({
|
||||
where: {
|
||||
archivedAt: null,
|
||||
},
|
||||
}),
|
||||
prisma.task.count({
|
||||
where: {
|
||||
status: { notIn: ['COMPLETED', 'CANCELLED'] },
|
||||
},
|
||||
}),
|
||||
prisma.notification.count({
|
||||
where: {
|
||||
userId,
|
||||
isRead: false,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
res.json(
|
||||
ResponseFormatter.success({
|
||||
contacts: contactsCount,
|
||||
activeTasks: activeTasksCount,
|
||||
notifications: unreadNotificationsCount,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new DashboardController();
|
||||
9
backend/src/modules/dashboard/dashboard.routes.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../../shared/middleware/auth';
|
||||
import dashboardController from './dashboard.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/stats', authenticate, dashboardController.getStats.bind(dashboardController));
|
||||
|
||||
export default router;
|
||||
@@ -19,14 +19,20 @@ export class HRController {
|
||||
|
||||
async findAllEmployees(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const pageSize = parseInt(req.query.pageSize as string) || 20;
|
||||
const rawPage = parseInt(req.query.page as string, 10);
|
||||
const rawPageSize = parseInt(req.query.pageSize as string, 10);
|
||||
const page = Number.isNaN(rawPage) || rawPage < 1 ? 1 : rawPage;
|
||||
const pageSize = Number.isNaN(rawPageSize) || rawPageSize < 1 || rawPageSize > 100 ? 20 : rawPageSize;
|
||||
|
||||
const filters = {
|
||||
search: req.query.search,
|
||||
departmentId: req.query.departmentId,
|
||||
status: req.query.status,
|
||||
};
|
||||
const rawSearch = req.query.search as string;
|
||||
const rawDepartmentId = req.query.departmentId as string;
|
||||
const rawStatus = req.query.status as string;
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
const filters: Record<string, string | undefined> = {};
|
||||
if (rawSearch && typeof rawSearch === 'string' && rawSearch.trim()) filters.search = rawSearch.trim();
|
||||
if (rawDepartmentId && uuidRegex.test(rawDepartmentId)) filters.departmentId = rawDepartmentId;
|
||||
if (rawStatus && rawStatus !== 'all' && rawStatus.trim()) filters.status = rawStatus;
|
||||
|
||||
const result = await hrService.findAllEmployees(filters, page, pageSize);
|
||||
res.json(ResponseFormatter.paginated(result.employees, result.total, result.page, result.pageSize));
|
||||
@@ -92,6 +98,16 @@ export class HRController {
|
||||
}
|
||||
}
|
||||
|
||||
async bulkSyncAttendance(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { deviceId, records } = req.body;
|
||||
const results = await hrService.bulkSyncAttendanceFromDevice(deviceId, records || [], req.user!.id);
|
||||
res.json(ResponseFormatter.success(results, 'تم مزامنة الحضور - Attendance synced'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== LEAVES ==========
|
||||
|
||||
async createLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
@@ -112,6 +128,29 @@ export class HRController {
|
||||
}
|
||||
}
|
||||
|
||||
async rejectLeave(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { rejectedReason } = req.body;
|
||||
const leave = await hrService.rejectLeave(req.params.id, rejectedReason || '', req.user!.id);
|
||||
res.json(ResponseFormatter.success(leave, 'تم رفض طلب الإجازة - Leave rejected'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findAllLeaves(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20));
|
||||
const employeeId = req.query.employeeId as string | undefined;
|
||||
const status = req.query.status as string | undefined;
|
||||
const result = await hrService.findAllLeaves({ employeeId, status }, page, pageSize);
|
||||
res.json(ResponseFormatter.paginated(result.leaves, result.total, result.page, result.pageSize));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== SALARIES ==========
|
||||
|
||||
async processSalary(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
@@ -135,6 +174,42 @@ export class HRController {
|
||||
}
|
||||
}
|
||||
|
||||
async getDepartmentsHierarchy(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const tree = await hrService.getDepartmentsHierarchy();
|
||||
res.json(ResponseFormatter.success(tree));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createDepartment(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const department = await hrService.createDepartment(req.body, req.user!.id);
|
||||
res.status(201).json(ResponseFormatter.success(department, 'تم إضافة القسم بنجاح - Department created'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateDepartment(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const department = await hrService.updateDepartment(req.params.id, req.body, req.user!.id);
|
||||
res.json(ResponseFormatter.success(department, 'تم تحديث القسم - Department updated'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteDepartment(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await hrService.deleteDepartment(req.params.id, req.user!.id);
|
||||
res.json(ResponseFormatter.success({ success: true }, 'تم حذف القسم - Department deleted'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== POSITIONS ==========
|
||||
|
||||
async findAllPositions(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
@@ -145,6 +220,198 @@ export class HRController {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== LOANS ==========
|
||||
|
||||
async findAllLoans(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20));
|
||||
const employeeId = req.query.employeeId as string | undefined;
|
||||
const status = req.query.status as string | undefined;
|
||||
const result = await hrService.findAllLoans({ employeeId, status }, page, pageSize);
|
||||
res.json(ResponseFormatter.paginated(result.loans, result.total, result.page, result.pageSize));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findLoanById(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const loan = await hrService.findLoanById(req.params.id);
|
||||
res.json(ResponseFormatter.success(loan));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createLoan(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const loan = await hrService.createLoan(req.body, req.user!.id);
|
||||
res.status(201).json(ResponseFormatter.success(loan, 'تم إنشاء طلب القرض - Loan request created'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async approveLoan(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { startDate } = req.body;
|
||||
const loan = await hrService.approveLoan(req.params.id, req.user!.id, startDate ? new Date(startDate) : new Date(), req.user!.id);
|
||||
res.json(ResponseFormatter.success(loan, 'تمت الموافقة على القرض - Loan approved'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async rejectLoan(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { rejectedReason } = req.body;
|
||||
const loan = await hrService.rejectLoan(req.params.id, rejectedReason || '', req.user!.id);
|
||||
res.json(ResponseFormatter.success(loan, 'تم رفض القرض - Loan rejected'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async recordLoanInstallmentPayment(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { installmentId, paidDate } = req.body;
|
||||
const loan = await hrService.recordLoanInstallmentPayment(req.params.id, installmentId, paidDate ? new Date(paidDate) : new Date(), req.user!.id);
|
||||
res.json(ResponseFormatter.success(loan, 'تم تسجيل الدفعة - Payment recorded'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== PURCHASE REQUESTS ==========
|
||||
|
||||
async findAllPurchaseRequests(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20));
|
||||
const employeeId = req.query.employeeId as string | undefined;
|
||||
const status = req.query.status as string | undefined;
|
||||
const result = await hrService.findAllPurchaseRequests({ employeeId, status }, page, pageSize);
|
||||
res.json(ResponseFormatter.paginated(result.purchaseRequests, result.total, result.page, result.pageSize));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findPurchaseRequestById(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const pr = await hrService.findPurchaseRequestById(req.params.id);
|
||||
res.json(ResponseFormatter.success(pr));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const pr = await hrService.createPurchaseRequest(req.body, req.user!.id);
|
||||
res.status(201).json(ResponseFormatter.success(pr, 'تم إنشاء طلب الشراء - Purchase request created'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async approvePurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const pr = await hrService.approvePurchaseRequest(req.params.id, req.user!.id, req.user!.id);
|
||||
res.json(ResponseFormatter.success(pr, 'تمت الموافقة على طلب الشراء - Purchase request approved'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async rejectPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { rejectedReason } = req.body;
|
||||
const pr = await hrService.rejectPurchaseRequest(req.params.id, rejectedReason || '', req.user!.id);
|
||||
res.json(ResponseFormatter.success(pr, 'تم رفض طلب الشراء - Purchase request rejected'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== LEAVE ENTITLEMENTS ==========
|
||||
|
||||
async getLeaveBalance(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const employeeId = req.params.employeeId || req.query.employeeId as string;
|
||||
const year = parseInt(req.query.year as string) || new Date().getFullYear();
|
||||
const balance = await hrService.getLeaveBalance(employeeId, year);
|
||||
res.json(ResponseFormatter.success(balance));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findAllLeaveEntitlements(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const employeeId = req.query.employeeId as string | undefined;
|
||||
const year = req.query.year ? parseInt(req.query.year as string) : undefined;
|
||||
const list = await hrService.findAllLeaveEntitlements(employeeId, year);
|
||||
res.json(ResponseFormatter.success(list));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async upsertLeaveEntitlement(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const ent = await hrService.upsertLeaveEntitlement(req.body, req.user!.id);
|
||||
res.json(ResponseFormatter.success(ent, 'تم حفظ رصيد الإجازة - Leave entitlement saved'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== EMPLOYEE CONTRACTS ==========
|
||||
|
||||
async findAllEmployeeContracts(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20));
|
||||
const employeeId = req.query.employeeId as string | undefined;
|
||||
const status = req.query.status as string | undefined;
|
||||
const result = await hrService.findAllEmployeeContracts({ employeeId, status }, page, pageSize);
|
||||
res.json(ResponseFormatter.paginated(result.contracts, result.total, result.page, result.pageSize));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findEmployeeContractById(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const c = await hrService.findEmployeeContractById(req.params.id);
|
||||
res.json(ResponseFormatter.success(c));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createEmployeeContract(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = { ...req.body, startDate: new Date(req.body.startDate), endDate: req.body.endDate ? new Date(req.body.endDate) : undefined };
|
||||
const c = await hrService.createEmployeeContract(data, req.user!.id);
|
||||
res.status(201).json(ResponseFormatter.success(c, 'تم إنشاء العقد - Contract created'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateEmployeeContract(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = { ...req.body, endDate: req.body.endDate ? new Date(req.body.endDate) : undefined };
|
||||
const c = await hrService.updateEmployeeContract(req.params.id, data, req.user!.id);
|
||||
res.json(ResponseFormatter.success(c, 'تم تحديث العقد - Contract updated'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const hrController = new HRController();
|
||||
|
||||
@@ -1,12 +1,64 @@
|
||||
import { Router } from 'express';
|
||||
import { body, param } from 'express-validator';
|
||||
import { hrController } from './hr.controller';
|
||||
import { portalController } from './portal.controller';
|
||||
import { authenticate, authorize } from '../../shared/middleware/auth';
|
||||
import { validate } from '../../shared/middleware/validation';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate);
|
||||
|
||||
// ========== EMPLOYEE PORTAL (authenticate only, scoped by employeeId) ==========
|
||||
|
||||
router.get('/portal/me', portalController.getMe);
|
||||
router.get('/portal/loans', portalController.getMyLoans);
|
||||
router.post('/portal/loans', portalController.submitLoanRequest);
|
||||
router.get('/portal/leave-balance', portalController.getMyLeaveBalance);
|
||||
router.get('/portal/leaves', portalController.getMyLeaves);
|
||||
router.post('/portal/leaves', portalController.submitLeaveRequest);
|
||||
|
||||
router.get(
|
||||
'/portal/managed-leaves',
|
||||
authorize('department_leave_requests', '*', 'read'),
|
||||
portalController.getManagedLeaves
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/portal/managed-leaves/:id/approve',
|
||||
authorize('department_leave_requests', '*', 'approve'),
|
||||
portalController.approveManagedLeave
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/portal/managed-leaves/:id/reject',
|
||||
authorize('department_leave_requests', '*', 'approve'),
|
||||
portalController.rejectManagedLeave
|
||||
);
|
||||
|
||||
router.get('/portal/overtime-requests', portalController.getMyOvertimeRequests);
|
||||
router.post('/portal/overtime-requests', portalController.submitOvertimeRequest);
|
||||
|
||||
router.get(
|
||||
'/portal/managed-overtime-requests',
|
||||
authorize('department_overtime_requests', '*', 'view'),
|
||||
portalController.getManagedOvertimeRequests
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/portal/managed-overtime-requests/:attendanceId/approve',
|
||||
authorize('department_overtime_requests', '*', 'approve'),
|
||||
portalController.approveManagedOvertimeRequest
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/portal/managed-overtime-requests/:attendanceId/reject',
|
||||
authorize('department_overtime_requests', '*', 'approve'),
|
||||
portalController.rejectManagedOvertimeRequest
|
||||
);
|
||||
|
||||
router.get('/portal/purchase-requests', portalController.getMyPurchaseRequests);
|
||||
router.post('/portal/purchase-requests', portalController.submitPurchaseRequest);
|
||||
router.get('/portal/attendance', portalController.getMyAttendance);
|
||||
router.get('/portal/salaries', portalController.getMySalaries);
|
||||
|
||||
// ========== EMPLOYEES ==========
|
||||
|
||||
router.get('/employees', authorize('hr', 'employees', 'read'), hrController.findAllEmployees);
|
||||
@@ -19,11 +71,14 @@ router.post('/employees/:id/terminate', authorize('hr', 'employees', 'terminate'
|
||||
|
||||
router.post('/attendance', authorize('hr', 'attendance', 'create'), hrController.recordAttendance);
|
||||
router.get('/attendance/:employeeId', authorize('hr', 'attendance', 'read'), hrController.getAttendance);
|
||||
router.post('/attendance/sync', authorize('hr', 'attendance', 'create'), hrController.bulkSyncAttendance);
|
||||
|
||||
// ========== LEAVES ==========
|
||||
|
||||
router.get('/leaves', authorize('hr', 'leaves', 'read'), hrController.findAllLeaves);
|
||||
router.post('/leaves', authorize('hr', 'leaves', 'create'), hrController.createLeaveRequest);
|
||||
router.post('/leaves/:id/approve', authorize('hr', 'leaves', 'approve'), hrController.approveLeave);
|
||||
router.post('/leaves/:id/reject', authorize('hr', 'leaves', 'approve'), hrController.rejectLeave);
|
||||
|
||||
// ========== SALARIES ==========
|
||||
|
||||
@@ -32,10 +87,43 @@ router.post('/salaries/process', authorize('hr', 'salaries', 'process'), hrContr
|
||||
// ========== DEPARTMENTS ==========
|
||||
|
||||
router.get('/departments', authorize('hr', 'all', 'read'), hrController.findAllDepartments);
|
||||
router.get('/departments/hierarchy', authorize('hr', 'all', 'read'), hrController.getDepartmentsHierarchy);
|
||||
router.post('/departments', authorize('hr', 'all', 'create'), hrController.createDepartment);
|
||||
router.put('/departments/:id', authorize('hr', 'all', 'update'), hrController.updateDepartment);
|
||||
router.delete('/departments/:id', authorize('hr', 'all', 'delete'), hrController.deleteDepartment);
|
||||
|
||||
// ========== POSITIONS ==========
|
||||
|
||||
router.get('/positions', authorize('hr', 'all', 'read'), hrController.findAllPositions);
|
||||
|
||||
export default router;
|
||||
// ========== LOANS ==========
|
||||
|
||||
router.get('/loans', authorize('hr', 'all', 'read'), hrController.findAllLoans);
|
||||
router.get('/loans/:id', authorize('hr', 'all', 'read'), hrController.findLoanById);
|
||||
router.post('/loans', authorize('hr', 'all', 'create'), hrController.createLoan);
|
||||
router.post('/loans/:id/approve', authorize('hr', 'all', 'approve'), hrController.approveLoan);
|
||||
router.post('/loans/:id/reject', authorize('hr', 'all', 'approve'), hrController.rejectLoan);
|
||||
router.post('/loans/:id/pay-installment', authorize('hr', 'all', 'update'), hrController.recordLoanInstallmentPayment);
|
||||
|
||||
// ========== PURCHASE REQUESTS ==========
|
||||
|
||||
router.get('/purchase-requests', authorize('hr', 'all', 'read'), hrController.findAllPurchaseRequests);
|
||||
router.get('/purchase-requests/:id', authorize('hr', 'all', 'read'), hrController.findPurchaseRequestById);
|
||||
router.post('/purchase-requests', authorize('hr', 'all', 'create'), hrController.createPurchaseRequest);
|
||||
router.post('/purchase-requests/:id/approve', authorize('hr', 'all', 'approve'), hrController.approvePurchaseRequest);
|
||||
router.post('/purchase-requests/:id/reject', authorize('hr', 'all', 'approve'), hrController.rejectPurchaseRequest);
|
||||
|
||||
// ========== LEAVE ENTITLEMENTS ==========
|
||||
|
||||
router.get('/leave-balance/:employeeId', authorize('hr', 'all', 'read'), hrController.getLeaveBalance);
|
||||
router.get('/leave-entitlements', authorize('hr', 'all', 'read'), hrController.findAllLeaveEntitlements);
|
||||
router.post('/leave-entitlements', authorize('hr', 'all', 'create'), hrController.upsertLeaveEntitlement);
|
||||
|
||||
// ========== EMPLOYEE CONTRACTS ==========
|
||||
|
||||
router.get('/contracts', authorize('hr', 'all', 'read'), hrController.findAllEmployeeContracts);
|
||||
router.get('/contracts/:id', authorize('hr', 'all', 'read'), hrController.findEmployeeContractById);
|
||||
router.post('/contracts', authorize('hr', 'all', 'create'), hrController.createEmployeeContract);
|
||||
router.put('/contracts/:id', authorize('hr', 'all', 'update'), hrController.updateEmployeeContract);
|
||||
|
||||
export default router;
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
@@ -3,15 +3,18 @@ import adminRoutes from '../modules/admin/admin.routes';
|
||||
import authRoutes from '../modules/auth/auth.routes';
|
||||
import contactsRoutes from '../modules/contacts/contacts.routes';
|
||||
import crmRoutes from '../modules/crm/crm.routes';
|
||||
import dashboardRoutes from '../modules/dashboard/dashboard.routes';
|
||||
import hrRoutes from '../modules/hr/hr.routes';
|
||||
import inventoryRoutes from '../modules/inventory/inventory.routes';
|
||||
import projectsRoutes from '../modules/projects/projects.routes';
|
||||
import marketingRoutes from '../modules/marketing/marketing.routes';
|
||||
import tendersRoutes from '../modules/tenders/tenders.routes';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Module routes
|
||||
router.use('/admin', adminRoutes);
|
||||
router.use('/dashboard', dashboardRoutes);
|
||||
router.use('/auth', authRoutes);
|
||||
router.use('/contacts', contactsRoutes);
|
||||
router.use('/crm', crmRoutes);
|
||||
@@ -19,6 +22,7 @@ router.use('/hr', hrRoutes);
|
||||
router.use('/inventory', inventoryRoutes);
|
||||
router.use('/projects', projectsRoutes);
|
||||
router.use('/marketing', marketingRoutes);
|
||||
router.use('/tenders', tendersRoutes);
|
||||
|
||||
// API info
|
||||
router.get('/', (req, res) => {
|
||||
@@ -34,6 +38,7 @@ router.get('/', (req, res) => {
|
||||
'Inventory & Assets',
|
||||
'Tasks & Projects',
|
||||
'Marketing',
|
||||
'Tender Management',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,12 +4,47 @@ import { config } from '../../config';
|
||||
import { AppError } from './errorHandler';
|
||||
import prisma from '../../config/database';
|
||||
|
||||
export interface EffectivePermission {
|
||||
module: string;
|
||||
resource: string;
|
||||
actions: string[];
|
||||
}
|
||||
|
||||
function mergePermissions(
|
||||
positionPerms: { module: string; resource: string; actions: unknown }[],
|
||||
rolePerms: { module: string; resource: string; actions: unknown }[]
|
||||
): EffectivePermission[] {
|
||||
const key = (m: string, r: string) => `${m}:${r}`;
|
||||
const map = new Map<string, Set<string>>();
|
||||
|
||||
const add = (m: string, r: string, actions: unknown) => {
|
||||
const arr = Array.isArray(actions) ? actions : [];
|
||||
const actionSet = new Set<string>(arr.map(String));
|
||||
const k = key(m, r);
|
||||
const existing = map.get(k);
|
||||
if (existing) {
|
||||
actionSet.forEach((a) => existing.add(a));
|
||||
} else {
|
||||
map.set(k, actionSet);
|
||||
}
|
||||
};
|
||||
|
||||
(positionPerms || []).forEach((p) => add(p.module, p.resource, p.actions));
|
||||
(rolePerms || []).forEach((p) => add(p.module, p.resource, p.actions));
|
||||
|
||||
return Array.from(map.entries()).map(([k, actions]) => {
|
||||
const [module, resource] = k.split(':');
|
||||
return { module, resource, actions: Array.from(actions) };
|
||||
});
|
||||
}
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
employeeId?: string;
|
||||
employee?: any;
|
||||
effectivePermissions?: EffectivePermission[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,7 +68,7 @@ export const authenticate = async (
|
||||
email: string;
|
||||
};
|
||||
|
||||
// Get user with employee info
|
||||
// Get user with employee + roles (Phase 3: multi-group)
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.id },
|
||||
include: {
|
||||
@@ -47,6 +82,14 @@ export const authenticate = async (
|
||||
department: true,
|
||||
},
|
||||
},
|
||||
userRoles: {
|
||||
where: { role: { isActive: true } },
|
||||
include: {
|
||||
role: {
|
||||
include: { permissions: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -59,12 +102,18 @@ export const authenticate = async (
|
||||
throw new AppError(403, 'الوصول مرفوض - Access denied. Active employee record required.');
|
||||
}
|
||||
|
||||
// Attach user to request
|
||||
const positionPerms = user.employee?.position?.permissions ?? [];
|
||||
const rolePerms = (user as any).userRoles?.flatMap(
|
||||
(ur: any) => ur.role?.permissions ?? []
|
||||
) ?? [];
|
||||
const effectivePermissions = mergePermissions(positionPerms, rolePerms);
|
||||
|
||||
req.user = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
employeeId: user.employeeId || undefined,
|
||||
employee: user.employee,
|
||||
effectivePermissions,
|
||||
};
|
||||
|
||||
next();
|
||||
@@ -76,25 +125,24 @@ export const authenticate = async (
|
||||
}
|
||||
};
|
||||
|
||||
// Permission checking middleware
|
||||
// Permission checking middleware (Position + Role permissions merged)
|
||||
export const authorize = (module: string, resource: string, action: string) => {
|
||||
return async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (!req.user?.employee?.position?.permissions) {
|
||||
const perms = req.user?.effectivePermissions;
|
||||
if (!perms || perms.length === 0) {
|
||||
throw new AppError(403, 'الوصول مرفوض - Access denied');
|
||||
}
|
||||
|
||||
// Find permission for this module and resource (check exact match or wildcard)
|
||||
const permission = req.user.employee.position.permissions.find(
|
||||
(p: any) => p.module === module && (p.resource === resource || p.resource === '*' || p.resource === 'all')
|
||||
const permission = perms.find(
|
||||
(p) => p.module === module && (p.resource === resource || p.resource === '*' || p.resource === 'all')
|
||||
);
|
||||
|
||||
if (!permission) {
|
||||
throw new AppError(403, 'الوصول مرفوض - Access denied');
|
||||
}
|
||||
|
||||
// Check if action is allowed (check exact match or wildcard)
|
||||
const actions = permission.actions as string[];
|
||||
const actions = permission.actions;
|
||||
if (!actions.includes(action) && !actions.includes('*') && !actions.includes('all')) {
|
||||
throw new AppError(403, 'الوصول مرفوض - Access denied');
|
||||
}
|
||||
|
||||
@@ -40,10 +40,12 @@ export const errorHandler = (
|
||||
|
||||
// Handle validation errors
|
||||
if (err instanceof Prisma.PrismaClientValidationError) {
|
||||
const detail = process.env.NODE_ENV !== 'production' ? err.message : undefined;
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'بيانات غير صالحة - Invalid data',
|
||||
error: 'VALIDATION_ERROR',
|
||||
...(detail && { detail }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -24,11 +24,13 @@ services:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: zerp_backend
|
||||
restart: unless-stopped
|
||||
restart: always
|
||||
environment:
|
||||
PORT: 5001
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-postgres123}@postgres:5432/mind14_crm?schema=public
|
||||
# Default matches postgres service when POSTGRES_PASSWORD is unset (local/staging).
|
||||
# Override via `.env` (Compose loads `.env`, not `.env.production`).
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql://postgres:${POSTGRES_PASSWORD:-postgres123}@postgres:5432/mind14_crm?schema=public}
|
||||
JWT_SECRET: ${JWT_SECRET:-z-crm-jwt-secret-change-in-production-NOW}
|
||||
JWT_EXPIRES_IN: 7d
|
||||
JWT_REFRESH_EXPIRES_IN: 30d
|
||||
@@ -39,6 +41,12 @@ services:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "5001:5001"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:5001/api/v1/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
command: sh -c "npx prisma migrate deploy && node dist/server.js"
|
||||
|
||||
frontend:
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
Clean the production database so you can load **new real data** that will reflect across the system at all levels. This removes existing (e.g. test/demo) data and leaves the database in a state where:
|
||||
|
||||
- Schema and migrations are unchanged
|
||||
- Base configuration is restored (pipelines, categories, departments, roles, default users)
|
||||
- All business data (contacts, deals, quotes, projects, etc.) is removed so you can enter new real data
|
||||
- One System Administrator user remains for configuration
|
||||
- All business data (contacts, deals, quotes, projects, etc.) is removed so you can enter new real data manually
|
||||
|
||||
## ⚠️ Important
|
||||
|
||||
@@ -21,7 +21,7 @@ Clean the production database so you can load **new real data** that will reflec
|
||||
This truncates all tables and then runs the seed so you get:
|
||||
|
||||
- Empty business data (contacts, deals, quotes, projects, inventory, etc.)
|
||||
- Restored base data: departments, positions, permissions, employees, users, contact categories, product categories, pipelines, one warehouse
|
||||
- One System Administrator user (admin@system.local) with full access to all modules
|
||||
|
||||
### Steps on production server
|
||||
|
||||
@@ -87,19 +87,17 @@ All rows are removed from every table, including:
|
||||
- Audit logs, notifications, approvals
|
||||
- Users, employees, departments, positions, permissions
|
||||
|
||||
Then the **seed** recreates only the base data (users, departments, positions, permissions, employees, contact/product categories, pipelines, one warehouse).
|
||||
Then the **seed** recreates only the base data (one System Administrator user with full access). No categories, pipelines, or warehouses—you configure these manually.
|
||||
|
||||
---
|
||||
|
||||
## Default users after re-seed
|
||||
## Default user after re-seed
|
||||
|
||||
| Role | Email | Password | Access |
|
||||
|-------------------|--------------------------|-----------|---------------|
|
||||
| General Manager | gm@atmata.com | Admin@123 | Full system |
|
||||
| Sales Manager | sales.manager@atmata.com | Admin@123 | Contacts, CRM |
|
||||
| Sales Representative | sales.rep@atmata.com | Admin@123 | Basic CRM |
|
||||
|-------------------|----------------------|-----------|-------------|
|
||||
| System Administrator | admin@system.local | Admin@123 | Full system |
|
||||
|
||||
Change these passwords after first login in production.
|
||||
Change the password after first login in production.
|
||||
|
||||
---
|
||||
|
||||
|
||||
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 && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy necessary files
|
||||
COPY --from=builder /app/public ./public
|
||||
# Standalone first, then static assets; public last so it is not overwritten by any nested folder in standalone.
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
|
||||
USER nextjs
|
||||
|
||||
|
||||
903
frontend/package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.21",
|
||||
"@tanstack/react-query": "^5.17.9",
|
||||
"axios": "^1.6.5",
|
||||
"date-fns": "^3.0.6",
|
||||
|
||||
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/public/logo.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
@@ -43,8 +43,8 @@ export default function AuditLogs() {
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
const formatDate = (d: string) =>
|
||||
new Date(d).toLocaleString('ar-SA', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
const formatDate = (d: string | null | undefined) =>
|
||||
d ? new Date(d).toLocaleString('ar-SA', { dateStyle: 'medium', timeStyle: 'short' }) : '-';
|
||||
|
||||
const getActionLabel = (a: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { usePathname } from 'next/navigation'
|
||||
import {
|
||||
Users,
|
||||
Shield,
|
||||
UsersRound,
|
||||
Database,
|
||||
Settings,
|
||||
FileText,
|
||||
@@ -16,8 +17,7 @@ import {
|
||||
Clock,
|
||||
Building2,
|
||||
LogOut,
|
||||
LayoutDashboard,
|
||||
Users2
|
||||
LayoutDashboard
|
||||
} from 'lucide-react'
|
||||
|
||||
function AdminLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
@@ -28,7 +28,7 @@ function AdminLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
{ icon: LayoutDashboard, label: 'لوحة التحكم', href: '/admin', exact: true },
|
||||
{ icon: Users, label: 'إدارة المستخدمين', href: '/admin/users' },
|
||||
{ icon: Shield, label: 'الأدوار والصلاحيات', href: '/admin/roles' },
|
||||
{ icon: Users2, label: 'مجموعات الصلاحيات', href: '/admin/permission-groups' },
|
||||
{ icon: UsersRound, label: 'مجموعات الصلاحيات', href: '/admin/permission-groups' },
|
||||
{ icon: Database, label: 'النسخ الاحتياطي', href: '/admin/backup' },
|
||||
{ icon: Settings, label: 'إعدادات النظام', href: '/admin/settings' },
|
||||
{ icon: FileText, label: 'سجل العمليات', href: '/admin/audit-logs' },
|
||||
|
||||
@@ -50,7 +50,8 @@ export default function AdminDashboard() {
|
||||
return labels[a] || a;
|
||||
};
|
||||
|
||||
const formatTime = (d: string) => {
|
||||
const formatTime = (d: string | null | undefined) => {
|
||||
if (!d) return '-';
|
||||
const diff = Date.now() - new Date(d).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
|
||||
@@ -1,259 +1,375 @@
|
||||
'use client'
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Plus, Edit, Trash2, Users2 } from 'lucide-react'
|
||||
import Modal from '@/components/Modal'
|
||||
|
||||
type PermissionGroup = {
|
||||
id: string
|
||||
name: string
|
||||
nameAr?: string
|
||||
modules: string[]
|
||||
createdAt: string
|
||||
}
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { UsersRound, Edit, Users, Check, X, Plus } from 'lucide-react';
|
||||
import { permissionGroupsAPI } from '@/lib/api/admin';
|
||||
import type { PermissionGroup } from '@/lib/api/admin';
|
||||
import Modal from '@/components/Modal';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
|
||||
const MODULES = [
|
||||
{ id: 'contacts', name: 'إدارة جهات الاتصال' },
|
||||
{ id: 'crm', name: 'إدارة علاقات العملاء' },
|
||||
{ id: 'inventory', name: 'المخزون والأصول' },
|
||||
{ id: 'projects', name: 'المهام والمشاريع' },
|
||||
{ id: 'hr', name: 'الموارد البشرية' },
|
||||
{ id: 'marketing', name: 'التسويق' },
|
||||
{ id: 'admin', name: 'لوحة الإدارة' },
|
||||
]
|
||||
{ id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' },
|
||||
{ id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' },
|
||||
{ id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' },
|
||||
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
|
||||
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
|
||||
{ id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' },
|
||||
{ id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' },
|
||||
{ id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية', nameEn: 'Department Overtime Requests' },
|
||||
{ id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' },
|
||||
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
|
||||
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'permissionGroups'
|
||||
const ACTIONS = [
|
||||
{ id: 'read', name: 'عرض' },
|
||||
{ id: 'create', name: 'إنشاء' },
|
||||
{ id: 'update', name: 'تعديل' },
|
||||
{ id: 'delete', name: 'حذف' },
|
||||
{ id: 'export', name: 'تصدير' },
|
||||
{ id: 'approve', name: 'اعتماد' },
|
||||
{ id: 'merge', name: 'دمج' },
|
||||
];
|
||||
|
||||
function hasAction(perm: { actions?: unknown } | undefined, action: string): boolean {
|
||||
if (!perm?.actions) return false;
|
||||
const actions = Array.isArray(perm.actions) ? perm.actions : [];
|
||||
return actions.includes('*') || actions.includes('all') || actions.includes(action);
|
||||
}
|
||||
|
||||
function buildPermissionsFromMatrix(matrix: Record<string, Record<string, boolean>>) {
|
||||
return MODULES.filter((m) => Object.values(matrix[m.id] || {}).some(Boolean)).map((m) => {
|
||||
const actions = ACTIONS.filter((a) => matrix[m.id]?.[a.id]).map((a) => a.id);
|
||||
return {
|
||||
module: m.id,
|
||||
resource: '*',
|
||||
actions: actions.length === ACTIONS.length ? ['*'] : actions,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildMatrixFromPermissions(permissions: { module: string; resource: string; actions: string[] }[]) {
|
||||
const matrix: Record<string, Record<string, boolean>> = {};
|
||||
for (const m of MODULES) {
|
||||
matrix[m.id] = {};
|
||||
const perm = permissions.find((p) => p.module === m.id && (p.resource === '*' || p.resource === m.id));
|
||||
const hasAll = perm && (Array.isArray(perm.actions)
|
||||
? perm.actions.includes('*') || perm.actions.includes('all')
|
||||
: false);
|
||||
for (const a of ACTIONS) {
|
||||
matrix[m.id][a.id] = hasAll || hasAction(perm, a.id);
|
||||
}
|
||||
}
|
||||
return matrix;
|
||||
}
|
||||
|
||||
export default function PermissionGroupsPage() {
|
||||
const [groups, setGroups] = useState<PermissionGroup[]>([])
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editing, setEditing] = useState<PermissionGroup | null>(null)
|
||||
const [groups, setGroups] = useState<PermissionGroup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [createForm, setCreateForm] = useState({ name: '', nameAr: '', description: '' });
|
||||
type PermissionMatrix = Record<string, Record<string, boolean>>;
|
||||
const [permissionMatrix, setPermissionMatrix] = useState<PermissionMatrix>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [nameAr, setNameAr] = useState('')
|
||||
const [selectedModules, setSelectedModules] = useState<Record<string, boolean>>({})
|
||||
const fetchGroups = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const list = await permissionGroupsAPI.getAll();
|
||||
setGroups(list);
|
||||
if (selectedId && !list.find((g) => g.id === selectedId)) setSelectedId(null);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'فشل تحميل المجموعات');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) setGroups(JSON.parse(raw))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
fetchGroups();
|
||||
}, [fetchGroups]);
|
||||
|
||||
const currentGroup = groups.find((g) => g.id === selectedId);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentGroup) {
|
||||
setPermissionMatrix(buildMatrixFromPermissions(currentGroup.permissions || []));
|
||||
}
|
||||
}, [currentGroup?.id, currentGroup?.permissions]);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!createForm.name.trim()) {
|
||||
alert('الاسم مطلوب');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(groups))
|
||||
} catch {
|
||||
// ignore
|
||||
const group = await permissionGroupsAPI.create({
|
||||
name: createForm.name.trim(),
|
||||
nameAr: createForm.nameAr.trim() || undefined,
|
||||
description: createForm.description.trim() || undefined,
|
||||
});
|
||||
setShowCreateModal(false);
|
||||
setCreateForm({ name: '', nameAr: '', description: '' });
|
||||
await fetchGroups();
|
||||
setSelectedId(group.id);
|
||||
setShowEditModal(true);
|
||||
} catch (err: unknown) {
|
||||
alert(err instanceof Error ? err.message : 'فشل الإنشاء');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [groups])
|
||||
};
|
||||
|
||||
const baseModulesMap = useMemo(() => {
|
||||
const m: Record<string, boolean> = {}
|
||||
MODULES.forEach(x => (m[x.id] = false))
|
||||
return m
|
||||
}, [])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
setName('')
|
||||
setNameAr('')
|
||||
setSelectedModules({ ...baseModulesMap })
|
||||
setShowModal(true)
|
||||
const handleSavePermissions = async () => {
|
||||
if (!selectedId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const permissions = buildPermissionsFromMatrix(permissionMatrix);
|
||||
await permissionGroupsAPI.updatePermissions(selectedId, permissions);
|
||||
setShowEditModal(false);
|
||||
fetchGroups();
|
||||
} catch (err: unknown) {
|
||||
alert(err instanceof Error ? err.message : 'فشل الحفظ');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (g: PermissionGroup) => {
|
||||
setEditing(g)
|
||||
setName(g.name || '')
|
||||
setNameAr(g.nameAr || '')
|
||||
const m = { ...baseModulesMap }
|
||||
g.modules.forEach(id => (m[id] = true))
|
||||
setSelectedModules(m)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const toggleModule = (id: string) => {
|
||||
setSelectedModules(prev => ({ ...prev, [id]: !prev[id] }))
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
const finalName = name.trim() || nameAr.trim()
|
||||
if (!finalName) {
|
||||
alert('الرجاء إدخال اسم المجموعة')
|
||||
return
|
||||
}
|
||||
|
||||
const mods = Object.keys(selectedModules).filter(k => selectedModules[k])
|
||||
const now = new Date().toISOString()
|
||||
|
||||
if (editing) {
|
||||
setGroups(prev =>
|
||||
prev.map(g =>
|
||||
g.id === editing.id
|
||||
? { ...g, name: finalName, nameAr: nameAr.trim() || undefined, modules: mods }
|
||||
: g
|
||||
)
|
||||
)
|
||||
} else {
|
||||
const id = crypto?.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`
|
||||
setGroups(prev => [
|
||||
{ id, name: finalName, nameAr: nameAr.trim() || undefined, modules: mods, createdAt: now },
|
||||
const handleTogglePermission = (moduleId: string, actionId: string) => {
|
||||
setPermissionMatrix((prev) => ({
|
||||
...prev,
|
||||
])
|
||||
}
|
||||
|
||||
setShowModal(false)
|
||||
}
|
||||
|
||||
const remove = (g: PermissionGroup) => {
|
||||
const ok = confirm(`هل أنت متأكد بحذف مجموعة الصلاحيات؟: ${g.nameAr || g.name} ؟`)
|
||||
if (!ok) return
|
||||
setGroups(prev => prev.filter(x => x.id !== g.id))
|
||||
}
|
||||
[moduleId]: {
|
||||
...(prev[moduleId] || {}),
|
||||
[actionId]: !prev[moduleId]?.[actionId],
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">مجموعات الصلاحيات</h1>
|
||||
<p className="text-gray-600">إدارة مجموعات لتجميع الوحدات بشكل أسرع</p>
|
||||
<p className="text-gray-600">مجموعات اختيارية تضيف صلاحيات إضافية للمستخدمين بغض النظر عن وظائفهم</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={openCreate}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-semibold"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all shadow-md"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
إضافة مجموعة
|
||||
<span className="font-semibold">إضافة مجموعة</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{groups.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-10 text-center">
|
||||
<Users2 className="h-14 w-14 text-gray-300 mx-auto mb-3" />
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-1">لا توجد مجموعات</h3>
|
||||
<p className="text-gray-600">قم بإضافة مجموعة صلاحيات لتسهيل إدارة الأدوار.</p>
|
||||
{loading ? (
|
||||
<div className="flex justify-center p-12">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center text-red-600 p-12">{error}</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{groups.map(g => (
|
||||
<div key={g.id} className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-1 space-y-4">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">المجموعات ({groups.length})</h2>
|
||||
{groups.map((g) => (
|
||||
<div
|
||||
key={g.id}
|
||||
onClick={() => setSelectedId(g.id)}
|
||||
className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${
|
||||
selectedId === g.id ? 'border-blue-600 bg-blue-50' : 'border-gray-200 bg-white hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className={`p-2 rounded-lg ${selectedId === g.id ? 'bg-blue-600' : 'bg-blue-100'}`}>
|
||||
<UsersRound className={`h-5 w-5 ${selectedId === g.id ? 'text-white' : 'text-blue-600'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900">{g.nameAr || g.name}</h3>
|
||||
<p className="text-xs text-gray-600">{g.name}</p>
|
||||
<p className="text-sm text-gray-700 mt-3">
|
||||
الوحدات: <span className="font-semibold">{g.modules.length}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">
|
||||
<Users className="h-4 w-4 inline mr-1" />
|
||||
{g._count?.userRoles ?? 0} مستخدم
|
||||
</span>
|
||||
<button
|
||||
onClick={() => openEdit(g)}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg"
|
||||
title="تعديل"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedId(g.id);
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => remove(g)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg"
|
||||
title="حذف"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{g.modules.map(id => {
|
||||
const m = MODULES.find(x => x.id === id)
|
||||
return (
|
||||
<span key={id} className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded-lg">
|
||||
{m?.name || id}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
{currentGroup ? (
|
||||
<div className="bg-white rounded-xl shadow-lg border p-6">
|
||||
<div className="flex justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">{currentGroup.nameAr || currentGroup.name}</h2>
|
||||
<p className="text-gray-600">{currentGroup.name}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowEditModal(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
تعديل الصلاحيات
|
||||
</button>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold mb-4">مصفوفة الصلاحيات</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-gray-200">
|
||||
<th className="px-4 py-3 text-right text-sm font-bold min-w-[200px]">الوحدة</th>
|
||||
{ACTIONS.map((a) => (
|
||||
<th key={a.id} className="px-4 py-3 text-center text-sm font-bold">{a.name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{MODULES.map((m) => (
|
||||
<tr key={m.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-4">
|
||||
<p className="font-semibold">{m.name}</p>
|
||||
<p className="text-xs text-gray-600">{m.nameEn}</p>
|
||||
</td>
|
||||
{ACTIONS.map((a) => {
|
||||
const has = permissionMatrix[m.id]?.[a.id];
|
||||
return (
|
||||
<td key={a.id} className="px-4 py-4 text-center">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center mx-auto ${
|
||||
has ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{has ? <Check className="h-6 w-6" /> : <X className="h-6 w-6" />}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl shadow-lg border p-12 text-center">
|
||||
<UsersRound className="h-16 w-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">اختر مجموعة</h3>
|
||||
<p className="text-gray-600">أو أنشئ مجموعة جديدة لإضافة صلاحيات اختيارية للمستخدمين</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
title={editing ? 'تعديل مجموعة الصلاحيات' : 'إضافة مجموعة صلاحيات'}
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title="إضافة مجموعة صلاحيات" size="md">
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1">الاسم (English)</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="e.g. Sales Group"
|
||||
type="text"
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, name: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
placeholder="e.g. Campaign Approver"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1">الاسم (عربي)</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name (Arabic)</label>
|
||||
<input
|
||||
value={nameAr}
|
||||
onChange={e => setNameAr(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="مثال: مجموعة المبيعات"
|
||||
type="text"
|
||||
value={createForm.nameAr}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, nameAr: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">الوحدات ضمن المجموعة</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{MODULES.map(m => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => toggleModule(m.id)}
|
||||
className={`flex items-center justify-between px-3 py-2 rounded-lg border ${
|
||||
selectedModules[m.id]
|
||||
? 'border-green-500 bg-green-50'
|
||||
: 'border-gray-200 bg-white hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-800">{m.name}</span>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded ${
|
||||
selectedModules[m.id] ? 'bg-green-600 text-white' : 'bg-gray-200 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{selectedModules[m.id] ? 'ضمن المجموعة' : 'غير محدد'}
|
||||
</span>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<textarea
|
||||
value={createForm.description}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, description: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end pt-4">
|
||||
<button type="button" onClick={() => setShowCreateModal(false)} className="px-6 py-3 border rounded-lg">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="px-6 py-3 bg-blue-600 text-white rounded-lg disabled:opacity-50">
|
||||
{saving ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="px-5 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
<Modal
|
||||
isOpen={showEditModal}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
title={`تعديل صلاحيات: ${currentGroup?.nameAr || currentGroup?.name || ''}`}
|
||||
size="2xl"
|
||||
>
|
||||
{currentGroup && (
|
||||
<div>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-gray-200">
|
||||
<th className="px-4 py-3 text-right text-sm font-bold">الوحدة</th>
|
||||
{ACTIONS.map((a) => (
|
||||
<th key={a.id} className="px-4 py-3 text-center text-sm font-bold">{a.name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{MODULES.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td className="px-4 py-4 font-semibold">{m.name}</td>
|
||||
{ACTIONS.map((a) => (
|
||||
<td key={a.id} className="px-4 py-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTogglePermission(m.id, a.id)}
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center mx-auto ${
|
||||
permissionMatrix[m.id]?.[a.id] ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{permissionMatrix[m.id]?.[a.id] ? <Check className="h-6 w-6" /> : <X className="h-6 w-6" />}
|
||||
</button>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button onClick={() => setShowEditModal(false)} className="px-6 py-3 border rounded-lg">
|
||||
إلغاء
|
||||
</button>
|
||||
<button
|
||||
onClick={save}
|
||||
className="px-5 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-semibold"
|
||||
>
|
||||
حفظ
|
||||
<button onClick={handleSavePermissions} disabled={saving} className="px-6 py-3 bg-blue-600 text-white rounded-lg disabled:opacity-50">
|
||||
{saving ? 'جاري الحفظ...' : 'حفظ'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Shield, Edit, Trash2, Users, Check, X, Loader2, Plus } from 'lucide-react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Shield, Edit, Users, Check, X, Plus } from 'lucide-react';
|
||||
import { positionsAPI } from '@/lib/api/admin';
|
||||
import type { PositionRole, PositionPermission } from '@/lib/api/admin';
|
||||
import { departmentsAPI } from '@/lib/api/employees';
|
||||
import type { PositionRole, PositionPermission, CreatePositionData } from '@/lib/api/admin';
|
||||
import Modal from '@/components/Modal';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
|
||||
const MODULES = [
|
||||
{ id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' },
|
||||
{ id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' },
|
||||
{ id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' },
|
||||
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
|
||||
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
|
||||
{ id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' },
|
||||
{ id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' },
|
||||
{ id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية', nameEn: 'Department Overtime Requests' },
|
||||
{ id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' },
|
||||
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
|
||||
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
|
||||
];
|
||||
@@ -49,9 +54,7 @@ function buildMatrixFromPermissions(permissions: PositionPermission[]): Record<s
|
||||
for (const m of MODULES) {
|
||||
matrix[m.id] = {};
|
||||
const perm = permissions.find((p) => p.module === m.id && (p.resource === '*' || p.resource === m.id));
|
||||
const hasAll =
|
||||
perm &&
|
||||
(Array.isArray(perm.actions)
|
||||
const hasAll = perm && (Array.isArray(perm.actions)
|
||||
? (perm.actions as string[]).includes('*') || (perm.actions as string[]).includes('all')
|
||||
: false);
|
||||
for (const a of ACTIONS) {
|
||||
@@ -61,33 +64,27 @@ function buildMatrixFromPermissions(permissions: PositionPermission[]): Record<s
|
||||
return matrix;
|
||||
}
|
||||
|
||||
const initialCreateForm: CreatePositionData & { description?: string } = {
|
||||
title: '',
|
||||
titleAr: '',
|
||||
code: '',
|
||||
departmentId: '',
|
||||
level: 5,
|
||||
description: '',
|
||||
};
|
||||
|
||||
export default function RolesManagement() {
|
||||
const [roles, setRoles] = useState<PositionRole[]>([]);
|
||||
const [departments, setDepartments] = useState<{ id: string; name: string; nameAr?: string | null }[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
|
||||
|
||||
// Edit modal (name + permissions)
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [permissionMatrix, setPermissionMatrix] = useState<Record<string, Record<string, boolean>>>({});
|
||||
const [editTitle, setEditTitle] = useState('');
|
||||
const [editTitleAr, setEditTitleAr] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Delete dialog
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [roleToDelete, setRoleToDelete] = useState<PositionRole | null>(null);
|
||||
|
||||
// Create modal
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [newTitleAr, setNewTitleAr] = useState('');
|
||||
const [newDepartmentId, setNewDepartmentId] = useState('');
|
||||
const [newLevel, setNewLevel] = useState<number>(1);
|
||||
const [newCode, setNewCode] = useState('');
|
||||
const [createForm, setCreateForm] = useState(initialCreateForm);
|
||||
const [createErrors, setCreateErrors] = useState<Record<string, string>>({});
|
||||
const [permissionMatrix, setPermissionMatrix] = useState<Record<string, Record<string, boolean>>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchRoles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -109,40 +106,49 @@ export default function RolesManagement() {
|
||||
fetchRoles();
|
||||
}, [fetchRoles]);
|
||||
|
||||
useEffect(() => {
|
||||
departmentsAPI.getAll().then((depts) => setDepartments(depts)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const currentRole = roles.find((r) => r.id === selectedRoleId);
|
||||
|
||||
// build departments options from existing roles
|
||||
const departmentOptions = useMemo(() => {
|
||||
const map = new Map<string, { id: string; label: string }>();
|
||||
roles.forEach((r) => {
|
||||
if (!r.departmentId) return;
|
||||
const label = r.department?.nameAr || r.department?.name || r.departmentId;
|
||||
if (!map.has(r.departmentId)) map.set(r.departmentId, { id: r.departmentId, label });
|
||||
const handleCreateRole = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const errs: Record<string, string> = {};
|
||||
if (!createForm.title?.trim()) errs.title = 'Required';
|
||||
if (!createForm.code?.trim()) errs.code = 'Required';
|
||||
if (!createForm.departmentId) errs.departmentId = 'Required';
|
||||
setCreateErrors(errs);
|
||||
if (Object.keys(errs).length > 0) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const position = await positionsAPI.create({
|
||||
title: createForm.title.trim(),
|
||||
titleAr: createForm.titleAr?.trim() || undefined,
|
||||
code: createForm.code.trim(),
|
||||
departmentId: createForm.departmentId,
|
||||
level: createForm.level ?? 5,
|
||||
description: createForm.description?.trim() || undefined,
|
||||
});
|
||||
return Array.from(map.values());
|
||||
}, [roles]);
|
||||
setShowCreateModal(false);
|
||||
setCreateForm(initialCreateForm);
|
||||
setCreateErrors({});
|
||||
await fetchRoles();
|
||||
setSelectedRoleId(position.id);
|
||||
setShowEditModal(true);
|
||||
} catch (err: unknown) {
|
||||
setCreateErrors({ form: err instanceof Error ? err.message : 'Failed to create role' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (currentRole) {
|
||||
setPermissionMatrix(buildMatrixFromPermissions(currentRole.permissions || []));
|
||||
setEditTitle(currentRole.title || '');
|
||||
setEditTitleAr(currentRole.titleAr || '');
|
||||
}
|
||||
}, [currentRole?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
// default department when opening create
|
||||
if (!showCreateModal) return;
|
||||
const fallback =
|
||||
(selectedRoleId && roles.find(r => r.id === selectedRoleId)?.departmentId) ||
|
||||
departmentOptions[0]?.id ||
|
||||
'';
|
||||
setNewDepartmentId(fallback);
|
||||
setNewLevel(1);
|
||||
setNewCode('');
|
||||
setNewTitle('');
|
||||
setNewTitleAr('');
|
||||
}, [showCreateModal, selectedRoleId, roles, departmentOptions]);
|
||||
}, [currentRole?.id, currentRole?.permissions]);
|
||||
|
||||
const handleTogglePermission = (moduleId: string, actionId: string) => {
|
||||
setPermissionMatrix((prev) => ({
|
||||
@@ -154,112 +160,21 @@ export default function RolesManagement() {
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSaveRole = async () => {
|
||||
const handleSavePermissions = async () => {
|
||||
if (!selectedRoleId) return;
|
||||
|
||||
const titleFinal = (editTitle || '').trim() || (editTitleAr || '').trim();
|
||||
if (!titleFinal) {
|
||||
alert('الرجاء إدخال اسم الدور');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
// 1) update name (only if changed)
|
||||
if (currentRole && (titleFinal !== currentRole.title || (editTitleAr || '') !== (currentRole.titleAr || ''))) {
|
||||
await positionsAPI.update(selectedRoleId, {
|
||||
title: titleFinal,
|
||||
titleAr: (editTitleAr || '').trim() || null,
|
||||
});
|
||||
}
|
||||
|
||||
// 2) update permissions
|
||||
const permissions = buildPermissionsFromMatrix(permissionMatrix);
|
||||
await positionsAPI.updatePermissions(selectedRoleId, permissions);
|
||||
|
||||
setShowEditModal(false);
|
||||
await fetchRoles();
|
||||
fetchRoles();
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as any)?.response?.data?.message ||
|
||||
(err as any)?.response?.data?.error ||
|
||||
(err as any)?.message ||
|
||||
'فشل حفظ التغييرات';
|
||||
alert(msg);
|
||||
alert(err instanceof Error ? err.message : 'فشل حفظ الصلاحيات');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openDeleteDialog = (role: PositionRole) => {
|
||||
setRoleToDelete(role);
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
const handleDeleteRole = async () => {
|
||||
if (!roleToDelete) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await positionsAPI.delete(roleToDelete.id);
|
||||
|
||||
if (selectedRoleId === roleToDelete.id) {
|
||||
setSelectedRoleId(null);
|
||||
}
|
||||
|
||||
setShowDeleteDialog(false);
|
||||
setRoleToDelete(null);
|
||||
await fetchRoles();
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as any)?.response?.data?.message ||
|
||||
(err as any)?.response?.data?.error ||
|
||||
(err as any)?.message ||
|
||||
'فشل حذف الدور';
|
||||
alert(msg);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateRole = async () => {
|
||||
const titleFinal = (newTitle || '').trim() || (newTitleAr || '').trim();
|
||||
if (!titleFinal) {
|
||||
alert('الرجاء إدخال اسم الدور');
|
||||
return;
|
||||
}
|
||||
if (!newDepartmentId) {
|
||||
alert('الرجاء اختيار قسم (Department)');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const created = await positionsAPI.create({
|
||||
title: titleFinal,
|
||||
titleAr: (newTitleAr || '').trim() || null,
|
||||
departmentId: newDepartmentId,
|
||||
level: Number.isFinite(newLevel) ? newLevel : 1,
|
||||
code: (newCode || '').trim() || undefined,
|
||||
});
|
||||
|
||||
setShowCreateModal(false);
|
||||
await fetchRoles();
|
||||
|
||||
// select new role + open edit modal directly
|
||||
setSelectedRoleId(created.id);
|
||||
setShowEditModal(true);
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as any)?.response?.data?.message ||
|
||||
(err as any)?.response?.data?.error ||
|
||||
(err as any)?.message ||
|
||||
'فشل إنشاء الدور';
|
||||
alert(msg);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectRole = (id: string) => {
|
||||
setSelectedRoleId(id);
|
||||
setShowEditModal(false);
|
||||
@@ -270,15 +185,14 @@ export default function RolesManagement() {
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">الأدوار والصلاحيات</h1>
|
||||
<p className="text-gray-600">إدارة أدوار المستخدمين و الصلاحيات</p>
|
||||
<p className="text-gray-600">إدارة أدوار المستخدمين ومصفوفة الصلاحيات</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-semibold"
|
||||
className="flex items-center gap-2 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-all shadow-md hover:shadow-lg"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
إنشاء دور
|
||||
<span className="font-semibold">إضافة دور</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -306,8 +220,12 @@ export default function RolesManagement() {
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${selectedRoleId === role.id ? 'bg-purple-600' : 'bg-purple-100'}`}>
|
||||
<Shield className={`h-5 w-5 ${selectedRoleId === role.id ? 'text-white' : 'text-purple-600'}`} />
|
||||
<div
|
||||
className={`p-2 rounded-lg ${selectedRoleId === role.id ? 'bg-purple-600' : 'bg-purple-100'}`}
|
||||
>
|
||||
<Shield
|
||||
className={`h-5 w-5 ${selectedRoleId === role.id ? 'text-white' : 'text-purple-600'}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900">{role.titleAr || role.title}</h3>
|
||||
@@ -315,25 +233,11 @@ export default function RolesManagement() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Users className="h-4 w-4" />
|
||||
<span>{role.usersCount ?? role._count?.employees ?? 0} مستخدم</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openDeleteDialog(role);
|
||||
}}
|
||||
className="p-1.5 text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
title="حذف"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -341,13 +245,11 @@ export default function RolesManagement() {
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded transition-colors"
|
||||
title="تعديل"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -365,11 +267,10 @@ export default function RolesManagement() {
|
||||
onClick={() => setShowEditModal(true)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium"
|
||||
>
|
||||
تعديل
|
||||
تعديل الصلاحيات
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4">مصفوفة الصلاحيات</h3>
|
||||
<div className="overflow-x-auto">
|
||||
@@ -386,7 +287,6 @@ export default function RolesManagement() {
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{MODULES.map((module) => (
|
||||
<tr key={module.id} className="hover:bg-gray-50 transition-colors">
|
||||
@@ -396,14 +296,15 @@ export default function RolesManagement() {
|
||||
<p className="text-xs text-gray-600">{module.nameEn}</p>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{ACTIONS.map((action) => {
|
||||
const hasPermission = permissionMatrix[module.id]?.[action.id];
|
||||
return (
|
||||
<td key={action.id} className="px-4 py-4 text-center">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-all mx-auto ${
|
||||
hasPermission ? 'bg-green-500 text-white shadow-md' : 'bg-gray-200 text-gray-500'
|
||||
hasPermission
|
||||
? 'bg-green-500 text-white shadow-md'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{hasPermission ? <Check className="h-6 w-6" /> : <X className="h-6 w-6" />}
|
||||
@@ -432,189 +333,110 @@ export default function RolesManagement() {
|
||||
{/* Create Role Modal */}
|
||||
<Modal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onClose={() => {
|
||||
setShowCreateModal(false);
|
||||
setCreateForm(initialCreateForm);
|
||||
setCreateErrors({});
|
||||
}}
|
||||
title="إضافة دور جديد"
|
||||
size="lg"
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<form onSubmit={handleCreateRole} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1">الاسم (English)</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Title (English) *</label>
|
||||
<input
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="e.g. Sales representative"
|
||||
type="text"
|
||||
value={createForm.title}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, title: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="e.g. Sales Representative"
|
||||
/>
|
||||
{createErrors.title && <p className="text-red-500 text-xs mt-1">{createErrors.title}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Title (Arabic)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.titleAr || ''}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, titleAr: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="مندوب مبيعات"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1">الاسم (عربي)</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Code *</label>
|
||||
<input
|
||||
value={newTitleAr}
|
||||
onChange={(e) => setNewTitleAr(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="مثال: مستخدم عادي"
|
||||
type="text"
|
||||
value={createForm.code}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, code: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="SALES_REP"
|
||||
/>
|
||||
{createErrors.code && <p className="text-red-500 text-xs mt-1">{createErrors.code}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1">القسم (Department)</label>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Department *</label>
|
||||
<select
|
||||
value={newDepartmentId}
|
||||
onChange={(e) => setNewDepartmentId(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 bg-white"
|
||||
value={createForm.departmentId}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, departmentId: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
{departmentOptions.length === 0 ? (
|
||||
<option value="">لا يوجد أقسام متاحة</option>
|
||||
) : (
|
||||
departmentOptions.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.label}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
<option value="">Select department</option>
|
||||
{departments.map((d) => (
|
||||
<option key={d.id} value={d.id}>{d.nameAr || d.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{departmentOptions.length === 0 && (
|
||||
<p className="text-xs text-red-600 mt-1">
|
||||
لا يوجد أقسام ضمن البيانات الحالية. (DepartmentId مطلوب لإنشاء الدور)
|
||||
</p>
|
||||
)}
|
||||
{createErrors.departmentId && <p className="text-red-500 text-xs mt-1">{createErrors.departmentId}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1">Level</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Level</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newLevel}
|
||||
onChange={(e) => setNewLevel(parseInt(e.target.value || '1', 10))}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
min={1}
|
||||
value={createForm.level ?? 5}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, level: parseInt(e.target.value, 10) || 5 }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1">Code (اختياري)</label>
|
||||
<input
|
||||
value={newCode}
|
||||
onChange={(e) => setNewCode(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="e.g. SALES_REP (في حال كان فارغاً سيقوم النظام بتوليده تلقائياً)"
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<textarea
|
||||
value={createForm.description || ''}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, description: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
rows={2}
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
{createErrors.form && <p className="text-red-500 text-sm">{createErrors.form}</p>}
|
||||
<div className="flex gap-3 justify-end pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="px-5 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
disabled={creating}
|
||||
className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 font-medium"
|
||||
>
|
||||
إلغاء
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateRole}
|
||||
className="px-5 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-semibold disabled:opacity-50 inline-flex items-center gap-2"
|
||||
disabled={creating}
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-semibold disabled:opacity-50"
|
||||
>
|
||||
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{creating ? 'جاري الإنشاء...' : 'إنشاء'}
|
||||
{saving ? 'Creating...' : 'Create Role'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
{showDeleteDialog && roleToDelete && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50"
|
||||
onClick={() => {
|
||||
if (!deleting) {
|
||||
setShowDeleteDialog(false);
|
||||
setRoleToDelete(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="relative bg-white rounded-xl shadow-2xl p-6 max-w-md w-full">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="bg-red-100 p-3 rounded-full">
|
||||
<Trash2 className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900">حذف الدور</h3>
|
||||
<p className="text-sm text-gray-600">هذا الإجراء لا يمكن التراجع عنه</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-700 mb-6">
|
||||
هل أنت متأكد أنك تريد حذف دور{' '}
|
||||
<span className="font-semibold">{roleToDelete.titleAr || roleToDelete.title}</span>؟
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDeleteDialog(false);
|
||||
setRoleToDelete(null);
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
|
||||
disabled={deleting}
|
||||
>
|
||||
إلغاء
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteRole}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
جاري الحذف...
|
||||
</>
|
||||
) : (
|
||||
'حذف'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Modal: name + permissions */}
|
||||
{/* Edit Permissions Modal */}
|
||||
<Modal
|
||||
isOpen={showEditModal}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
title={`تعديل: ${currentRole?.titleAr || currentRole?.title || ''}`}
|
||||
title={`تعديل صلاحيات: ${currentRole?.titleAr || currentRole?.title || ''}`}
|
||||
size="2xl"
|
||||
>
|
||||
{currentRole && (
|
||||
<div>
|
||||
{/* Name edit */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-6">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1">الاسم (English)</label>
|
||||
<input
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1">الاسم (عربي)</label>
|
||||
<input
|
||||
value={editTitleAr}
|
||||
onChange={(e) => setEditTitleAr(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
@@ -627,14 +449,12 @@ export default function RolesManagement() {
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{MODULES.map((module) => (
|
||||
<tr key={module.id}>
|
||||
<td className="px-4 py-4">
|
||||
<p className="font-semibold text-gray-900">{module.name}</p>
|
||||
</td>
|
||||
|
||||
{ACTIONS.map((action) => {
|
||||
const hasPermission = permissionMatrix[module.id]?.[action.id];
|
||||
return (
|
||||
@@ -656,21 +476,18 @@ export default function RolesManagement() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setShowEditModal(false)}
|
||||
className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 font-medium"
|
||||
disabled={saving}
|
||||
>
|
||||
إلغاء
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveRole}
|
||||
onClick={handleSavePermissions}
|
||||
disabled={saving}
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-semibold disabled:opacity-50 inline-flex items-center gap-2"
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-semibold disabled:opacity-50"
|
||||
>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{saving ? 'جاري الحفظ...' : 'حفظ التغييرات'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Shield,
|
||||
Calendar,
|
||||
} from 'lucide-react';
|
||||
import { usersAPI, statsAPI, positionsAPI } from '@/lib/api/admin';
|
||||
import { usersAPI, statsAPI, positionsAPI, userRolesAPI, permissionGroupsAPI } from '@/lib/api/admin';
|
||||
import { employeesAPI } from '@/lib/api/employees';
|
||||
import type { User, CreateUserData, UpdateUserData } from '@/lib/api/admin';
|
||||
import type { Employee } from '@/lib/api/employees';
|
||||
@@ -567,6 +567,10 @@ function EditUserModal({
|
||||
employeeId: null,
|
||||
isActive: true,
|
||||
});
|
||||
const [userRoles, setUserRoles] = useState<{ id: string; role: { id: string; name: string; nameAr?: string | null } }[]>([]);
|
||||
const [permissionGroups, setPermissionGroups] = useState<{ id: string; name: string; nameAr?: string | null }[]>([]);
|
||||
const [assignRoleId, setAssignRoleId] = useState('');
|
||||
const [rolesLoading, setRolesLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
@@ -580,6 +584,41 @@ function EditUserModal({
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && user) {
|
||||
userRolesAPI.getAll(user.id).then((r) => setUserRoles(r)).catch(() => setUserRoles([]));
|
||||
permissionGroupsAPI.getAll().then((g) => setPermissionGroups(g)).catch(() => setPermissionGroups([]));
|
||||
}
|
||||
}, [isOpen, user?.id]);
|
||||
|
||||
const handleAssignRole = async () => {
|
||||
if (!user || !assignRoleId) return;
|
||||
setRolesLoading(true);
|
||||
try {
|
||||
await userRolesAPI.assign(user.id, assignRoleId);
|
||||
const updated = await userRolesAPI.getAll(user.id);
|
||||
setUserRoles(updated);
|
||||
setAssignRoleId('');
|
||||
} catch (err: unknown) {
|
||||
alert(err instanceof Error ? err.message : 'فشل الإضافة');
|
||||
} finally {
|
||||
setRolesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveRole = async (roleId: string) => {
|
||||
if (!user) return;
|
||||
setRolesLoading(true);
|
||||
try {
|
||||
await userRolesAPI.remove(user.id, roleId);
|
||||
setUserRoles((prev) => prev.filter((ur) => ur.role.id !== roleId));
|
||||
} catch (err: unknown) {
|
||||
alert(err instanceof Error ? err.message : 'فشل الإزالة');
|
||||
} finally {
|
||||
setRolesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user) return;
|
||||
@@ -670,6 +709,50 @@ function EditUserModal({
|
||||
الحساب نشط
|
||||
</label>
|
||||
</div>
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">مجموعات الصلاحيات الإضافية</label>
|
||||
<p className="text-xs text-gray-600 mb-2">صلاحيات اختيارية تضاف إلى صلاحيات الوظيفة</p>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<select
|
||||
value={assignRoleId}
|
||||
onChange={(e) => setAssignRoleId(e.target.value)}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="">إضافة مجموعة...</option>
|
||||
{permissionGroups
|
||||
.filter((g) => !userRoles.some((ur) => ur.role.id === g.id))
|
||||
.map((g) => (
|
||||
<option key={g.id} value={g.id}>{g.nameAr || g.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAssignRole}
|
||||
disabled={!assignRoleId || rolesLoading}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 disabled:opacity-50"
|
||||
>
|
||||
إضافة
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{userRoles.map((ur) => (
|
||||
<div key={ur.id} className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded-lg">
|
||||
<span className="font-medium">{ur.role.nameAr || ur.role.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveRole(ur.role.id)}
|
||||
disabled={rolesLoading}
|
||||
className="text-red-600 hover:text-red-700 text-sm disabled:opacity-50"
|
||||
>
|
||||
إزالة
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{userRoles.length === 0 && (
|
||||
<p className="text-sm text-gray-500 py-2">لا توجد مجموعات إضافية</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end pt-4">
|
||||
<button type="button" onClick={onClose} className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 font-medium">
|
||||
إلغاء
|
||||
|
||||
@@ -98,7 +98,15 @@ function ContactDetailContent() {
|
||||
INDIVIDUAL: 'bg-blue-100 text-blue-700',
|
||||
COMPANY: 'bg-green-100 text-green-700',
|
||||
HOLDING: 'bg-purple-100 text-purple-700',
|
||||
GOVERNMENT: 'bg-orange-100 text-orange-700'
|
||||
GOVERNMENT: 'bg-orange-100 text-orange-700',
|
||||
ORGANIZATION: 'bg-cyan-100 text-cyan-700',
|
||||
EMBASSIES: 'bg-red-100 text-red-700',
|
||||
BANK: 'bg-emerald-100 text-emerald-700',
|
||||
UNIVERSITY: 'bg-indigo-100 text-indigo-700',
|
||||
SCHOOL: 'bg-yellow-100 text-yellow-700',
|
||||
UN: 'bg-sky-100 text-sky-700',
|
||||
NGO: 'bg-pink-100 text-pink-700',
|
||||
INSTITUTION: 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
return colors[type] || 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
@@ -108,7 +116,15 @@ function ContactDetailContent() {
|
||||
INDIVIDUAL: 'فرد - Individual',
|
||||
COMPANY: 'شركة - Company',
|
||||
HOLDING: 'مجموعة - Holding',
|
||||
GOVERNMENT: 'حكومي - Government'
|
||||
GOVERNMENT: 'حكومي - Government',
|
||||
ORGANIZATION: 'منظمات - Organizations',
|
||||
BANK: 'بنوك - Banks',
|
||||
UNIVERSITY: 'جامعات - Universities',
|
||||
EMBASSIES: 'سفارات - Embassies',
|
||||
SCHOOL: 'مدارس - Schools',
|
||||
UN: 'UN - United Nations',
|
||||
NGO: 'NGO - Non-Governmental Organization',
|
||||
INSTITUTION: 'مؤسسة - Institution'
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
|
||||
@@ -41,20 +41,17 @@ function flattenCategories(cats: Category[], result: Category[] = []): Category[
|
||||
}
|
||||
|
||||
function ContactsContent() {
|
||||
// State Management
|
||||
const [contacts, setContacts] = useState<Contact[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedContacts, setSelectedContacts] = useState<Set<string>>(new Set())
|
||||
const [showBulkActions, setShowBulkActions] = useState(false)
|
||||
|
||||
// Pagination
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const pageSize = 10
|
||||
|
||||
// Filters
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [selectedType, setSelectedType] = useState('all')
|
||||
const [selectedStatus, setSelectedStatus] = useState('all')
|
||||
@@ -64,7 +61,6 @@ function ContactsContent() {
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
|
||||
|
||||
// Modals
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
@@ -75,7 +71,6 @@ function ContactsContent() {
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [exportExcludeCompanyEmployees, setExportExcludeCompanyEmployees] = useState(false)
|
||||
|
||||
// Fetch Contacts (with debouncing for search)
|
||||
const fetchContacts = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
@@ -104,21 +99,18 @@ function ContactsContent() {
|
||||
}
|
||||
}, [currentPage, searchTerm, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory])
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
const debounce = setTimeout(() => {
|
||||
setCurrentPage(1) // Reset to page 1 on new search
|
||||
setCurrentPage(1)
|
||||
fetchContacts()
|
||||
}, 500)
|
||||
return () => clearTimeout(debounce)
|
||||
}, [searchTerm])
|
||||
|
||||
// Fetch on filter/page change
|
||||
useEffect(() => {
|
||||
fetchContacts()
|
||||
}, [currentPage, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory])
|
||||
|
||||
// Create Contact
|
||||
const handleCreate = async (data: CreateContactData) => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
@@ -136,7 +128,6 @@ function ContactsContent() {
|
||||
}
|
||||
}
|
||||
|
||||
// Edit Contact
|
||||
const handleEdit = async (data: UpdateContactData) => {
|
||||
if (!selectedContact) return
|
||||
|
||||
@@ -156,7 +147,6 @@ function ContactsContent() {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete Contact
|
||||
const handleDelete = async () => {
|
||||
if (!selectedContact) return
|
||||
|
||||
@@ -175,7 +165,6 @@ function ContactsContent() {
|
||||
}
|
||||
}
|
||||
|
||||
// Utility Functions
|
||||
const resetForm = () => {
|
||||
setSelectedContact(null)
|
||||
}
|
||||
@@ -195,7 +184,15 @@ function ContactsContent() {
|
||||
INDIVIDUAL: 'bg-blue-100 text-blue-700',
|
||||
COMPANY: 'bg-green-100 text-green-700',
|
||||
HOLDING: 'bg-purple-100 text-purple-700',
|
||||
GOVERNMENT: 'bg-orange-100 text-orange-700'
|
||||
GOVERNMENT: 'bg-orange-100 text-orange-700',
|
||||
ORGANIZATION: 'bg-cyan-100 text-cyan-700',
|
||||
EMBASSIES: 'bg-red-100 text-red-700',
|
||||
BANK: 'bg-emerald-100 text-emerald-700',
|
||||
UNIVERSITY: 'bg-indigo-100 text-indigo-700',
|
||||
SCHOOL: 'bg-yellow-100 text-yellow-700',
|
||||
UN: 'bg-sky-100 text-sky-700',
|
||||
NGO: 'bg-pink-100 text-pink-700',
|
||||
INSTITUTION: 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
return colors[type] || 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
@@ -209,15 +206,49 @@ function ContactsContent() {
|
||||
INDIVIDUAL: 'فرد',
|
||||
COMPANY: 'شركة',
|
||||
HOLDING: 'مجموعة',
|
||||
GOVERNMENT: 'حكومي'
|
||||
GOVERNMENT: 'حكومي',
|
||||
ORGANIZATION: 'منظمات',
|
||||
EMBASSIES: 'سفارات',
|
||||
BANK: 'بنوك',
|
||||
UNIVERSITY: 'جامعات',
|
||||
SCHOOL: 'مدارس',
|
||||
UN: 'UN',
|
||||
NGO: 'NGO',
|
||||
INSTITUTION: 'مؤسسة'
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
|
||||
const organizationTypes = new Set([
|
||||
'COMPANY',
|
||||
'HOLDING',
|
||||
'GOVERNMENT',
|
||||
'ORGANIZATION',
|
||||
'EMBASSIES',
|
||||
'BANK',
|
||||
'UNIVERSITY',
|
||||
'SCHOOL',
|
||||
'UN',
|
||||
'NGO',
|
||||
'INSTITUTION',
|
||||
])
|
||||
|
||||
const isOrganizationContact = (contact: Contact) => organizationTypes.has(contact.type)
|
||||
|
||||
const getListContactName = (contact: Contact) => {
|
||||
return contact.name || '-'
|
||||
}
|
||||
|
||||
const getListCompanyName = (contact: Contact) => {
|
||||
return contact.companyName || '-'
|
||||
}
|
||||
|
||||
const getListContactNameAr = (contact: Contact) => {
|
||||
return (contact as any).nameAr || ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -289,7 +320,6 @@ function ContactsContent() {
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -344,12 +374,9 @@ function ContactsContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200 mb-6">
|
||||
<div className="space-y-4">
|
||||
{/* Main Filters Row */}
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
@@ -361,7 +388,6 @@ function ContactsContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type Filter */}
|
||||
<select
|
||||
value={selectedType}
|
||||
onChange={(e) => setSelectedType(e.target.value)}
|
||||
@@ -372,9 +398,16 @@ function ContactsContent() {
|
||||
<option value="COMPANY">Companies</option>
|
||||
<option value="HOLDING">Holdings</option>
|
||||
<option value="GOVERNMENT">Government</option>
|
||||
<option value="ORGANIZATION">Organizations</option>
|
||||
<option value="EMBASSIES">Embassies</option>
|
||||
<option value="BANK">Banks</option>
|
||||
<option value="UNIVERSITY">Universities</option>
|
||||
<option value="SCHOOL">Schools</option>
|
||||
<option value="UN">UN</option>
|
||||
<option value="NGO">NGO</option>
|
||||
<option value="INSTITUTION">Institution</option>
|
||||
</select>
|
||||
|
||||
{/* Status Filter */}
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => setSelectedStatus(e.target.value)}
|
||||
@@ -385,7 +418,6 @@ function ContactsContent() {
|
||||
<option value="INACTIVE">Inactive</option>
|
||||
</select>
|
||||
|
||||
{/* Advanced Filters Toggle */}
|
||||
<button
|
||||
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
|
||||
className={`flex items-center gap-2 px-4 py-3 border rounded-lg transition-colors ${
|
||||
@@ -399,11 +431,9 @@ function ContactsContent() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Advanced Filters */}
|
||||
{showAdvancedFilters && (
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Source Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Source</label>
|
||||
<select
|
||||
@@ -423,7 +453,6 @@ function ContactsContent() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Rating Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Rating</label>
|
||||
<select
|
||||
@@ -440,7 +469,6 @@ function ContactsContent() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Category</label>
|
||||
<select
|
||||
@@ -455,7 +483,6 @@ function ContactsContent() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Clear Filters */}
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -478,7 +505,6 @@ function ContactsContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contacts Table */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-12">
|
||||
@@ -525,9 +551,9 @@ function ContactsContent() {
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Contact</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Contact Info</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Company</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Contact Info</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Contact</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Type</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Status</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Actions</th>
|
||||
@@ -554,17 +580,18 @@ function ContactsContent() {
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold">
|
||||
{contact.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">{contact.name}</p>
|
||||
{contact.nameAr && <p className="text-sm text-gray-600">{contact.nameAr}</p>}
|
||||
</div>
|
||||
{getListCompanyName(contact) !== '-' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-900">
|
||||
{getListCompanyName(contact)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4">
|
||||
<div className="space-y-1">
|
||||
{contact.email && (
|
||||
@@ -573,28 +600,40 @@ function ContactsContent() {
|
||||
{contact.email}
|
||||
</div>
|
||||
)}
|
||||
{contact.phone && (
|
||||
{(contact.phone || contact.mobile) && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Phone className="h-4 w-4" />
|
||||
{contact.phone}
|
||||
{contact.phone || contact.mobile}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4">
|
||||
{contact.companyName && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-900">{contact.companyName}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold">
|
||||
{getListContactName(contact).charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">
|
||||
{getListContactName(contact)}
|
||||
</p>
|
||||
{getListContactNameAr(contact) && (
|
||||
<p className="text-sm text-gray-600">
|
||||
{getListContactNameAr(contact)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium ${getTypeColor(contact.type)}`}>
|
||||
<Tag className="h-3 w-3" />
|
||||
{getTypeLabel(contact.type)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
|
||||
contact.status === 'ACTIVE'
|
||||
@@ -604,6 +643,7 @@ function ContactsContent() {
|
||||
{contact.status === 'ACTIVE' ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
@@ -630,12 +670,12 @@ function ContactsContent() {
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)})}
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-600">
|
||||
Showing <span className="font-semibold">{((currentPage - 1) * pageSize) + 1}</span> to{' '}
|
||||
@@ -681,7 +721,6 @@ function ContactsContent() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Create Modal */}
|
||||
<Modal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => {
|
||||
@@ -692,6 +731,7 @@ function ContactsContent() {
|
||||
size="xl"
|
||||
>
|
||||
<ContactForm
|
||||
key="create-contact"
|
||||
onSubmit={async (data) => {
|
||||
await handleCreate(data as CreateContactData)
|
||||
}}
|
||||
@@ -703,7 +743,6 @@ function ContactsContent() {
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal
|
||||
isOpen={showEditModal}
|
||||
onClose={() => {
|
||||
@@ -714,6 +753,7 @@ function ContactsContent() {
|
||||
size="xl"
|
||||
>
|
||||
<ContactForm
|
||||
key={selectedContact?.id || 'edit-contact'}
|
||||
contact={selectedContact || undefined}
|
||||
onSubmit={async (data) => {
|
||||
await handleEdit(data as UpdateContactData)
|
||||
@@ -726,7 +766,6 @@ function ContactsContent() {
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* Export Modal */}
|
||||
{showExportModal && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowExportModal(false)} />
|
||||
@@ -820,7 +859,6 @@ function ContactsContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
{showDeleteDialog && selectedContact && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowDeleteDialog(false)} />
|
||||
@@ -869,7 +907,6 @@ function ContactsContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Modal */}
|
||||
{showImportModal && (
|
||||
<ContactImport
|
||||
onClose={() => setShowImportModal(false)}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { toast } from 'react-hot-toast'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Edit,
|
||||
Archive,
|
||||
History,
|
||||
Award,
|
||||
TrendingDown,
|
||||
@@ -15,15 +14,23 @@ import {
|
||||
Target,
|
||||
Calendar,
|
||||
User,
|
||||
Building2,
|
||||
FileText,
|
||||
Clock,
|
||||
Loader2
|
||||
Loader2,
|
||||
Plus,
|
||||
Check,
|
||||
X,
|
||||
Receipt,
|
||||
FileSignature
|
||||
} from 'lucide-react'
|
||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||
import Modal from '@/components/Modal'
|
||||
import { dealsAPI, Deal } from '@/lib/api/deals'
|
||||
import { quotesAPI, Quote } from '@/lib/api/quotes'
|
||||
import { costSheetsAPI, CostSheet, CostSheetItem } from '@/lib/api/costSheets'
|
||||
import { contractsAPI, Contract, CreateContractData } from '@/lib/api/contracts'
|
||||
import { invoicesAPI, Invoice, InvoiceItem } from '@/lib/api/invoices'
|
||||
import { useLanguage } from '@/contexts/LanguageContext'
|
||||
|
||||
function DealDetailContent() {
|
||||
@@ -34,15 +41,165 @@ function DealDetailContent() {
|
||||
|
||||
const [deal, setDeal] = useState<Deal | null>(null)
|
||||
const [quotes, setQuotes] = useState<Quote[]>([])
|
||||
const [costSheets, setCostSheets] = useState<CostSheet[]>([])
|
||||
const [contracts, setContracts] = useState<Contract[]>([])
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||
const [history, setHistory] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<'info' | 'quotes' | 'history'>('info')
|
||||
const [activeTab, setActiveTab] = useState<'info' | 'quotes' | 'costSheets' | 'contracts' | 'invoices' | 'history'>('info')
|
||||
const [showWinDialog, setShowWinDialog] = useState(false)
|
||||
const [showLoseDialog, setShowLoseDialog] = useState(false)
|
||||
const [showCostSheetModal, setShowCostSheetModal] = useState(false)
|
||||
const [showContractModal, setShowContractModal] = useState(false)
|
||||
const [showInvoiceModal, setShowInvoiceModal] = useState(false)
|
||||
const [showPaymentModal, setShowPaymentModal] = useState<Invoice | null>(null)
|
||||
const [winData, setWinData] = useState({ actualValue: 0, wonReason: '' })
|
||||
const [loseData, setLoseData] = useState({ lostReason: '' })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [costSheetForm, setCostSheetForm] = useState({
|
||||
items: [{ description: '', source: '', cost: 0, quantity: 1 }] as CostSheetItem[],
|
||||
totalCost: 0,
|
||||
suggestedPrice: 0,
|
||||
profitMargin: 0,
|
||||
})
|
||||
const [contractForm, setContractForm] = useState<CreateContractData>({
|
||||
dealId: '',
|
||||
title: '',
|
||||
type: 'SALES',
|
||||
clientInfo: {},
|
||||
companyInfo: {},
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
value: 0,
|
||||
paymentTerms: {},
|
||||
deliveryTerms: {},
|
||||
terms: '',
|
||||
})
|
||||
const [invoiceForm, setInvoiceForm] = useState({
|
||||
items: [{ description: '', quantity: 1, unitPrice: 0, total: 0 }] as InvoiceItem[],
|
||||
subtotal: 0,
|
||||
taxAmount: 0,
|
||||
total: 0,
|
||||
dueDate: '',
|
||||
})
|
||||
const [paymentForm, setPaymentForm] = useState({ paidAmount: 0, paidDate: new Date().toISOString().slice(0, 10) })
|
||||
|
||||
useEffect(() => {
|
||||
if (showPaymentModal) {
|
||||
setPaymentForm({ paidAmount: Number(showPaymentModal?.total) || 0, paidDate: new Date().toISOString().slice(0, 10) })
|
||||
}
|
||||
}, [showPaymentModal])
|
||||
|
||||
useEffect(() => {
|
||||
if (deal && showContractModal) {
|
||||
const contact = deal.contact
|
||||
setContractForm((f) => ({
|
||||
...f,
|
||||
dealId: deal.id,
|
||||
clientInfo: contact ? { name: contact.name, email: contact.email, phone: contact.phone } : {},
|
||||
companyInfo: f.companyInfo && Object.keys(f.companyInfo).length ? f.companyInfo : {},
|
||||
}))
|
||||
}
|
||||
}, [deal, showContractModal])
|
||||
|
||||
const handleCreateCostSheet = async () => {
|
||||
const items = costSheetForm.items.filter((i) => i.cost > 0 || i.description)
|
||||
if (!items.length || costSheetForm.totalCost <= 0 || costSheetForm.suggestedPrice <= 0) {
|
||||
toast.error(t('crm.fixFormErrors'))
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await costSheetsAPI.create({
|
||||
dealId,
|
||||
items,
|
||||
totalCost: costSheetForm.totalCost,
|
||||
suggestedPrice: costSheetForm.suggestedPrice,
|
||||
profitMargin: costSheetForm.profitMargin,
|
||||
})
|
||||
toast.success(t('crm.costSheetCreated'))
|
||||
setShowCostSheetModal(false)
|
||||
setCostSheetForm({ items: [{ description: '', source: '', cost: 0, quantity: 1 }], totalCost: 0, suggestedPrice: 0, profitMargin: 0 })
|
||||
fetchCostSheets()
|
||||
} catch (e: any) {
|
||||
toast.error(e.response?.data?.message || 'Failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateContract = async () => {
|
||||
if (!contractForm.title || !contractForm.startDate || contractForm.value <= 0) {
|
||||
toast.error(t('crm.fixFormErrors'))
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await contractsAPI.create({
|
||||
...contractForm,
|
||||
dealId,
|
||||
clientInfo: contractForm.clientInfo || {},
|
||||
companyInfo: contractForm.companyInfo || {},
|
||||
paymentTerms: contractForm.paymentTerms || {},
|
||||
deliveryTerms: contractForm.deliveryTerms || {},
|
||||
})
|
||||
toast.success(t('crm.contractCreated'))
|
||||
setShowContractModal(false)
|
||||
setContractForm({ dealId: '', title: '', type: 'SALES', clientInfo: {}, companyInfo: {}, startDate: '', endDate: '', value: 0, paymentTerms: {}, deliveryTerms: {}, terms: '' })
|
||||
fetchContracts()
|
||||
} catch (e: any) {
|
||||
toast.error(e.response?.data?.message || 'Failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateInvoice = async () => {
|
||||
const items = invoiceForm.items.filter((i) => i.quantity > 0 && i.unitPrice >= 0)
|
||||
if (!items.length || invoiceForm.total <= 0 || !invoiceForm.dueDate) {
|
||||
toast.error(t('crm.fixFormErrors'))
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await invoicesAPI.create({
|
||||
dealId,
|
||||
items: items.map((i) => ({ ...i, total: (i.quantity || 0) * (i.unitPrice || 0) })),
|
||||
subtotal: invoiceForm.subtotal,
|
||||
taxAmount: invoiceForm.taxAmount,
|
||||
total: invoiceForm.total,
|
||||
dueDate: invoiceForm.dueDate,
|
||||
})
|
||||
toast.success(t('crm.invoiceCreated'))
|
||||
setShowInvoiceModal(false)
|
||||
setInvoiceForm({ items: [{ description: '', quantity: 1, unitPrice: 0, total: 0 }], subtotal: 0, taxAmount: 0, total: 0, dueDate: '' })
|
||||
fetchInvoices()
|
||||
} catch (e: any) {
|
||||
toast.error(e.response?.data?.message || 'Failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRecordPayment = async () => {
|
||||
if (!showPaymentModal || paymentForm.paidAmount <= 0) {
|
||||
toast.error(t('crm.fixFormErrors'))
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await invoicesAPI.recordPayment(showPaymentModal.id, paymentForm.paidAmount, paymentForm.paidDate)
|
||||
toast.success(t('crm.paymentRecorded'))
|
||||
setShowPaymentModal(null)
|
||||
setPaymentForm({ paidAmount: 0, paidDate: new Date().toISOString().slice(0, 10) })
|
||||
fetchInvoices()
|
||||
} catch (e: any) {
|
||||
toast.error(e.response?.data?.message || 'Failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchDeal()
|
||||
@@ -51,6 +208,9 @@ function DealDetailContent() {
|
||||
useEffect(() => {
|
||||
if (deal) {
|
||||
fetchQuotes()
|
||||
fetchCostSheets()
|
||||
fetchContracts()
|
||||
fetchInvoices()
|
||||
fetchHistory()
|
||||
}
|
||||
}, [deal])
|
||||
@@ -88,6 +248,33 @@ function DealDetailContent() {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCostSheets = async () => {
|
||||
try {
|
||||
const data = await costSheetsAPI.getByDeal(dealId)
|
||||
setCostSheets(data || [])
|
||||
} catch {
|
||||
setCostSheets([])
|
||||
}
|
||||
}
|
||||
|
||||
const fetchContracts = async () => {
|
||||
try {
|
||||
const data = await contractsAPI.getByDeal(dealId)
|
||||
setContracts(data || [])
|
||||
} catch {
|
||||
setContracts([])
|
||||
}
|
||||
}
|
||||
|
||||
const fetchInvoices = async () => {
|
||||
try {
|
||||
const data = await invoicesAPI.getByDeal(dealId)
|
||||
setInvoices(data || [])
|
||||
} catch {
|
||||
setInvoices([])
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
ACTIVE: 'bg-green-100 text-green-700',
|
||||
@@ -272,18 +459,18 @@ function DealDetailContent() {
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-4 px-6">
|
||||
{(['info', 'quotes', 'history'] as const).map((tab) => (
|
||||
<nav className="flex gap-4 px-6 overflow-x-auto">
|
||||
{(['info', 'quotes', 'costSheets', 'contracts', 'invoices', 'history'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors whitespace-nowrap ${
|
||||
activeTab === tab
|
||||
? 'border-green-600 text-green-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab === 'info' ? t('crm.dealInfo') : tab === 'quotes' ? t('crm.quotes') : t('crm.history')}
|
||||
{tab === 'info' ? t('crm.dealInfo') : tab === 'quotes' ? t('crm.quotes') : tab === 'costSheets' ? t('crm.costSheets') : tab === 'contracts' ? t('crm.contracts') : tab === 'invoices' ? t('crm.invoices') : t('crm.history')}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
@@ -377,6 +564,135 @@ function DealDetailContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'costSheets' && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="font-semibold text-gray-900">{t('crm.costSheets')}</h3>
|
||||
<button onClick={() => setShowCostSheetModal(true)} className="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('crm.addCostSheet')}
|
||||
</button>
|
||||
</div>
|
||||
{costSheets.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<FileText className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>{t('common.noData')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{costSheets.map((cs) => (
|
||||
<div key={cs.id} className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{cs.costSheetNumber}</p>
|
||||
<p className="text-sm text-gray-500">v{cs.version} · {cs.status}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{Number(cs.totalCost)?.toLocaleString()} SAR cost · {Number(cs.suggestedPrice)?.toLocaleString()} SAR suggested · {Number(cs.profitMargin)}% margin
|
||||
</p>
|
||||
</div>
|
||||
{cs.status === 'DRAFT' && (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={async () => { try { await costSheetsAPI.approve(cs.id); toast.success(t('crm.costSheetApproved')); fetchCostSheets(); } catch (e: any) { toast.error(e.response?.data?.message || 'Failed'); } }} className="flex items-center gap-1 px-2 py-1 text-green-600 border border-green-300 rounded hover:bg-green-50 text-sm">
|
||||
<Check className="h-4 w-4" />
|
||||
{t('crm.approve')}
|
||||
</button>
|
||||
<button onClick={async () => { try { await costSheetsAPI.reject(cs.id); toast.success(t('crm.costSheetRejected')); fetchCostSheets(); } catch (e: any) { toast.error(e.response?.data?.message || 'Failed'); } }} className="flex items-center gap-1 px-2 py-1 text-red-600 border border-red-300 rounded hover:bg-red-50 text-sm">
|
||||
<X className="h-4 w-4" />
|
||||
{t('crm.reject')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">{formatDate(cs.createdAt)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'contracts' && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="font-semibold text-gray-900">{t('crm.contracts')}</h3>
|
||||
<button onClick={() => setShowContractModal(true)} className="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('crm.addContract')}
|
||||
</button>
|
||||
</div>
|
||||
{contracts.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<FileSignature className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>{t('common.noData')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{contracts.map((c) => (
|
||||
<div key={c.id} className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{c.contractNumber} · {c.title}</p>
|
||||
<p className="text-sm text-gray-500">{c.type} · {c.status}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{Number(c.value)?.toLocaleString()} SAR · {formatDate(c.startDate)} {c.endDate ? `– ${formatDate(c.endDate)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
{c.status === 'PENDING_SIGNATURE' && (
|
||||
<button onClick={async () => { try { await contractsAPI.sign(c.id); toast.success(t('crm.contractSigned')); fetchContracts(); } catch (e: any) { toast.error(e.response?.data?.message || 'Failed'); } }} className="flex items-center gap-1 px-2 py-1 text-green-600 border border-green-300 rounded hover:bg-green-50 text-sm">
|
||||
<FileSignature className="h-4 w-4" />
|
||||
{t('crm.markSigned')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">{formatDate(c.createdAt)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'invoices' && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="font-semibold text-gray-900">{t('crm.invoices')}</h3>
|
||||
<button onClick={() => setShowInvoiceModal(true)} className="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('crm.addInvoice')}
|
||||
</button>
|
||||
</div>
|
||||
{invoices.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<Receipt className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>{t('common.noData')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{invoices.map((inv) => (
|
||||
<div key={inv.id} className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{inv.invoiceNumber}</p>
|
||||
<p className="text-sm text-gray-500">{inv.status}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{Number(inv.total)?.toLocaleString()} SAR · {inv.paidAmount ? `${Number(inv.paidAmount)?.toLocaleString()} paid` : ''} · due {formatDate(inv.dueDate)}
|
||||
</p>
|
||||
</div>
|
||||
{(inv.status === 'SENT' || inv.status === 'OVERDUE') && (
|
||||
<button onClick={() => setShowPaymentModal(inv)} className="flex items-center gap-1 px-2 py-1 text-green-600 border border-green-300 rounded hover:bg-green-50 text-sm">
|
||||
<DollarSign className="h-4 w-4" />
|
||||
{t('crm.recordPayment')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">{formatDate(inv.createdAt)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<div>
|
||||
{history.length === 0 ? (
|
||||
@@ -527,6 +843,179 @@ function DealDetailContent() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cost Sheet Modal */}
|
||||
<Modal isOpen={showCostSheetModal} onClose={() => setShowCostSheetModal(false)} title={t('crm.addCostSheet')} size="xl">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">{t('crm.costSheetItems')}</p>
|
||||
{costSheetForm.items.map((item, idx) => (
|
||||
<div key={idx} className="grid grid-cols-12 gap-2 items-end">
|
||||
<input placeholder={t('crm.description')} value={item.description || ''} onChange={(e) => {
|
||||
const next = [...costSheetForm.items]; next[idx] = { ...next[idx], description: e.target.value }; setCostSheetForm({ ...costSheetForm, items: next })
|
||||
}} className="col-span-4 px-3 py-2 border rounded-lg" />
|
||||
<input placeholder={t('crm.source')} value={item.source || ''} onChange={(e) => {
|
||||
const next = [...costSheetForm.items]; next[idx] = { ...next[idx], source: e.target.value }; setCostSheetForm({ ...costSheetForm, items: next })
|
||||
}} className="col-span-2 px-3 py-2 border rounded-lg" />
|
||||
<input type="number" placeholder="Cost" value={item.cost || ''} onChange={(e) => {
|
||||
const next = [...costSheetForm.items]; next[idx] = { ...next[idx], cost: parseFloat(e.target.value) || 0 }; const total = next.reduce((s, i) => s + (i.cost || 0) * (i.quantity || 1), 0); setCostSheetForm({ ...costSheetForm, items: next, totalCost: total })
|
||||
}} className="col-span-2 px-3 py-2 border rounded-lg" />
|
||||
<input type="number" placeholder="Qty" value={item.quantity || 1} onChange={(e) => {
|
||||
const next = [...costSheetForm.items]; next[idx] = { ...next[idx], quantity: parseInt(e.target.value) || 1 }; const total = next.reduce((s, i) => s + (i.cost || 0) * (i.quantity || 1), 0); setCostSheetForm({ ...costSheetForm, items: next, totalCost: total })
|
||||
}} className="col-span-2 px-3 py-2 border rounded-lg" />
|
||||
<button type="button" onClick={() => setCostSheetForm({ ...costSheetForm, items: costSheetForm.items.filter((_, i) => i !== idx) })} className="col-span-2 py-2 text-red-600 hover:bg-red-50 rounded">×</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setCostSheetForm({ ...costSheetForm, items: [...costSheetForm.items, { description: '', source: '', cost: 0, quantity: 1 }] })} className="text-sm text-green-600 hover:underline">
|
||||
+ {t('crm.addRow')}
|
||||
</button>
|
||||
<div className="grid grid-cols-3 gap-4 pt-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.totalCost')}</label>
|
||||
<input type="number" value={costSheetForm.totalCost || ''} onChange={(e) => setCostSheetForm({ ...costSheetForm, totalCost: parseFloat(e.target.value) || 0 })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.suggestedPrice')}</label>
|
||||
<input type="number" value={costSheetForm.suggestedPrice || ''} onChange={(e) => setCostSheetForm({ ...costSheetForm, suggestedPrice: parseFloat(e.target.value) || 0 })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.profitMargin')} (%)</label>
|
||||
<input type="number" value={costSheetForm.profitMargin || ''} onChange={(e) => setCostSheetForm({ ...costSheetForm, profitMargin: parseFloat(e.target.value) || 0 })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button onClick={() => setShowCostSheetModal(false)} className="px-4 py-2 border rounded-lg">{t('common.cancel')}</button>
|
||||
<button onClick={handleCreateCostSheet} disabled={submitting} className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg disabled:opacity-50">
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Contract Modal */}
|
||||
<Modal isOpen={showContractModal} onClose={() => setShowContractModal(false)} title={t('crm.addContract')} size="xl">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.contractTitle')} *</label>
|
||||
<input value={contractForm.title} onChange={(e) => setContractForm({ ...contractForm, title: e.target.value })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.contractType')}</label>
|
||||
<select value={contractForm.type} onChange={(e) => setContractForm({ ...contractForm, type: e.target.value })} className="w-full px-3 py-2 border rounded-lg">
|
||||
<option value="SALES">{t('crm.contractTypeSales')}</option>
|
||||
<option value="SERVICE">{t('crm.contractTypeService')}</option>
|
||||
<option value="MAINTENANCE">{t('crm.contractTypeMaintenance')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.contractValue')} *</label>
|
||||
<input type="number" value={contractForm.value || ''} onChange={(e) => setContractForm({ ...contractForm, value: parseFloat(e.target.value) || 0 })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.startDate')} *</label>
|
||||
<input type="date" value={contractForm.startDate} onChange={(e) => setContractForm({ ...contractForm, startDate: e.target.value })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.endDate')}</label>
|
||||
<input type="date" value={contractForm.endDate || ''} onChange={(e) => setContractForm({ ...contractForm, endDate: e.target.value })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.paymentTerms')}</label>
|
||||
<input placeholder="e.g. Net 30" value={typeof contractForm.paymentTerms === 'object' && contractForm.paymentTerms?.description ? (contractForm.paymentTerms as any).description : ''} onChange={(e) => setContractForm({ ...contractForm, paymentTerms: { description: e.target.value } })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.deliveryTerms')}</label>
|
||||
<input placeholder="e.g. FOB" value={typeof contractForm.deliveryTerms === 'object' && contractForm.deliveryTerms?.description ? (contractForm.deliveryTerms as any).description : ''} onChange={(e) => setContractForm({ ...contractForm, deliveryTerms: { description: e.target.value } })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.terms')}</label>
|
||||
<textarea value={contractForm.terms} onChange={(e) => setContractForm({ ...contractForm, terms: e.target.value })} rows={3} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button onClick={() => setShowContractModal(false)} className="px-4 py-2 border rounded-lg">{t('common.cancel')}</button>
|
||||
<button onClick={handleCreateContract} disabled={submitting} className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg disabled:opacity-50">
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Invoice Modal */}
|
||||
<Modal isOpen={showInvoiceModal} onClose={() => setShowInvoiceModal(false)} title={t('crm.addInvoice')} size="xl">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">{t('crm.invoiceItems')}</p>
|
||||
{invoiceForm.items.map((item, idx) => (
|
||||
<div key={idx} className="grid grid-cols-12 gap-2 items-end">
|
||||
<input placeholder={t('crm.description')} value={item.description || ''} onChange={(e) => { const next = [...invoiceForm.items]; next[idx] = { ...next[idx], description: e.target.value }; setInvoiceForm({ ...invoiceForm, items: next }) }} className="col-span-4 px-3 py-2 border rounded-lg" />
|
||||
<input type="number" placeholder="Qty" value={item.quantity || 1} onChange={(e) => {
|
||||
const next = [...invoiceForm.items]; next[idx] = { ...next[idx], quantity: parseInt(e.target.value) || 1, unitPrice: next[idx].unitPrice || 0 }; next[idx].total = (next[idx].quantity || 0) * (next[idx].unitPrice || 0); const sub = next.reduce((s, i) => s + ((i.quantity || 0) * (i.unitPrice || 0)), 0); setInvoiceForm({ ...invoiceForm, items: next, subtotal: sub, total: sub + invoiceForm.taxAmount })
|
||||
}} className="col-span-2 px-3 py-2 border rounded-lg" />
|
||||
<input type="number" placeholder="Unit Price" value={item.unitPrice || ''} onChange={(e) => {
|
||||
const next = [...invoiceForm.items]; next[idx] = { ...next[idx], unitPrice: parseFloat(e.target.value) || 0 }; next[idx].total = (next[idx].quantity || 0) * (next[idx].unitPrice || 0); const sub = next.reduce((s, i) => s + ((i.quantity || 0) * (i.unitPrice || 0)), 0); setInvoiceForm({ ...invoiceForm, items: next, subtotal: sub, total: sub + invoiceForm.taxAmount })
|
||||
}} className="col-span-2 px-3 py-2 border rounded-lg" />
|
||||
<span className="col-span-2 py-2 text-gray-600">{(item.quantity || 0) * (item.unitPrice || 0)}</span>
|
||||
<button type="button" onClick={() => setInvoiceForm({ ...invoiceForm, items: invoiceForm.items.filter((_, i) => i !== idx) })} className="col-span-2 py-2 text-red-600 hover:bg-red-50 rounded">×</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setInvoiceForm({ ...invoiceForm, items: [...invoiceForm.items, { description: '', quantity: 1, unitPrice: 0, total: 0 }] })} className="text-sm text-green-600 hover:underline">
|
||||
+ {t('crm.addRow')}
|
||||
</button>
|
||||
<div className="grid grid-cols-4 gap-4 pt-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.subtotal')}</label>
|
||||
<input type="number" value={invoiceForm.subtotal || ''} onChange={(e) => setInvoiceForm({ ...invoiceForm, subtotal: parseFloat(e.target.value) || 0, total: (parseFloat(e.target.value) || 0) + invoiceForm.taxAmount })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.taxAmount')}</label>
|
||||
<input type="number" value={invoiceForm.taxAmount || ''} onChange={(e) => setInvoiceForm({ ...invoiceForm, taxAmount: parseFloat(e.target.value) || 0, total: invoiceForm.subtotal + (parseFloat(e.target.value) || 0) })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.total')}</label>
|
||||
<input type="number" value={invoiceForm.total || ''} onChange={(e) => setInvoiceForm({ ...invoiceForm, total: parseFloat(e.target.value) || 0 })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.dueDate')} *</label>
|
||||
<input type="date" value={invoiceForm.dueDate} onChange={(e) => setInvoiceForm({ ...invoiceForm, dueDate: e.target.value })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button onClick={() => setShowInvoiceModal(false)} className="px-4 py-2 border rounded-lg">{t('common.cancel')}</button>
|
||||
<button onClick={handleCreateInvoice} disabled={submitting} className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg disabled:opacity-50">
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Record Payment Modal */}
|
||||
{showPaymentModal && (
|
||||
<Modal isOpen={!!showPaymentModal} onClose={() => setShowPaymentModal(null)} title={t('crm.recordPayment')}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">{showPaymentModal.invoiceNumber} · {Number(showPaymentModal.total)?.toLocaleString()} SAR</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.paidAmount')} *</label>
|
||||
<input type="number" value={paymentForm.paidAmount || ''} onChange={(e) => setPaymentForm({ ...paymentForm, paidAmount: parseFloat(e.target.value) || 0 })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.paidDate')}</label>
|
||||
<input type="date" value={paymentForm.paidDate} onChange={(e) => setPaymentForm({ ...paymentForm, paidDate: e.target.value })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button onClick={() => setShowPaymentModal(null)} className="px-4 py-2 border rounded-lg">{t('common.cancel')}</button>
|
||||
<button onClick={handleRecordPayment} disabled={submitting} className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg disabled:opacity-50">
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{t('crm.recordPayment')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -902,11 +902,11 @@ function CRMContent() {
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{deal.estimatedValue.toLocaleString()} SAR
|
||||
{(deal.estimatedValue ?? 0).toLocaleString()} SAR
|
||||
</span>
|
||||
{deal.actualValue && (
|
||||
{(deal.actualValue ?? 0) > 0 && (
|
||||
<p className="text-xs text-green-600">
|
||||
Actual: {deal.actualValue.toLocaleString()}
|
||||
Actual: {(deal.actualValue ?? 0).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Image from 'next/image'
|
||||
import logoImage from '@/assets/logo.png'
|
||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { useLanguage } from '@/contexts/LanguageContext'
|
||||
@@ -7,6 +10,7 @@ import LanguageSwitcher from '@/components/LanguageSwitcher'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Users,
|
||||
User,
|
||||
TrendingUp,
|
||||
Package,
|
||||
CheckSquare,
|
||||
@@ -16,12 +20,23 @@ import {
|
||||
Building2,
|
||||
Settings,
|
||||
Bell,
|
||||
Shield
|
||||
Shield,
|
||||
FileText
|
||||
} from 'lucide-react'
|
||||
import { dashboardAPI } from '@/lib/api'
|
||||
|
||||
function DashboardContent() {
|
||||
const { user, logout, hasPermission } = useAuth()
|
||||
const { t, language, dir } = useLanguage()
|
||||
const [stats, setStats] = useState({ contacts: 0, activeTasks: 0, notifications: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
dashboardAPI.getStats()
|
||||
.then((res) => {
|
||||
if (res.data?.data) setStats(res.data.data)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const allModules = [
|
||||
{
|
||||
@@ -44,6 +59,16 @@ function DashboardContent() {
|
||||
description: 'الفرص التجارية والعروض والصفقات',
|
||||
permission: 'crm'
|
||||
},
|
||||
{
|
||||
id: 'tenders',
|
||||
name: 'إدارة المناقصات',
|
||||
nameEn: 'Tender Management',
|
||||
icon: FileText,
|
||||
color: 'bg-indigo-500',
|
||||
href: '/tenders',
|
||||
description: 'تسجيل ومتابعة المناقصات وتحويلها إلى فرص',
|
||||
permission: 'tenders'
|
||||
},
|
||||
{
|
||||
id: 'inventory',
|
||||
name: 'المخزون والأصول',
|
||||
@@ -74,6 +99,16 @@ function DashboardContent() {
|
||||
description: 'الموظفين والإجازات والرواتب',
|
||||
permission: 'hr'
|
||||
},
|
||||
{
|
||||
id: 'portal',
|
||||
name: 'البوابة الذاتية',
|
||||
nameEn: 'My Portal',
|
||||
icon: User,
|
||||
color: 'bg-cyan-500',
|
||||
href: '/portal',
|
||||
description: 'قروضي، إجازاتي، طلبات الشراء والرواتب',
|
||||
permission: 'portal'
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
name: 'التسويق',
|
||||
@@ -108,11 +143,16 @@ function DashboardContent() {
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary-600 p-2 rounded-lg">
|
||||
<Building2 className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<Image
|
||||
src={logoImage}
|
||||
alt="Company Logo"
|
||||
width={48}
|
||||
height={48}
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Z.CRM</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900">ATMATA</h1>
|
||||
<p className="text-sm text-gray-600">نظام إدارة علاقات العملاء</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,7 +168,7 @@ function DashboardContent() {
|
||||
</div>
|
||||
|
||||
{/* Admin Panel Link - Only for admins */}
|
||||
{(hasPermission('admin', 'view') || user?.role?.name === 'المدير العام' || user?.role?.nameEn === 'General Manager') && (
|
||||
{hasPermission('admin', 'view') && (
|
||||
<Link
|
||||
href="/admin"
|
||||
className="p-2 hover:bg-red-50 rounded-lg transition-colors relative group"
|
||||
@@ -144,7 +184,9 @@ function DashboardContent() {
|
||||
{/* Notifications */}
|
||||
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors relative">
|
||||
<Bell className="h-5 w-5 text-gray-600" />
|
||||
{stats.notifications > 0 && (
|
||||
<span className="absolute top-1 right-1 h-2 w-2 bg-red-500 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Settings */}
|
||||
@@ -193,7 +235,7 @@ function DashboardContent() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">المهام النشطة</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">12</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">{stats.activeTasks}</p>
|
||||
</div>
|
||||
<div className="bg-green-100 p-3 rounded-lg">
|
||||
<CheckSquare className="h-8 w-8 text-green-600" />
|
||||
@@ -205,7 +247,7 @@ function DashboardContent() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">الإشعارات</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">5</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">{stats.notifications}</p>
|
||||
</div>
|
||||
<div className="bg-orange-100 p-3 rounded-lg">
|
||||
<Bell className="h-8 w-8 text-orange-600" />
|
||||
@@ -217,7 +259,7 @@ function DashboardContent() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">جهات الاتصال</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">248</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">{stats.contacts}</p>
|
||||
</div>
|
||||
<div className="bg-purple-100 p-3 rounded-lg">
|
||||
<Users className="h-8 w-8 text-purple-600" />
|
||||
@@ -268,37 +310,7 @@ function DashboardContent() {
|
||||
{/* Recent Activity */}
|
||||
<div className="bg-white rounded-xl shadow-md p-6 border border-gray-100">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4">النشاط الأخير</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 p-3 hover:bg-gray-50 rounded-lg transition-colors">
|
||||
<div className="bg-blue-100 p-2 rounded-lg">
|
||||
<Users className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-gray-900">تم إضافة عميل جديد</p>
|
||||
<p className="text-xs text-gray-600">منذ ساعتين</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-3 hover:bg-gray-50 rounded-lg transition-colors">
|
||||
<div className="bg-green-100 p-2 rounded-lg">
|
||||
<TrendingUp className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-gray-900">تم إغلاق صفقة جديدة</p>
|
||||
<p className="text-xs text-gray-600">منذ 4 ساعات</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-3 hover:bg-gray-50 rounded-lg transition-colors">
|
||||
<div className="bg-orange-100 p-2 rounded-lg">
|
||||
<CheckSquare className="h-5 w-5 text-orange-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-gray-900">تم إكمال مهمة</p>
|
||||
<p className="text-xs text-gray-600">منذ يوم واحد</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-500 text-center py-6">لا يوجد نشاط حديث</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<rect width="128" height="128" rx="24" fill="#2563EB"/>
|
||||
<text x="64" y="82" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="72" font-weight="800" fill="#FFFFFF">Z</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 270 B |
@@ -19,8 +19,11 @@ const readexPro = Readex_Pro({
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Z.CRM - نظام إدارة علاقات العملاء',
|
||||
title: 'ATMATA - نظام إدارة علاقات العملاء',
|
||||
description: 'Enterprise CRM System for Contact Management, Sales, HR, Inventory, Projects, and Marketing',
|
||||
icons: {
|
||||
icon: '/favicon.ico',
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -7,7 +7,7 @@ import { LogIn, Mail, Lock, Building2, AlertCircle } from 'lucide-react'
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login } = useAuth()
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailOrUsername, setEmailOrUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
@@ -18,7 +18,7 @@ export default function LoginPage() {
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
await login(email, password)
|
||||
await login(emailOrUsername, password)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'فشل تسجيل الدخول. الرجاء المحاولة مرة أخرى.')
|
||||
} finally {
|
||||
@@ -37,7 +37,7 @@ export default function LoginPage() {
|
||||
</div>
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">تسجيل الدخول</h1>
|
||||
<p className="text-gray-600">Z.CRM - نظام إدارة علاقات العملاء</p>
|
||||
<p className="text-gray-600">ATMATA - نظام إدارة علاقات العملاء</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
@@ -50,20 +50,21 @@ export default function LoginPage() {
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Email Field */}
|
||||
{/* Email or Username Field */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
البريد الإلكتروني
|
||||
<label htmlFor="emailOrUsername" className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
البريد الإلكتروني أو اسم المستخدم
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
id="emailOrUsername"
|
||||
type="text"
|
||||
value={emailOrUsername}
|
||||
onChange={(e) => setEmailOrUsername(e.target.value)}
|
||||
required
|
||||
placeholder="example@atmata.com"
|
||||
placeholder="admin أو example@atmata.com"
|
||||
autoComplete="username"
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
@@ -110,15 +111,14 @@ export default function LoginPage() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Demo Credentials */}
|
||||
{/* System Administrator
|
||||
<div className="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h3 className="text-sm font-semibold text-blue-900 mb-2">الحسابات التجريبية:</h3>
|
||||
<div className="text-sm text-blue-800 space-y-1">
|
||||
<p>• <strong>المدير العام:</strong> gm@atmata.com / Admin@123</p>
|
||||
<p>• <strong>مدير المبيعات:</strong> sales.manager@atmata.com / Admin@123</p>
|
||||
<p>• <strong>مندوب مبيعات:</strong> sales.rep@atmata.com / Admin@123</p>
|
||||
<h3 className="text-sm font-semibold text-blue-900 mb-2">مدير النظام:</h3>
|
||||
<div className="text-sm text-blue-800">
|
||||
<p><strong>admin@system.local</strong> / Admin@123</p>
|
||||
</div>
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
|
||||
{/* Back to Home */}
|
||||
|
||||
@@ -628,9 +628,9 @@ function MarketingContent() {
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{(campaign.budget || 0).toLocaleString()} SAR
|
||||
</span>
|
||||
{campaign.actualCost && (
|
||||
{(campaign.actualCost ?? 0) > 0 && (
|
||||
<p className="text-xs text-gray-600">
|
||||
Spent: {campaign.actualCost.toLocaleString()}
|
||||
Spent: {(campaign.actualCost ?? 0).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function Home() {
|
||||
<Building2 className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Z.CRM</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900">ATMATA</h1>
|
||||
<p className="text-sm text-gray-600">نظام إدارة علاقات العملاء</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,7 +144,7 @@ export default function Home() {
|
||||
جاهز لتحويل إدارة أعمالك؟
|
||||
</h3>
|
||||
<p className="text-xl mb-8 text-primary-100">
|
||||
ابدأ باستخدام Z.CRM اليوم وشاهد الفرق
|
||||
ابدأ باستخدام Atmata System اليوم وشاهد الفرق
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
@@ -162,10 +162,10 @@ export default function Home() {
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-2 mb-3">
|
||||
<Building2 className="h-6 w-6 text-primary-600" />
|
||||
<span className="text-lg font-bold text-gray-900">Z.CRM</span>
|
||||
<span className="text-lg font-bold text-gray-900">Atmata System</span>
|
||||
</div>
|
||||
<p className="text-gray-600">
|
||||
© 2024 Z.CRM. جميع الحقوق محفوظة.
|
||||
© 2026 ATMATA جميع الحقوق محفوظة.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,210 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { portalAPI, type PurchaseRequest } from '@/lib/api/portal'
|
||||
import Modal from '@/components/Modal'
|
||||
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { ShoppingCart, Plus } from 'lucide-react'
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||
PENDING: { label: 'قيد المراجعة', color: 'bg-amber-100 text-amber-800' },
|
||||
APPROVED: { label: 'معتمد', color: 'bg-green-100 text-green-800' },
|
||||
REJECTED: { label: 'مرفوض', color: 'bg-red-100 text-red-800' },
|
||||
ORDERED: { label: 'تم الطلب', color: 'bg-blue-100 text-blue-800' },
|
||||
}
|
||||
|
||||
export default function PortalPurchaseRequestsPage() {
|
||||
const [requests, setRequests] = useState<PurchaseRequest[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [form, setForm] = useState({
|
||||
items: [{ description: '', quantity: 1, estimatedPrice: '' }],
|
||||
reason: '',
|
||||
priority: 'NORMAL',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
portalAPI.getPurchaseRequests()
|
||||
.then(setRequests)
|
||||
.catch(() => toast.error('فشل تحميل الطلبات'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const addItem = () => setForm((p) => ({ ...p, items: [...p.items, { description: '', quantity: 1, estimatedPrice: '' }] }))
|
||||
const removeItem = (i: number) =>
|
||||
setForm((p) => ({ ...p, items: p.items.filter((_, idx) => idx !== i) }))
|
||||
const updateItem = (i: number, key: string, value: string | number) =>
|
||||
setForm((p) => ({
|
||||
...p,
|
||||
items: p.items.map((it, idx) => (idx === i ? { ...it, [key]: value } : it)),
|
||||
}))
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const items = form.items
|
||||
.filter((it) => it.description.trim())
|
||||
.map((it) => ({
|
||||
description: it.description,
|
||||
quantity: it.quantity || 1,
|
||||
estimatedPrice: parseFloat(String(it.estimatedPrice)) || 0,
|
||||
}))
|
||||
if (items.length === 0) {
|
||||
toast.error('أضف صنفاً واحداً على الأقل')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
portalAPI.submitPurchaseRequest({
|
||||
items,
|
||||
reason: form.reason || undefined,
|
||||
priority: form.priority,
|
||||
})
|
||||
.then((pr) => {
|
||||
setRequests((prev) => [pr, ...prev])
|
||||
setShowModal(false)
|
||||
setForm({ items: [{ description: '', quantity: 1, estimatedPrice: '' }], reason: '', priority: 'NORMAL' })
|
||||
toast.success('تم إرسال طلب الشراء')
|
||||
})
|
||||
.catch(() => toast.error('فشل إرسال الطلب'))
|
||||
.finally(() => setSubmitting(false))
|
||||
}
|
||||
|
||||
if (loading) return <LoadingSpinner />
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">طلبات الشراء</h1>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
طلب شراء جديد
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{requests.length === 0 ? (
|
||||
<div className="bg-white rounded-xl shadow p-12 text-center text-gray-500">
|
||||
<ShoppingCart className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>لا توجد طلبات شراء</p>
|
||||
<button onClick={() => setShowModal(true)} className="mt-4 text-teal-600 hover:underline">
|
||||
تقديم طلب شراء
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{requests.map((pr) => {
|
||||
const statusInfo = STATUS_MAP[pr.status] || { label: pr.status, color: 'bg-gray-100 text-gray-800' }
|
||||
const items = Array.isArray(pr.items) ? pr.items : []
|
||||
return (
|
||||
<div key={pr.id} className="bg-white rounded-xl shadow p-6 border border-gray-100">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">{pr.requestNumber}</p>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{pr.totalAmount != null ? `${Number(pr.totalAmount).toLocaleString()} ر.س` : '-'}
|
||||
</p>
|
||||
{items.length > 0 && (
|
||||
<ul className="mt-2 text-sm text-gray-600 list-disc list-inside">
|
||||
{items.slice(0, 3).map((it: any, i: number) => (
|
||||
<li key={i}>
|
||||
{it.description} × {it.quantity || 1}
|
||||
{it.estimatedPrice ? ` (${Number(it.estimatedPrice).toLocaleString()} ر.س)` : ''}
|
||||
</li>
|
||||
))}
|
||||
{items.length > 3 && <li>... و {items.length - 3} أصناف أخرى</li>}
|
||||
</ul>
|
||||
)}
|
||||
{pr.rejectedReason && (
|
||||
<p className="mt-2 text-sm text-red-600">سبب الرفض: {pr.rejectedReason}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب شراء جديد">
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-h-[70vh] overflow-y-auto">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="text-sm font-medium text-gray-700">الأصناف</label>
|
||||
<button type="button" onClick={addItem} className="text-teal-600 text-sm hover:underline">
|
||||
+ إضافة صنف
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{form.items.map((it, i) => (
|
||||
<div key={i} className="flex gap-2 items-start border p-2 rounded">
|
||||
<input
|
||||
placeholder="الوصف"
|
||||
value={it.description}
|
||||
onChange={(e) => updateItem(i, 'description', e.target.value)}
|
||||
className="flex-1 px-2 py-1 border rounded text-sm"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="الكمية"
|
||||
value={it.quantity}
|
||||
onChange={(e) => updateItem(i, 'quantity', parseInt(e.target.value) || 1)}
|
||||
className="w-20 px-2 py-1 border rounded text-sm"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="السعر"
|
||||
value={it.estimatedPrice}
|
||||
onChange={(e) => updateItem(i, 'estimatedPrice', e.target.value)}
|
||||
className="w-24 px-2 py-1 border rounded text-sm"
|
||||
/>
|
||||
<button type="button" onClick={() => removeItem(i)} className="text-red-600 text-sm">
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">الأولوية</label>
|
||||
<select
|
||||
value={form.priority}
|
||||
onChange={(e) => setForm((p) => ({ ...p, priority: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="LOW">منخفضة</option>
|
||||
<option value="NORMAL">عادية</option>
|
||||
<option value="HIGH">عالية</option>
|
||||
<option value="URGENT">عاجلة</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">السبب / التوضيح</label>
|
||||
<textarea
|
||||
value={form.reason}
|
||||
onChange={(e) => setForm((p) => ({ ...p, reason: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={() => setShowModal(false)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">
|
||||
إلغاء
|
||||
</button>
|
||||
<button type="submit" disabled={submitting} className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50">
|
||||
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
frontend/src/app/portal/salaries/page.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { portalAPI, type Salary } from '@/lib/api/portal'
|
||||
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||
import { DollarSign } from 'lucide-react'
|
||||
|
||||
const MONTHS_AR = ['يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر']
|
||||
|
||||
export default function PortalSalariesPage() {
|
||||
const [salaries, setSalaries] = useState<Salary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
portalAPI.getSalaries()
|
||||
.then(setSalaries)
|
||||
.catch(() => setSalaries([]))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) return <LoadingSpinner />
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">رواتبي</h1>
|
||||
|
||||
{salaries.length === 0 ? (
|
||||
<div className="bg-white rounded-xl shadow p-12 text-center text-gray-500">
|
||||
<DollarSign className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>لا توجد سجلات رواتب</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{salaries.map((s) => (
|
||||
<div key={s.id} className="bg-white rounded-xl shadow p-6 border border-gray-100">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">
|
||||
{MONTHS_AR[s.month - 1]} {s.year}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-teal-600 mt-1">
|
||||
{Number(s.netSalary).toLocaleString()} ر.س
|
||||
</p>
|
||||
<div className="mt-2 text-sm text-gray-600 space-y-1">
|
||||
<p>الأساس: {Number(s.basicSalary).toLocaleString()} | البدلات: {Number(s.allowances).toLocaleString()} | الخصومات: {Number(s.deductions).toLocaleString()}</p>
|
||||
<p>عمولة: {Number(s.commissions).toLocaleString()} | إضافي: {Number(s.overtimePay).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
s.status === 'PAID' ? 'bg-green-100 text-green-800' :
|
||||
s.status === 'APPROVED' ? 'bg-blue-100 text-blue-800' : 'bg-amber-100 text-amber-800'
|
||||
}`}>
|
||||
{s.status === 'PAID' ? 'مدفوع' : s.status === 'APPROVED' ? 'معتمد' : 'قيد المعالجة'}
|
||||
</span>
|
||||
</div>
|
||||
{s.paidDate && (
|
||||
<p className="text-xs text-gray-500 mt-2">تاريخ الدفع: {new Date(s.paidDate).toLocaleDateString('ar-SA')}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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
@@ -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
|
After Width: | Height: | Size: 101 KiB |
@@ -15,11 +15,7 @@ interface ContactFormProps {
|
||||
submitting?: boolean
|
||||
}
|
||||
|
||||
export default function ContactForm({ contact, onSubmit, onCancel, submitting = false }: ContactFormProps) {
|
||||
const isEdit = !!contact
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState<CreateContactData>({
|
||||
const buildInitialFormData = (contact?: Contact): CreateContactData => ({
|
||||
type: contact?.type || 'INDIVIDUAL',
|
||||
name: contact?.name || '',
|
||||
nameAr: contact?.nameAr,
|
||||
@@ -33,7 +29,7 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
||||
commercialRegister: contact?.commercialRegister,
|
||||
address: contact?.address,
|
||||
city: contact?.city,
|
||||
country: contact?.country || 'Saudi Arabia',
|
||||
country: contact?.country || 'Syria',
|
||||
postalCode: contact?.postalCode,
|
||||
source: contact?.source || 'WEBSITE',
|
||||
categories: contact?.categories?.map((c: any) => c.id || c) || [],
|
||||
@@ -41,14 +37,25 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
||||
parentId: contact?.parent?.id,
|
||||
employeeId: contact?.employeeId ?? undefined,
|
||||
customFields: contact?.customFields
|
||||
})
|
||||
})
|
||||
|
||||
export default function ContactForm({ contact, onSubmit, onCancel, submitting = false }: ContactFormProps) {
|
||||
const isEdit = !!contact
|
||||
|
||||
const [formData, setFormData] = useState<CreateContactData>(buildInitialFormData(contact))
|
||||
const [rating, setRating] = useState<number>(contact?.rating || 0)
|
||||
const [newTag, setNewTag] = useState('')
|
||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [employees, setEmployees] = useState<Employee[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setFormData(buildInitialFormData(contact))
|
||||
setRating(contact?.rating || 0)
|
||||
setNewTag('')
|
||||
setFormErrors({})
|
||||
}, [contact])
|
||||
|
||||
useEffect(() => {
|
||||
categoriesAPI.getTree().then(setCategories).catch(() => {})
|
||||
}, [])
|
||||
@@ -72,7 +79,23 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
||||
|
||||
const isCompanyEmployeeSelected = companyEmployeeCategoryId && (formData.categories || []).includes(companyEmployeeCategoryId)
|
||||
|
||||
// Validation
|
||||
const organizationTypes = new Set([
|
||||
'COMPANY',
|
||||
'HOLDING',
|
||||
'GOVERNMENT',
|
||||
'ORGANIZATION',
|
||||
'EMBASSIES',
|
||||
'BANK',
|
||||
'UNIVERSITY',
|
||||
'SCHOOL',
|
||||
'UN',
|
||||
'NGO',
|
||||
'INSTITUTION',
|
||||
])
|
||||
|
||||
const isOrganizationType = organizationTypes.has(formData.type)
|
||||
const showCompanyFields = isOrganizationType
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const errors: Record<string, string> = {}
|
||||
|
||||
@@ -103,28 +126,49 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!validateForm()) return
|
||||
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
|
||||
const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, value]) => {
|
||||
// Keep the value if it's not an empty string, or if it's a required field
|
||||
if (value !== '' || ['type', 'name', 'source', 'country'].includes(key)) {
|
||||
// keep required fields as-is
|
||||
if (requiredFields.includes(key)) {
|
||||
acc[key] = value
|
||||
return acc
|
||||
}
|
||||
|
||||
// in edit mode, allow clearing optional fields by sending null
|
||||
if (isEdit && value === '') {
|
||||
acc[key] = null
|
||||
return acc
|
||||
}
|
||||
|
||||
// in create mode, ignore empty optional fields
|
||||
if (value !== '') {
|
||||
acc[key] = value
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {} as any)
|
||||
|
||||
// Remove parentId if it's empty or undefined
|
||||
if (!cleanData.parentId) {
|
||||
delete cleanData.parentId
|
||||
}
|
||||
|
||||
// Remove categories if empty array
|
||||
if (cleanData.categories && cleanData.categories.length === 0) {
|
||||
delete cleanData.categories
|
||||
}
|
||||
|
||||
// Remove employeeId if empty
|
||||
if (!cleanData.employeeId) {
|
||||
if (!cleanData.parentId) {
|
||||
delete cleanData.parentId
|
||||
}
|
||||
|
||||
if (cleanData.categories && cleanData.categories.length === 0) {
|
||||
delete cleanData.categories
|
||||
}
|
||||
|
||||
// employeeId:
|
||||
// - in create: remove if empty
|
||||
// - in edit: keep null if user cleared it
|
||||
if (!isEdit && !cleanData.employeeId) {
|
||||
delete cleanData.employeeId
|
||||
}
|
||||
|
||||
@@ -152,16 +196,12 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
||||
})
|
||||
}
|
||||
|
||||
const showCompanyFields = ['COMPANY', 'HOLDING', 'GOVERNMENT'].includes(formData.type)
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Basic Information Section */}
|
||||
<form onSubmit={handleSubmit} noValidate className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Basic Information</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Contact Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Contact Type <span className="text-red-500">*</span>
|
||||
@@ -175,11 +215,18 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
||||
<option value="COMPANY">Company - شركة</option>
|
||||
<option value="HOLDING">Holding - مجموعة</option>
|
||||
<option value="GOVERNMENT">Government - حكومي</option>
|
||||
<option value="ORGANIZATION">Organizations - منظمات</option>
|
||||
<option value="EMBASSIES">Embassies - سفارات</option>
|
||||
<option value="BANK">Banks - بنوك</option>
|
||||
<option value="UNIVERSITY">Universities - جامعات</option>
|
||||
<option value="SCHOOL">Schools - مدارس</option>
|
||||
<option value="UN">UN - الأمم المتحدة</option>
|
||||
<option value="NGO">NGO - منظمة غير حكومية</option>
|
||||
<option value="INSTITUTION">Institution - مؤسسة</option>
|
||||
</select>
|
||||
{formErrors.type && <p className="text-red-500 text-xs mt-1">{formErrors.type}</p>}
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Source <span className="text-red-500">*</span>
|
||||
@@ -202,37 +249,20 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Name <span className="text-red-500">*</span>
|
||||
{isOrganizationType ? 'Contact Person Name' : 'Name'} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||
placeholder="Enter contact name"
|
||||
placeholder={isOrganizationType ? 'Enter contact person name' : 'Enter contact name'}
|
||||
/>
|
||||
{formErrors.name && <p className="text-red-500 text-xs mt-1">{formErrors.name}</p>}
|
||||
</div>
|
||||
|
||||
{/* Arabic Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Arabic Name - الاسم بالعربية
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.nameAr || ''}
|
||||
onChange={(e) => setFormData({ ...formData, nameAr: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||
placeholder="أدخل الاسم بالعربية"
|
||||
dir="rtl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rating */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Rating
|
||||
@@ -268,12 +298,10 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Methods Section */}
|
||||
<div className="pt-6 border-t">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Contact Methods</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
@@ -288,7 +316,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
||||
{formErrors.email && <p className="text-red-500 text-xs mt-1">{formErrors.email}</p>}
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Phone
|
||||
@@ -305,7 +332,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Mobile */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Mobile
|
||||
@@ -319,7 +345,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Website */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Website
|
||||
@@ -336,44 +361,27 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Company Information Section (conditional) */}
|
||||
{showCompanyFields && (
|
||||
<div className="pt-6 border-t">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Company Information</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Company Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Company Name
|
||||
{formData.type === 'EMBASSIES' ? 'Embassy Name' : 'Company / Organization Name'}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.companyName || ''}
|
||||
onChange={(e) => setFormData({ ...formData, companyName: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||
placeholder="Company name"
|
||||
placeholder={formData.type === 'EMBASSIES' ? 'Embassy name' : 'Company / organization name'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Company Name Arabic */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Company Name (Arabic) - اسم الشركة
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.companyNameAr || ''}
|
||||
onChange={(e) => setFormData({ ...formData, companyNameAr: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||
placeholder="اسم الشركة بالعربية"
|
||||
dir="rtl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Tax Number */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tax Number
|
||||
@@ -387,7 +395,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Commercial Register */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Commercial Register
|
||||
@@ -405,11 +412,9 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Address Section */}
|
||||
<div className="pt-6 border-t">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Address Information</h3>
|
||||
<div className="space-y-4">
|
||||
{/* Address */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Street Address
|
||||
@@ -424,7 +429,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* City */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
City
|
||||
@@ -438,7 +442,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Country */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Country
|
||||
@@ -452,7 +455,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Postal Code */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Postal Code
|
||||
@@ -469,7 +471,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories Section */}
|
||||
<div className="pt-6 border-t">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Categories</h3>
|
||||
<CategorySelector
|
||||
@@ -479,7 +480,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Employee Link - when Company Employee category is selected */}
|
||||
{isCompanyEmployeeSelected && (
|
||||
<div className="pt-6 border-t">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Link to Employee (Optional)</h3>
|
||||
@@ -501,11 +501,9 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags Section */}
|
||||
<div className="pt-6 border-t">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Tags</h3>
|
||||
<div className="space-y-3">
|
||||
{/* Tag input */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
@@ -524,7 +522,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tags display */}
|
||||
{formData.tags && formData.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.tags.map((tag, index) => (
|
||||
@@ -547,7 +544,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duplicate Detection */}
|
||||
<DuplicateAlert
|
||||
email={formData.email}
|
||||
phone={formData.phone}
|
||||
@@ -556,14 +552,12 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
||||
commercialRegister={formData.commercialRegister}
|
||||
excludeId={contact?.id}
|
||||
onMerge={(contactId) => {
|
||||
// Navigate to merge page with pre-selected contacts
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = `/contacts/merge?sourceId=${contactId}`
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex items-center justify-end gap-3 pt-6 border-t">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
116
frontend/src/components/hr/OrgChart.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
import { Tree, TreeNode } from 'react-organizational-chart'
|
||||
import type { Department } from '@/lib/api/employees'
|
||||
import { Building2, Users } from 'lucide-react'
|
||||
|
||||
// Force LTR for org chart - RTL breaks the tree layout and connecting lines
|
||||
function DeptNode({ dept }: { dept: Department }) {
|
||||
const empCount = dept._count?.employees ?? dept.employees?.length ?? 0
|
||||
const childCount = dept.children?.length ?? 0
|
||||
return (
|
||||
<div className="inline-flex flex-col items-center" dir="ltr">
|
||||
<div className="px-4 py-3 bg-white border-2 border-blue-200 rounded-lg shadow-md hover:shadow-lg transition-shadow min-w-[180px] max-w-[220px]">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Building2 className="h-5 w-5 text-blue-600 flex-shrink-0" />
|
||||
<span className="font-semibold text-gray-900 truncate">{dept.nameAr || dept.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-1">{dept.code}</p>
|
||||
<div className="flex gap-2 text-xs text-gray-600">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{empCount} موظف
|
||||
</span>
|
||||
{childCount > 0 && (
|
||||
<span>• {childCount} أقسام فرعية</span>
|
||||
)}
|
||||
</div>
|
||||
{dept.employees && dept.employees.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-100 space-y-0.5">
|
||||
{dept.employees.slice(0, 3).map((emp: any) => (
|
||||
<p key={emp.id} className="text-xs text-gray-600 truncate">
|
||||
{emp.firstNameAr || emp.firstName} {emp.lastNameAr || emp.lastName}
|
||||
{emp.position && ` - ${emp.position.titleAr || emp.position.title}`}
|
||||
</p>
|
||||
))}
|
||||
{dept.employees.length > 3 && (
|
||||
<p className="text-xs text-gray-500">+{dept.employees.length - 3} أكثر</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function OrgChartTree({ dept }: { dept: Department }) {
|
||||
if (!dept.children?.length) {
|
||||
return <TreeNode label={<DeptNode dept={dept} />} />
|
||||
}
|
||||
return (
|
||||
<TreeNode label={<DeptNode dept={dept} />}>
|
||||
{dept.children.map((child) => (
|
||||
<OrgChartTree key={child.id} dept={child} />
|
||||
))}
|
||||
</TreeNode>
|
||||
)
|
||||
}
|
||||
|
||||
interface OrgChartProps {
|
||||
hierarchy: Department[]
|
||||
}
|
||||
|
||||
export default function OrgChart({ hierarchy }: OrgChartProps) {
|
||||
if (hierarchy.length === 0) {
|
||||
return (
|
||||
<div className="p-12 text-center text-gray-500">
|
||||
<Building2 className="h-16 w-16 mx-auto mb-4 text-gray-300" />
|
||||
<p>لا توجد أقسام. أضف أقساماً من تبويب الأقسام.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (hierarchy.length === 1) {
|
||||
const root = hierarchy[0]
|
||||
return (
|
||||
<div className="p-6 overflow-auto min-h-[400px]" dir="ltr" style={{ direction: 'ltr' }}>
|
||||
<div className="inline-block">
|
||||
<Tree
|
||||
label={<DeptNode dept={root} />}
|
||||
lineWidth="2px"
|
||||
lineColor="#93c5fd"
|
||||
lineBorderRadius="4px"
|
||||
lineHeight="24px"
|
||||
nodePadding="16px"
|
||||
>
|
||||
{root.children?.map((child) => (
|
||||
<OrgChartTree key={child.id} dept={child} />
|
||||
))}
|
||||
</Tree>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="p-6 overflow-auto min-h-[400px]" dir="ltr" style={{ direction: 'ltr' }}>
|
||||
<div className="inline-block">
|
||||
<Tree
|
||||
label={
|
||||
<div className="px-4 py-3 bg-blue-50 border-2 border-blue-200 rounded-lg min-w-[200px] text-center">
|
||||
<p className="font-bold text-gray-900">الشركة</p>
|
||||
<p className="text-xs text-gray-500">الجذر التنظيمي</p>
|
||||
</div>
|
||||
}
|
||||
lineWidth="2px"
|
||||
lineColor="#93c5fd"
|
||||
lineBorderRadius="4px"
|
||||
lineHeight="24px"
|
||||
nodePadding="16px"
|
||||
>
|
||||
{hierarchy.map((dept) => (
|
||||
<OrgChartTree key={dept.id} dept={dept} />
|
||||
))}
|
||||
</Tree>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -99,12 +99,17 @@ const translations = {
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
archived: 'Archived',
|
||||
deleted: 'Deleted'
|
||||
deleted: 'Deleted',
|
||||
all: 'All',
|
||||
view: 'View',
|
||||
showing: 'Showing',
|
||||
of: 'of'
|
||||
},
|
||||
nav: {
|
||||
dashboard: 'Dashboard',
|
||||
contacts: 'Contacts',
|
||||
crm: 'CRM',
|
||||
tenders: 'Tenders',
|
||||
projects: 'Projects',
|
||||
inventory: 'Inventory',
|
||||
hr: 'HR',
|
||||
@@ -274,7 +279,79 @@ const translations = {
|
||||
processing: 'Processing...',
|
||||
deleting: 'Deleting...',
|
||||
deleteDealConfirm: 'Are you sure you want to delete',
|
||||
deleteDealDesc: 'This will mark the deal as lost'
|
||||
deleteDealDesc: 'This will mark the deal as lost',
|
||||
costSheets: 'Cost Sheets',
|
||||
contracts: 'Contracts',
|
||||
invoices: 'Invoices',
|
||||
addCostSheet: 'Add Cost Sheet',
|
||||
addContract: 'Add Contract',
|
||||
addInvoice: 'Add Invoice',
|
||||
approve: 'Approve',
|
||||
reject: 'Reject',
|
||||
markSigned: 'Mark Signed',
|
||||
recordPayment: 'Record Payment',
|
||||
costSheetApproved: 'Cost sheet approved',
|
||||
costSheetRejected: 'Cost sheet rejected',
|
||||
contractSigned: 'Contract signed',
|
||||
paymentRecorded: 'Payment recorded',
|
||||
costSheetCreated: 'Cost sheet created',
|
||||
contractCreated: 'Contract created',
|
||||
invoiceCreated: 'Invoice created',
|
||||
costSheetItems: 'Cost items (description, source, cost, quantity)',
|
||||
invoiceItems: 'Line items (description, quantity, unit price)',
|
||||
description: 'Description',
|
||||
source: 'Source',
|
||||
addRow: 'Add row',
|
||||
totalCost: 'Total Cost',
|
||||
suggestedPrice: 'Suggested Price',
|
||||
profitMargin: 'Profit Margin',
|
||||
contractTitle: 'Contract Title',
|
||||
contractType: 'Contract Type',
|
||||
contractTypeSales: 'Sales',
|
||||
contractTypeService: 'Service',
|
||||
contractTypeMaintenance: 'Maintenance',
|
||||
contractValue: 'Contract Value',
|
||||
startDate: 'Start Date',
|
||||
endDate: 'End Date',
|
||||
paymentTerms: 'Payment Terms',
|
||||
deliveryTerms: 'Delivery Terms',
|
||||
terms: 'Terms & Conditions',
|
||||
subtotal: 'Subtotal',
|
||||
taxAmount: 'Tax Amount',
|
||||
total: 'Total',
|
||||
dueDate: 'Due Date',
|
||||
paidAmount: 'Paid Amount',
|
||||
paidDate: 'Paid Date'
|
||||
},
|
||||
tenders: {
|
||||
title: 'Tenders',
|
||||
subtitle: 'Tender Management',
|
||||
addTender: 'Add Tender',
|
||||
tenderNumber: 'Tender number',
|
||||
issuingBody: 'Issuing body',
|
||||
titleLabel: 'Title',
|
||||
termsValue: 'Terms booklet value',
|
||||
bondValue: 'Bond value',
|
||||
announcementDate: 'Announcement date',
|
||||
closingDate: 'Closing date',
|
||||
announcementLink: 'Announcement link',
|
||||
source: 'Source',
|
||||
announcementType: 'Announcement type',
|
||||
searchPlaceholder: 'Search by number, title, issuing body...',
|
||||
noTenders: 'No tenders found.',
|
||||
loadError: 'Failed to load tenders',
|
||||
createSuccess: 'Tender created successfully',
|
||||
duplicateWarning: 'Possible duplicates found. Please review.',
|
||||
directives: 'Directives',
|
||||
addDirective: 'Add directive',
|
||||
directiveType: 'Directive type',
|
||||
assignee: 'Assignee',
|
||||
convertToDeal: 'Convert to Opportunity',
|
||||
history: 'History',
|
||||
attachments: 'Attachments',
|
||||
uploadFile: 'Upload file',
|
||||
completeTask: 'Complete task',
|
||||
completionNotes: 'Completion notes'
|
||||
},
|
||||
import: {
|
||||
title: 'Import Contacts',
|
||||
@@ -339,6 +416,7 @@ const translations = {
|
||||
dashboard: 'لوحة التحكم',
|
||||
contacts: 'جهات الاتصال',
|
||||
crm: 'إدارة العملاء',
|
||||
tenders: 'المناقصات',
|
||||
projects: 'المشاريع',
|
||||
inventory: 'المخزون',
|
||||
hr: 'الموارد البشرية',
|
||||
@@ -346,6 +424,36 @@ const translations = {
|
||||
settings: 'الإعدادات',
|
||||
logout: 'تسجيل الخروج'
|
||||
},
|
||||
tenders: {
|
||||
title: 'المناقصات',
|
||||
subtitle: 'نظام إدارة المناقصات',
|
||||
addTender: 'إضافة مناقصة',
|
||||
tenderNumber: 'رقم المناقصة',
|
||||
issuingBody: 'الجهة الطارحة',
|
||||
titleLabel: 'عنوان المناقصة',
|
||||
termsValue: 'قيمة دفتر الشروط',
|
||||
bondValue: 'قيمة التأمينات',
|
||||
announcementDate: 'تاريخ الإعلان',
|
||||
closingDate: 'تاريخ الإغلاق',
|
||||
announcementLink: 'رابط الإعلان',
|
||||
source: 'مصدر المناقصة',
|
||||
announcementType: 'نوع الإعلان',
|
||||
searchPlaceholder: 'البحث بالرقم أو العنوان أو الجهة الطارحة...',
|
||||
noTenders: 'لم يتم العثور على مناقصات.',
|
||||
loadError: 'فشل تحميل المناقصات',
|
||||
createSuccess: 'تم إنشاء المناقصة بنجاح',
|
||||
duplicateWarning: 'يوجد مناقصات مشابهة. يرجى المراجعة.',
|
||||
directives: 'التوجيهات',
|
||||
addDirective: 'إضافة توجيه',
|
||||
directiveType: 'نوع التوجيه',
|
||||
assignee: 'الموظف المسؤول',
|
||||
convertToDeal: 'تحويل إلى فرصة',
|
||||
history: 'السجل',
|
||||
attachments: 'المرفقات',
|
||||
uploadFile: 'رفع ملف',
|
||||
completeTask: 'إتمام المهمة',
|
||||
completionNotes: 'ملاحظات الإنجاز'
|
||||
},
|
||||
contacts: {
|
||||
title: 'جهات الاتصال',
|
||||
addContact: 'إضافة جهة اتصال',
|
||||
@@ -508,7 +616,49 @@ const translations = {
|
||||
processing: 'جاري المعالجة...',
|
||||
deleting: 'جاري الحذف...',
|
||||
deleteDealConfirm: 'هل أنت متأكد من حذف',
|
||||
deleteDealDesc: 'سيتم تحديد الصفقة كخاسرة'
|
||||
deleteDealDesc: 'سيتم تحديد الصفقة كخاسرة',
|
||||
costSheets: 'كشوفات التكلفة',
|
||||
contracts: 'العقود',
|
||||
invoices: 'الفواتير',
|
||||
addCostSheet: 'إضافة كشف تكلفة',
|
||||
addContract: 'إضافة عقد',
|
||||
addInvoice: 'إضافة فاتورة',
|
||||
approve: 'موافقة',
|
||||
reject: 'رفض',
|
||||
markSigned: 'توقيع',
|
||||
recordPayment: 'تسجيل الدفع',
|
||||
costSheetApproved: 'تمت الموافقة على كشف التكلفة',
|
||||
costSheetRejected: 'تم رفض كشف التكلفة',
|
||||
contractSigned: 'تم توقيع العقد',
|
||||
paymentRecorded: 'تم تسجيل الدفع',
|
||||
costSheetCreated: 'تم إنشاء كشف التكلفة',
|
||||
contractCreated: 'تم إنشاء العقد',
|
||||
invoiceCreated: 'تم إنشاء الفاتورة',
|
||||
costSheetItems: 'بنود التكلفة (الوصف، المصدر، التكلفة، الكمية)',
|
||||
invoiceItems: 'بنود الفاتورة (الوصف، الكمية، سعر الوحدة)',
|
||||
description: 'الوصف',
|
||||
source: 'المصدر',
|
||||
addRow: 'إضافة صف',
|
||||
totalCost: 'إجمالي التكلفة',
|
||||
suggestedPrice: 'السعر المقترح',
|
||||
profitMargin: 'هامش الربح',
|
||||
contractTitle: 'عنوان العقد',
|
||||
contractType: 'نوع العقد',
|
||||
contractTypeSales: 'مبيعات',
|
||||
contractTypeService: 'خدمة',
|
||||
contractTypeMaintenance: 'صيانة',
|
||||
contractValue: 'قيمة العقد',
|
||||
startDate: 'تاريخ البداية',
|
||||
endDate: 'تاريخ النهاية',
|
||||
paymentTerms: 'شروط الدفع',
|
||||
deliveryTerms: 'شروط التسليم',
|
||||
terms: 'الشروط والأحكام',
|
||||
subtotal: 'المجموع الفرعي',
|
||||
taxAmount: 'ضريبة',
|
||||
total: 'الإجمالي',
|
||||
dueDate: 'تاريخ الاستحقاق',
|
||||
paidAmount: 'المبلغ المدفوع',
|
||||
paidDate: 'تاريخ الدفع'
|
||||
},
|
||||
import: {
|
||||
title: 'استيراد جهات الاتصال',
|
||||
|
||||
@@ -77,6 +77,10 @@ export const contactsAPI = {
|
||||
api.post('/contacts/merge', { sourceId, targetId, reason }),
|
||||
}
|
||||
|
||||
export const dashboardAPI = {
|
||||
getStats: () => api.get<{ success: boolean; data: { contacts: number; activeTasks: number; notifications: number } }>('/dashboard/stats'),
|
||||
}
|
||||
|
||||
export const crmAPI = {
|
||||
// Deals
|
||||
getDeals: (params?: any) => api.get('/crm/deals', { params }),
|
||||
|
||||
@@ -113,7 +113,7 @@ export const statsAPI = {
|
||||
},
|
||||
};
|
||||
|
||||
// Positions (Roles) API
|
||||
// Positions (Roles) API - maps to HR positions with permissions
|
||||
export interface PositionPermission {
|
||||
id: string;
|
||||
module: string;
|
||||
@@ -126,25 +126,19 @@ export interface PositionRole {
|
||||
title: string;
|
||||
titleAr?: string | null;
|
||||
code: string;
|
||||
level: number;
|
||||
departmentId: string;
|
||||
department?: { id?: string; name: string; nameAr?: string | null };
|
||||
department?: { name: string; nameAr?: string | null };
|
||||
permissions: PositionPermission[];
|
||||
usersCount: number;
|
||||
_count?: { employees: number };
|
||||
}
|
||||
|
||||
export interface CreatePositionPayload {
|
||||
export interface CreatePositionData {
|
||||
title: string;
|
||||
titleAr?: string | null;
|
||||
titleAr?: string;
|
||||
code: string;
|
||||
departmentId: string;
|
||||
level?: number;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePositionPayload {
|
||||
title?: string;
|
||||
titleAr?: string | null;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const positionsAPI = {
|
||||
@@ -153,20 +147,19 @@ export const positionsAPI = {
|
||||
return response.data.data || [];
|
||||
},
|
||||
|
||||
create: async (payload: CreatePositionPayload): Promise<PositionRole> => {
|
||||
const response = await api.post('/admin/positions', payload);
|
||||
create: async (data: CreatePositionData): Promise<PositionRole> => {
|
||||
const response = await api.post('/admin/positions', data);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
update: async (positionId: string, payload: UpdatePositionPayload): Promise<PositionRole> => {
|
||||
const response = await api.put(`/admin/positions/${positionId}`, payload);
|
||||
update: async (
|
||||
id: string,
|
||||
data: Partial<CreatePositionData & { isActive?: boolean }>
|
||||
): Promise<PositionRole> => {
|
||||
const response = await api.put(`/admin/positions/${id}`, data);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
delete: async (positionId: string): Promise<void> => {
|
||||
await api.delete(`/admin/positions/${positionId}`);
|
||||
},
|
||||
|
||||
updatePermissions: async (
|
||||
positionId: string,
|
||||
permissions: Array<{ module: string; resource: string; actions: string[] }>
|
||||
@@ -178,7 +171,7 @@ export const positionsAPI = {
|
||||
},
|
||||
};
|
||||
|
||||
// Roles API - alias for positions
|
||||
// Roles API - alias for positions (for compatibility with existing frontend)
|
||||
export interface Role {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -218,6 +211,53 @@ export const rolesAPI = {
|
||||
},
|
||||
};
|
||||
|
||||
// Permission Groups API (Phase 3 - multi-group)
|
||||
export interface PermissionGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
nameAr?: string | null;
|
||||
description?: string | null;
|
||||
isActive: boolean;
|
||||
permissions: { id: string; module: string; resource: string; actions: string[] }[];
|
||||
_count?: { userRoles: number };
|
||||
}
|
||||
|
||||
export const permissionGroupsAPI = {
|
||||
getAll: async (): Promise<PermissionGroup[]> => {
|
||||
const response = await api.get('/admin/permission-groups');
|
||||
return response.data.data || [];
|
||||
},
|
||||
create: async (data: { name: string; nameAr?: string; description?: string }) => {
|
||||
const response = await api.post('/admin/permission-groups', data);
|
||||
return response.data.data;
|
||||
},
|
||||
update: async (id: string, data: Partial<{ name: string; nameAr: string; description: string; isActive: boolean }>) => {
|
||||
const response = await api.put(`/admin/permission-groups/${id}`, data);
|
||||
return response.data.data;
|
||||
},
|
||||
updatePermissions: async (
|
||||
id: string,
|
||||
permissions: Array<{ module: string; resource: string; actions: string[] }>
|
||||
) => {
|
||||
const response = await api.put(`/admin/permission-groups/${id}/permissions`, { permissions });
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const userRolesAPI = {
|
||||
getAll: async (userId: string) => {
|
||||
const response = await api.get(`/admin/users/${userId}/roles`);
|
||||
return response.data.data || [];
|
||||
},
|
||||
assign: async (userId: string, roleId: string) => {
|
||||
const response = await api.post(`/admin/users/${userId}/roles`, { roleId });
|
||||
return response.data.data;
|
||||
},
|
||||
remove: async (userId: string, roleId: string) => {
|
||||
await api.delete(`/admin/users/${userId}/roles/${roleId}`);
|
||||
},
|
||||
};
|
||||
|
||||
// Audit Logs API
|
||||
export interface AuditLog {
|
||||
id: string;
|
||||
@@ -260,7 +300,7 @@ export const auditLogsAPI = {
|
||||
},
|
||||
};
|
||||
|
||||
// System Settings API (placeholder)
|
||||
// System Settings API (placeholder - out of scope)
|
||||
export interface SystemSetting {
|
||||
key: string;
|
||||
value: unknown;
|
||||
@@ -279,7 +319,7 @@ export const settingsAPI = {
|
||||
},
|
||||
};
|
||||
|
||||
// System Health API (placeholder)
|
||||
// System Health API (placeholder - optional)
|
||||
export interface SystemHealth {
|
||||
status: string;
|
||||
database: string;
|
||||
|
||||
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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,149 +1,5 @@
|
||||
import { api } from '../api'
|
||||
|
||||
const toNumber = (v: any): number => {
|
||||
if (v === null || v === undefined || v === '') return 0
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : 0
|
||||
const s = typeof v === 'string' ? v : (v?.toString?.() ?? String(v))
|
||||
const cleaned = s.replace(/[^0-9.-]/g, '')
|
||||
const n = Number(cleaned)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
const cleanStr = (v: any) => {
|
||||
if (v === null || v === undefined) return undefined
|
||||
const s = String(v).trim()
|
||||
return s === '' ? undefined : s
|
||||
}
|
||||
|
||||
const cleanEmail = (v: any) => {
|
||||
const s = cleanStr(v)
|
||||
if (!s) return undefined
|
||||
return s.replace(/\s+/g, '').replace(/\++$/g, '')
|
||||
}
|
||||
|
||||
const parseDateToISO = (v: any) => {
|
||||
const s = cleanStr(v)
|
||||
if (!s) return undefined
|
||||
|
||||
// DD/MM/YYYY
|
||||
const m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/)
|
||||
if (m) {
|
||||
const dd = m[1]
|
||||
const mm = m[2]
|
||||
const yyyy = m[3]
|
||||
const d = new Date(`${yyyy}-${mm}-${dd}T00:00:00.000Z`)
|
||||
return isNaN(d.getTime()) ? undefined : d.toISOString()
|
||||
}
|
||||
|
||||
const d = new Date(s)
|
||||
return isNaN(d.getTime()) ? undefined : d.toISOString()
|
||||
}
|
||||
|
||||
const normalizeEmployeeFromApi = (e: any) => {
|
||||
const basic = toNumber(e?.basicSalary ?? e?.baseSalary ?? e?.salary)
|
||||
return {
|
||||
...e,
|
||||
baseSalary: basic,
|
||||
salary: basic,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const normalizeEmployeeToApi = (data: any) => {
|
||||
const d: any = { ...(data || {}) }
|
||||
|
||||
if (d.department?.id && !d.departmentId) d.departmentId = d.department.id
|
||||
if (d.position?.id && !d.positionId) d.positionId = d.position.id
|
||||
if (d.reportingTo?.id && !d.reportingToId) d.reportingToId = d.reportingTo.id
|
||||
|
||||
if (d.baseSalary !== undefined) {
|
||||
d.basicSalary = toNumber(d.baseSalary)
|
||||
} else if (d.salary !== undefined) {
|
||||
d.basicSalary = toNumber(d.salary)
|
||||
}
|
||||
|
||||
d.firstName = cleanStr(d.firstName)
|
||||
d.lastName = cleanStr(d.lastName)
|
||||
d.firstNameAr = cleanStr(d.firstNameAr)
|
||||
d.lastNameAr = cleanStr(d.lastNameAr)
|
||||
|
||||
d.email = cleanEmail(d.email)
|
||||
d.phone = cleanStr(d.phone)
|
||||
d.mobile = cleanStr(d.mobile)
|
||||
|
||||
d.gender = cleanStr(d.gender)
|
||||
d.nationality = cleanStr(d.nationality)
|
||||
d.nationalId = cleanStr(d.nationalId)
|
||||
d.passportNumber = cleanStr(d.passportNumber)
|
||||
|
||||
d.employmentType = cleanStr(d.employmentType)
|
||||
d.contractType = cleanStr(d.contractType)
|
||||
|
||||
d.departmentId = cleanStr(d.departmentId)
|
||||
d.positionId = cleanStr(d.positionId)
|
||||
d.reportingToId = cleanStr(d.reportingToId)
|
||||
|
||||
d.currency = cleanStr(d.currency) ?? 'SAR'
|
||||
d.status = cleanStr(d.status)
|
||||
|
||||
d.address = cleanStr(d.address)
|
||||
d.city = cleanStr(d.city)
|
||||
d.country = cleanStr(d.country)
|
||||
|
||||
d.terminationReason = cleanStr(d.terminationReason)
|
||||
d.emergencyContactName = cleanStr(d.emergencyContactName)
|
||||
d.emergencyContactPhone = cleanStr(d.emergencyContactPhone)
|
||||
d.emergencyContactRelation = cleanStr(d.emergencyContactRelation)
|
||||
|
||||
d.hireDate = parseDateToISO(d.hireDate)
|
||||
d.dateOfBirth = parseDateToISO(d.dateOfBirth)
|
||||
d.endDate = parseDateToISO(d.endDate)
|
||||
d.probationEndDate = parseDateToISO(d.probationEndDate)
|
||||
d.terminationDate = parseDateToISO(d.terminationDate)
|
||||
|
||||
const allowed = [
|
||||
'firstName',
|
||||
'lastName',
|
||||
'firstNameAr',
|
||||
'lastNameAr',
|
||||
'email',
|
||||
'phone',
|
||||
'mobile',
|
||||
'dateOfBirth',
|
||||
'gender',
|
||||
'nationality',
|
||||
'nationalId',
|
||||
'passportNumber',
|
||||
'employmentType',
|
||||
'contractType',
|
||||
'hireDate',
|
||||
'endDate',
|
||||
'probationEndDate',
|
||||
'departmentId',
|
||||
'positionId',
|
||||
'reportingToId',
|
||||
'basicSalary',
|
||||
'currency',
|
||||
'status',
|
||||
'terminationDate',
|
||||
'terminationReason',
|
||||
'emergencyContactName',
|
||||
'emergencyContactPhone',
|
||||
'emergencyContactRelation',
|
||||
'address',
|
||||
'city',
|
||||
'country',
|
||||
'documents',
|
||||
]
|
||||
|
||||
const payload: any = {}
|
||||
for (const k of allowed) {
|
||||
if (d[k] !== undefined) payload[k] = d[k]
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
export interface Employee {
|
||||
id: string
|
||||
uniqueEmployeeId: string
|
||||
@@ -169,11 +25,7 @@ export interface Employee {
|
||||
position?: any
|
||||
reportingToId?: string
|
||||
reportingTo?: any
|
||||
|
||||
baseSalary: number
|
||||
basicSalary?: any
|
||||
|
||||
currency?: string
|
||||
status: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
@@ -197,7 +49,6 @@ export interface CreateEmployeeData {
|
||||
departmentId: string
|
||||
positionId: string
|
||||
reportingToId?: string
|
||||
|
||||
baseSalary: number
|
||||
}
|
||||
|
||||
@@ -221,6 +72,7 @@ export interface EmployeesResponse {
|
||||
}
|
||||
|
||||
export const employeesAPI = {
|
||||
// Get all employees with filters and pagination
|
||||
getAll: async (filters: EmployeeFilters = {}): Promise<EmployeesResponse> => {
|
||||
const params = new URLSearchParams()
|
||||
if (filters.search) params.append('search', filters.search)
|
||||
@@ -232,11 +84,12 @@ export const employeesAPI = {
|
||||
|
||||
const response = await api.get(`/hr/employees?${params.toString()}`)
|
||||
const { data, pagination } = response.data
|
||||
|
||||
const normalized = Array.isArray(data) ? data.map(normalizeEmployeeFromApi) : []
|
||||
|
||||
const employees = (data || []).map((e: any) => ({
|
||||
...e,
|
||||
baseSalary: e.baseSalary ?? e.basicSalary ?? 0,
|
||||
}))
|
||||
return {
|
||||
employees: normalized,
|
||||
employees,
|
||||
total: pagination?.total || 0,
|
||||
page: pagination?.page || 1,
|
||||
pageSize: pagination?.pageSize || 20,
|
||||
@@ -244,35 +97,70 @@ export const employeesAPI = {
|
||||
}
|
||||
},
|
||||
|
||||
// Get single employee by ID
|
||||
getById: async (id: string): Promise<Employee> => {
|
||||
const response = await api.get(`/hr/employees/${id}`)
|
||||
return normalizeEmployeeFromApi(response.data.data)
|
||||
const e = response.data.data
|
||||
return e ? { ...e, baseSalary: e.baseSalary ?? e.basicSalary ?? 0 } : e
|
||||
},
|
||||
|
||||
// Create new employee
|
||||
create: async (data: CreateEmployeeData): Promise<Employee> => {
|
||||
const payload = normalizeEmployeeToApi(data)
|
||||
const response = await api.post('/hr/employees', payload)
|
||||
return normalizeEmployeeFromApi(response.data.data)
|
||||
const response = await api.post('/hr/employees', data)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
// Update existing employee
|
||||
update: async (id: string, data: UpdateEmployeeData): Promise<Employee> => {
|
||||
const payload = normalizeEmployeeToApi(data)
|
||||
const response = await api.put(`/hr/employees/${id}`, payload)
|
||||
return normalizeEmployeeFromApi(response.data.data)
|
||||
const response = await api.put(`/hr/employees/${id}`, data)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
// Delete employee
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`/hr/employees/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Departments API
|
||||
export interface Department {
|
||||
id: string
|
||||
name: string
|
||||
nameAr?: string | null
|
||||
code: string
|
||||
parentId?: string | null
|
||||
parent?: { id: string; name: string; nameAr?: string | null }
|
||||
description?: string | null
|
||||
isActive?: boolean
|
||||
children?: Department[]
|
||||
employees?: any[]
|
||||
positions?: any[]
|
||||
_count?: { children: number; employees: number }
|
||||
}
|
||||
|
||||
export const departmentsAPI = {
|
||||
getAll: async (): Promise<any[]> => {
|
||||
const response = await api.get('/hr/departments')
|
||||
return response.data.data
|
||||
},
|
||||
getHierarchy: async (): Promise<Department[]> => {
|
||||
const response = await api.get('/hr/departments/hierarchy')
|
||||
return response.data.data
|
||||
},
|
||||
create: async (data: { name: string; nameAr?: string; code: string; parentId?: string; description?: string }) => {
|
||||
const response = await api.post('/hr/departments', data)
|
||||
return response.data.data
|
||||
},
|
||||
update: async (id: string, data: Partial<{ name: string; nameAr: string; code: string; parentId: string | null; description: string; isActive: boolean }>) => {
|
||||
const response = await api.put(`/hr/departments/${id}`, data)
|
||||
return response.data.data
|
||||
},
|
||||
delete: async (id: string) => {
|
||||
await api.delete(`/hr/departments/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Positions API
|
||||
export const positionsAPI = {
|
||||
getAll: async (): Promise<any[]> => {
|
||||
const response = await api.get('/hr/positions')
|
||||
|
||||
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
@@ -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
@@ -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
@@ -0,0 +1,208 @@
|
||||
import { api } from '../api'
|
||||
|
||||
export interface Tender {
|
||||
id: string
|
||||
tenderNumber: string
|
||||
issuingBodyName: string
|
||||
title: string
|
||||
|
||||
termsValue: number
|
||||
bondValue: number
|
||||
|
||||
// extra fields stored inside notes metadata for now
|
||||
initialBondValue?: number | null
|
||||
finalBondValue?: number | null
|
||||
finalBondRefundPeriod?: string | null
|
||||
siteVisitRequired?: boolean
|
||||
siteVisitLocation?: string | null
|
||||
termsPickupProvince?: string | null
|
||||
|
||||
announcementDate: string
|
||||
closingDate: string
|
||||
announcementLink?: string
|
||||
source: string
|
||||
sourceOther?: string
|
||||
announcementType: string
|
||||
notes?: string
|
||||
status: string
|
||||
contactId?: string
|
||||
contact?: any
|
||||
createdById: string
|
||||
createdBy?: any
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
directives?: TenderDirective[]
|
||||
attachments?: any[]
|
||||
_count?: { directives: number }
|
||||
}
|
||||
|
||||
|
||||
export interface TenderDirective {
|
||||
id: string
|
||||
tenderId: string
|
||||
type: string
|
||||
notes?: string
|
||||
assignedToEmployeeId: string
|
||||
assignedToEmployee?: any
|
||||
issuedById: string
|
||||
issuedBy?: any
|
||||
status: string
|
||||
completedAt?: string
|
||||
completionNotes?: string
|
||||
completedById?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
attachments?: any[]
|
||||
}
|
||||
|
||||
export interface CreateTenderData {
|
||||
tenderNumber: string
|
||||
issuingBodyName: string
|
||||
title: string
|
||||
|
||||
termsValue: number
|
||||
bondValue: number
|
||||
|
||||
// extra UI/backend fields without DB migration
|
||||
initialBondValue?: number
|
||||
finalBondValue?: number
|
||||
finalBondRefundPeriod?: string
|
||||
siteVisitRequired?: boolean
|
||||
siteVisitLocation?: string
|
||||
termsPickupProvince?: string
|
||||
|
||||
announcementDate: string
|
||||
closingDate: string
|
||||
announcementLink?: string
|
||||
source: string
|
||||
sourceOther?: string
|
||||
announcementType: string
|
||||
notes?: string
|
||||
contactId?: string
|
||||
}
|
||||
|
||||
export interface CreateDirectiveData {
|
||||
type: string
|
||||
assignedToEmployeeId: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface TenderFilters {
|
||||
search?: string
|
||||
status?: string
|
||||
source?: string
|
||||
announcementType?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface TendersResponse {
|
||||
data: Tender[]
|
||||
pagination: {
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
}
|
||||
|
||||
export const tendersAPI = {
|
||||
getAll: async (filters: TenderFilters = {}): Promise<{ tenders: Tender[]; total: number; page: number; pageSize: number; totalPages: number }> => {
|
||||
const params = new URLSearchParams()
|
||||
if (filters.search) params.append('search', filters.search)
|
||||
if (filters.status) params.append('status', filters.status)
|
||||
if (filters.source) params.append('source', filters.source)
|
||||
if (filters.announcementType) params.append('announcementType', filters.announcementType)
|
||||
if (filters.page) params.append('page', filters.page.toString())
|
||||
if (filters.pageSize) params.append('pageSize', filters.pageSize.toString())
|
||||
|
||||
const response = await api.get(`/tenders?${params.toString()}`)
|
||||
const { data, pagination } = response.data
|
||||
return {
|
||||
tenders: data || [],
|
||||
total: pagination?.total ?? 0,
|
||||
page: pagination?.page ?? 1,
|
||||
pageSize: pagination?.pageSize ?? 20,
|
||||
totalPages: pagination?.totalPages ?? 0,
|
||||
}
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Tender> => {
|
||||
const response = await api.get(`/tenders/${id}`)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
create: async (data: CreateTenderData): Promise<{ tender: Tender; possibleDuplicates?: Tender[] }> => {
|
||||
const response = await api.post('/tenders', data)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
update: async (id: string, data: Partial<CreateTenderData>): Promise<Tender> => {
|
||||
const response = await api.put(`/tenders/${id}`, data)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
checkDuplicates: async (data: Partial<CreateTenderData>): Promise<Tender[]> => {
|
||||
const response = await api.post('/tenders/check-duplicates', data)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
getHistory: async (id: string): Promise<any[]> => {
|
||||
const response = await api.get(`/tenders/${id}/history`)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
createDirective: async (tenderId: string, data: CreateDirectiveData): Promise<TenderDirective> => {
|
||||
const response = await api.post(`/tenders/${tenderId}/directives`, data)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
updateDirective: async (directiveId: string, data: { status?: string; completionNotes?: string }): Promise<TenderDirective> => {
|
||||
const response = await api.put(`/tenders/directives/${directiveId}`, data)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
convertToDeal: async (tenderId: string, data: { contactId: string; pipelineId: string; ownerId?: string }): Promise<any> => {
|
||||
const response = await api.post(`/tenders/${tenderId}/convert-to-deal`, data)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
uploadTenderAttachment: async (tenderId: string, file: File, category?: string): Promise<any> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (category) formData.append('category', category)
|
||||
const response = await api.post(`/tenders/${tenderId}/attachments`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
uploadDirectiveAttachment: async (directiveId: string, file: File, category?: string): Promise<any> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (category) formData.append('category', category)
|
||||
const response = await api.post(`/tenders/directives/${directiveId}/attachments`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
deleteAttachment: async (attachmentId: string): Promise<void> => {
|
||||
await api.delete(`/tenders/attachments/${attachmentId}`)
|
||||
},
|
||||
|
||||
getSourceValues: async (): Promise<string[]> => {
|
||||
const response = await api.get('/tenders/source-values')
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
getAnnouncementTypeValues: async (): Promise<string[]> => {
|
||||
const response = await api.get('/tenders/announcement-type-values')
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
getDirectiveTypeValues: async (): Promise<string[]> => {
|
||||
const response = await api.get('/tenders/directive-type-values')
|
||||
return response.data.data
|
||||
},
|
||||
}
|
||||
68
package-lock.json
generated
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "mind14-crm",
|
||||
"name": "z-crm",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mind14-crm",
|
||||
"name": "z-crm",
|
||||
"version": "1.0.0",
|
||||
"license": "PROPRIETARY",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"concurrently": "^8.2.2"
|
||||
}
|
||||
},
|
||||
@@ -22,6 +23,22 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
@@ -175,6 +192,21 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
@@ -212,6 +244,38 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
|
||||