Compare commits
15 Commits
005edf2b69
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| bda70feb18 | |||
| 13f2214df5 | |||
| 04cc054be1 | |||
| 8954e9c1dd | |||
|
|
7b73053d80 | ||
|
|
f101989047 | ||
|
|
03312c3769 | ||
|
|
1014d88313 | ||
|
|
278d8f6982 | ||
|
|
94d651c29e | ||
|
|
3e8985ffe0 | ||
|
|
3fbe607ed7 | ||
|
|
14d2597722 | ||
|
|
45c43ab526 | ||
|
|
78aa7c0fb5 |
174
DEVELOPER_STAGING_DEPLOY.md
Normal file
174
DEVELOPER_STAGING_DEPLOY.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# Staging deploy guide (for developers)
|
||||||
|
|
||||||
|
This is the **same workflow** the team uses for staging: work locally, push to Git, **sync your project folder to the server with rsync**, then **rebuild Docker on the server**. Pushing to Git alone does not update the live staging site.
|
||||||
|
|
||||||
|
**Staging URL:** https://zerp.atmata-group.com/
|
||||||
|
|
||||||
|
**Server project path:** `/root/z_crm` (confirm with your lead if different)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What you need from your lead
|
||||||
|
|
||||||
|
| Item | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| **Staging server address** (IP or hostname) | For SSH and rsync |
|
||||||
|
| **SSH login** (e.g. `root` or another user) | Remote shell and file sync |
|
||||||
|
| **SSH key or password** | Prefer **SSH keys** (`ssh-copy-id`) so you are not typing a password on every deploy |
|
||||||
|
|
||||||
|
You do **not** need Node.js **on the server**. Docker runs `npm ci` and `npm run build` inside the containers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. One-time setup on your laptop
|
||||||
|
|
||||||
|
1. **Clone** the same Git repo you commit to, and use that folder as your working copy.
|
||||||
|
2. **Test SSH:**
|
||||||
|
```bash
|
||||||
|
ssh YOUR_USER@YOUR_SERVER_HOST
|
||||||
|
```
|
||||||
|
Exit with `exit` once you see a shell.
|
||||||
|
3. **Install rsync** if needed (macOS and most Linux distros include it).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Standard deploy (every time you want staging updated)
|
||||||
|
|
||||||
|
Run these from your **project root** (the folder that contains `frontend/`, `backend/`, and `docker-compose.yml`).
|
||||||
|
|
||||||
|
### Step 1 — Get the code you want on Git
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull origin master
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolve any conflicts, then make sure your changes are **committed and pushed**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin master
|
||||||
|
```
|
||||||
|
|
||||||
|
(Use your team’s branch name if staging tracks something other than `master`.)
|
||||||
|
|
||||||
|
### Step 2 — Build locally (recommended)
|
||||||
|
|
||||||
|
Catches errors before you touch the server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run build && cd ../backend && npm run build && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
If either build fails, fix the problem and repeat before continuing.
|
||||||
|
|
||||||
|
### Step 3 — Sync files to the server (rsync)
|
||||||
|
|
||||||
|
From the **project root**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rsync -avz --delete \
|
||||||
|
--exclude 'node_modules' \
|
||||||
|
--exclude '.git' \
|
||||||
|
--exclude '.next' \
|
||||||
|
--exclude '.env' \
|
||||||
|
./ YOUR_USER@YOUR_SERVER_HOST:/root/z_crm/
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`./`** — trailing slash means “contents of this folder”; destination is `/root/z_crm/`.
|
||||||
|
- **`--delete`** — removes files on the server that no longer exist in your tree (keeps server tree aligned with yours). **Excluded paths are not deleted** from the server by default when you use `--exclude` (rsync does not remove excluded remote files unless you add `--delete-excluded`, which you should **not** use here).
|
||||||
|
- **`.env` must stay excluded** so you do **not** overwrite the server’s database URL and secrets. The server keeps its existing `/root/z_crm/.env`.
|
||||||
|
|
||||||
|
Replace `YOUR_USER` and `YOUR_SERVER_HOST` with what your lead gave you.
|
||||||
|
|
||||||
|
### Step 4 — Rebuild Docker on the server
|
||||||
|
|
||||||
|
Either open an SSH session and run the commands, or run them in one shot:
|
||||||
|
|
||||||
|
**Option A — SSH in, then commands**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh YOUR_USER@YOUR_SERVER_HOST
|
||||||
|
cd /root/z_crm
|
||||||
|
docker-compose down
|
||||||
|
docker-compose build --no-cache frontend backend
|
||||||
|
docker-compose up -d
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B — One line from your laptop**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh YOUR_USER@YOUR_SERVER_HOST 'cd /root/z_crm && docker-compose down && docker-compose build --no-cache frontend backend && docker-compose up -d && docker-compose ps'
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see `zerp_postgres` (healthy), `zerp_backend`, and `zerp_frontend` up.
|
||||||
|
|
||||||
|
### Step 5 — Quick check
|
||||||
|
|
||||||
|
Open https://zerp.atmata-group.com/ and do a hard refresh if the UI looks cached (`Cmd+Shift+R` / `Ctrl+Shift+R`).
|
||||||
|
|
||||||
|
If the API or login fails:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh YOUR_USER@YOUR_SERVER_HOST 'cd /root/z_crm && docker-compose logs backend --tail 80'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. If the backend will not start (database / Prisma)
|
||||||
|
|
||||||
|
The server relies on **`/root/z_crm/.env`** (not in Git) for `DATABASE_URL` and related settings. If that file is missing or wrong, ask your lead to fix or restore it **once**; your normal deploy should **never** sync your local `.env` over it (keep the `--exclude '.env'` on rsync).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Database migrations
|
||||||
|
|
||||||
|
The backend container runs **`npx prisma migrate deploy`** when it starts. After you deploy code that includes new Prisma migrations, a normal **`docker-compose up -d`** after rebuild applies them automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Optional: rebuild all services (slower)
|
||||||
|
|
||||||
|
Usually you only need **frontend** and **backend** images. To rebuild everything defined in `docker-compose.yml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh YOUR_USER@YOUR_SERVER_HOST 'cd /root/z_crm && docker-compose down && docker-compose build --no-cache && docker-compose up -d'
|
||||||
|
```
|
||||||
|
|
||||||
|
Postgres **data** stays on the Docker volume unless someone removes volumes on purpose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Alternative: Git pull **on** the server (only if `.git` exists there)
|
||||||
|
|
||||||
|
Some teams keep a full clone on the server. If `/root/z_crm/.git` exists **and** the server can reach your Git remote, you can skip rsync and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh YOUR_USER@YOUR_SERVER_HOST
|
||||||
|
cd /root/z_crm
|
||||||
|
git pull origin master
|
||||||
|
docker-compose down
|
||||||
|
docker-compose build --no-cache frontend backend
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
If there is **no** `.git` on the server (typical when deploys have always been rsync), use **section 3** only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Copy-paste cheat sheet (replace user/host)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From project root, after git push:
|
||||||
|
cd frontend && npm run build && cd ../backend && npm run build && cd ..
|
||||||
|
|
||||||
|
rsync -avz --delete \
|
||||||
|
--exclude 'node_modules' \
|
||||||
|
--exclude '.git' \
|
||||||
|
--exclude '.next' \
|
||||||
|
--exclude '.env' \
|
||||||
|
./ YOUR_USER@YOUR_SERVER_HOST:/root/z_crm/
|
||||||
|
|
||||||
|
ssh YOUR_USER@YOUR_SERVER_HOST 'cd /root/z_crm && docker-compose down && docker-compose build --no-cache frontend backend && docker-compose up -d && docker-compose ps'
|
||||||
|
```
|
||||||
|
|
||||||
|
This matches the workflow: **local code → Git → rsync to server → Docker rebuild**.
|
||||||
1
backend/package-lock.json
generated
1
backend/package-lock.json
generated
@@ -2909,7 +2909,6 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ router.post(
|
|||||||
'/',
|
'/',
|
||||||
authorize('contacts', 'contacts', 'create'),
|
authorize('contacts', 'contacts', 'create'),
|
||||||
[
|
[
|
||||||
body('type').isIn(['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT']),
|
body('type').isIn(['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT','ORGANIZATION','EMBASSIES',
|
||||||
|
'BANK','UNIVERSITY','SCHOOL','UN','NGO','INSTITUTION',]),
|
||||||
body('name').notEmpty().trim(),
|
body('name').notEmpty().trim(),
|
||||||
body('email').optional().isEmail(),
|
body('email').optional().isEmail(),
|
||||||
body('source').notEmpty(),
|
body('source').notEmpty(),
|
||||||
@@ -57,7 +58,29 @@ router.put(
|
|||||||
authorize('contacts', 'contacts', 'update'),
|
authorize('contacts', 'contacts', 'update'),
|
||||||
[
|
[
|
||||||
param('id').isUUID(),
|
param('id').isUUID(),
|
||||||
body('email').optional().isEmail(),
|
body('type')
|
||||||
|
.optional()
|
||||||
|
.isIn([
|
||||||
|
'INDIVIDUAL',
|
||||||
|
'COMPANY',
|
||||||
|
'HOLDING',
|
||||||
|
'GOVERNMENT',
|
||||||
|
'ORGANIZATION',
|
||||||
|
'EMBASSIES',
|
||||||
|
'BANK',
|
||||||
|
'UNIVERSITY',
|
||||||
|
'SCHOOL',
|
||||||
|
'UN',
|
||||||
|
'NGO',
|
||||||
|
'INSTITUTION',
|
||||||
|
]),
|
||||||
|
body('email')
|
||||||
|
.optional({ values: 'falsy' })
|
||||||
|
.custom((value) => {
|
||||||
|
if (value === null || value === undefined || value === '') return true
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
||||||
|
})
|
||||||
|
.withMessage('Invalid email format'),
|
||||||
validate,
|
validate,
|
||||||
],
|
],
|
||||||
contactsController.update
|
contactsController.update
|
||||||
|
|||||||
@@ -328,46 +328,50 @@ class ContactsService {
|
|||||||
|
|
||||||
// Update contact
|
// Update contact
|
||||||
const contact = await prisma.contact.update({
|
const contact = await prisma.contact.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
name: data.name,
|
type: data.type,
|
||||||
nameAr: data.nameAr,
|
name: data.name,
|
||||||
email: data.email,
|
nameAr: data.nameAr,
|
||||||
phone: data.phone,
|
email: data.email === '' || data.email === undefined ? null : data.email,
|
||||||
mobile: data.mobile,
|
phone: data.phone,
|
||||||
website: data.website,
|
mobile: data.mobile,
|
||||||
companyName: data.companyName,
|
website: data.website,
|
||||||
companyNameAr: data.companyNameAr,
|
companyName: data.companyName,
|
||||||
taxNumber: data.taxNumber,
|
companyNameAr: data.companyNameAr,
|
||||||
commercialRegister: data.commercialRegister,
|
taxNumber: data.taxNumber,
|
||||||
address: data.address,
|
commercialRegister: data.commercialRegister,
|
||||||
city: data.city,
|
address: data.address,
|
||||||
country: data.country,
|
city: data.city,
|
||||||
postalCode: data.postalCode,
|
country: data.country,
|
||||||
categories: data.categories ? {
|
postalCode: data.postalCode,
|
||||||
set: data.categories.map(id => ({ id }))
|
categories: data.categories
|
||||||
} : undefined,
|
? {
|
||||||
tags: data.tags,
|
set: data.categories.map((id) => ({ id })),
|
||||||
employeeId: data.employeeId !== undefined ? (data.employeeId || null) : undefined,
|
}
|
||||||
source: data.source,
|
: undefined,
|
||||||
status: data.status,
|
tags: data.tags,
|
||||||
rating: data.rating,
|
employeeId:
|
||||||
customFields: data.customFields,
|
data.employeeId !== undefined ? (data.employeeId || null) : undefined,
|
||||||
|
source: data.source,
|
||||||
|
status: data.status,
|
||||||
|
rating: data.rating,
|
||||||
|
customFields: data.customFields,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
categories: true,
|
||||||
|
parent: true,
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
},
|
},
|
||||||
include: {
|
},
|
||||||
categories: true,
|
},
|
||||||
parent: true,
|
});
|
||||||
employee: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
firstName: true,
|
|
||||||
lastName: true,
|
|
||||||
email: true,
|
|
||||||
uniqueEmployeeId: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log audit
|
// Log audit
|
||||||
await AuditLogger.log({
|
await AuditLogger.log({
|
||||||
@@ -679,7 +683,7 @@ class ContactsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate type
|
// Validate type
|
||||||
if (!['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT'].includes(row.type)) {
|
if (!['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT', 'ORGANIZATION','EMBASSIES','BANK','UNIVERSITY','SCHOOL','UN','NGO','INSTITUTION',].includes(row.type)) {
|
||||||
results.errors.push({
|
results.errors.push({
|
||||||
row: rowNumber,
|
row: rowNumber,
|
||||||
field: 'type',
|
field: 'type',
|
||||||
|
|||||||
@@ -14,6 +14,46 @@ router.post('/portal/loans', portalController.submitLoanRequest);
|
|||||||
router.get('/portal/leave-balance', portalController.getMyLeaveBalance);
|
router.get('/portal/leave-balance', portalController.getMyLeaveBalance);
|
||||||
router.get('/portal/leaves', portalController.getMyLeaves);
|
router.get('/portal/leaves', portalController.getMyLeaves);
|
||||||
router.post('/portal/leaves', portalController.submitLeaveRequest);
|
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.get('/portal/purchase-requests', portalController.getMyPurchaseRequests);
|
||||||
router.post('/portal/purchase-requests', portalController.submitPurchaseRequest);
|
router.post('/portal/purchase-requests', portalController.submitPurchaseRequest);
|
||||||
router.get('/portal/attendance', portalController.getMyAttendance);
|
router.get('/portal/attendance', portalController.getMyAttendance);
|
||||||
@@ -87,4 +127,3 @@ router.post('/contracts', authorize('hr', 'all', 'create'), hrController.createE
|
|||||||
router.put('/contracts/:id', authorize('hr', 'all', 'update'), hrController.updateEmployeeContract);
|
router.put('/contracts/:id', authorize('hr', 'all', 'update'), hrController.updateEmployeeContract);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|||||||
@@ -313,40 +313,54 @@ class HRService {
|
|||||||
// ========== LEAVES ==========
|
// ========== LEAVES ==========
|
||||||
|
|
||||||
async createLeaveRequest(data: any, userId: string) {
|
async createLeaveRequest(data: any, userId: string) {
|
||||||
const days = this.calculateLeaveDays(data.startDate, data.endDate);
|
const allowedLeaveTypes = ['ANNUAL', 'HOURLY'];
|
||||||
const startDate = new Date(data.startDate);
|
|
||||||
const year = startDate.getFullYear();
|
|
||||||
|
|
||||||
const ent = await prisma.leaveEntitlement.findUnique({
|
if (!allowedLeaveTypes.includes(String(data.leaveType || '').toUpperCase())) {
|
||||||
where: { employeeId_year_leaveType: { employeeId: data.employeeId, year, leaveType: data.leaveType } },
|
throw new AppError(400, 'نوع الإجازة غير مدعوم - Only ANNUAL and HOURLY leave types are allowed');
|
||||||
});
|
|
||||||
if (ent) {
|
|
||||||
const available = ent.totalDays + ent.carriedOver - ent.usedDays;
|
|
||||||
if (days > available) {
|
|
||||||
throw new AppError(400, `رصيد الإجازة غير كافٍ - Insufficient leave balance. Available: ${available}, Requested: ${days}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const leave = await prisma.leave.create({
|
|
||||||
data: {
|
|
||||||
...data,
|
|
||||||
days,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
employee: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await AuditLogger.log({
|
|
||||||
entityType: 'LEAVE',
|
|
||||||
entityId: leave.id,
|
|
||||||
action: 'CREATE',
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return leave;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedLeaveType = String(data.leaveType).toUpperCase();
|
||||||
|
const days = this.calculateLeaveDays(data.startDate, data.endDate);
|
||||||
|
const startDate = new Date(data.startDate);
|
||||||
|
const year = startDate.getFullYear();
|
||||||
|
|
||||||
|
const ent = await prisma.leaveEntitlement.findUnique({
|
||||||
|
where: {
|
||||||
|
employeeId_year_leaveType: {
|
||||||
|
employeeId: data.employeeId,
|
||||||
|
year,
|
||||||
|
leaveType: normalizedLeaveType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ent) {
|
||||||
|
const available = ent.totalDays + ent.carriedOver - ent.usedDays;
|
||||||
|
if (days > available) {
|
||||||
|
throw new AppError(400, `رصيد الإجازة غير كافٍ - Insufficient leave balance. Available: ${available}, Requested: ${days}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const leave = await prisma.leave.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
leaveType: normalizedLeaveType,
|
||||||
|
days,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
employee: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await AuditLogger.log({
|
||||||
|
entityType: 'LEAVE',
|
||||||
|
entityId: leave.id,
|
||||||
|
action: 'CREATE',
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return leave;
|
||||||
|
}
|
||||||
async approveLeave(id: string, approvedBy: string, userId: string) {
|
async approveLeave(id: string, approvedBy: string, userId: string) {
|
||||||
const leave = await prisma.leave.update({
|
const leave = await prisma.leave.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -418,6 +432,195 @@ class HRService {
|
|||||||
return { leaves, total, page, pageSize };
|
return { leaves, total, page, pageSize };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private calculateLeaveHours(startDate: Date, endDate: Date) {
|
||||||
|
const diffMs = new Date(endDate).getTime() - new Date(startDate).getTime();
|
||||||
|
const diffHours = diffMs / (1000 * 60 * 60);
|
||||||
|
return diffHours > 0 ? diffHours : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async canManagerApproveLeave(leave: any) {
|
||||||
|
if (leave.leaveType === 'ANNUAL') {
|
||||||
|
const yearStart = new Date(new Date(leave.startDate).getFullYear(), 0, 1);
|
||||||
|
const yearEnd = new Date(new Date(leave.startDate).getFullYear(), 11, 31, 23, 59, 59, 999);
|
||||||
|
|
||||||
|
const approvedAnnualLeaves = await prisma.leave.findMany({
|
||||||
|
where: {
|
||||||
|
employeeId: leave.employeeId,
|
||||||
|
leaveType: 'ANNUAL',
|
||||||
|
status: 'APPROVED',
|
||||||
|
startDate: {
|
||||||
|
gte: yearStart,
|
||||||
|
lte: yearEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
days: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const usedDays = approvedAnnualLeaves.reduce((sum, item) => sum + Number(item.days || 0), 0);
|
||||||
|
return usedDays + Number(leave.days || 0) <= 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leave.leaveType === 'HOURLY') {
|
||||||
|
const start = new Date(leave.startDate);
|
||||||
|
const monthStart = new Date(start.getFullYear(), start.getMonth(), 1);
|
||||||
|
const monthEnd = new Date(start.getFullYear(), start.getMonth() + 1, 0, 23, 59, 59, 999);
|
||||||
|
|
||||||
|
const approvedHourlyLeaves = await prisma.leave.findMany({
|
||||||
|
where: {
|
||||||
|
employeeId: leave.employeeId,
|
||||||
|
leaveType: 'HOURLY',
|
||||||
|
status: 'APPROVED',
|
||||||
|
startDate: {
|
||||||
|
gte: monthStart,
|
||||||
|
lte: monthEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const usedHours = approvedHourlyLeaves.reduce((sum, item) => {
|
||||||
|
return sum + this.calculateLeaveHours(item.startDate, item.endDate);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const requestedHours = this.calculateLeaveHours(leave.startDate, leave.endDate);
|
||||||
|
return usedHours + requestedHours <= 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findManagedLeaves(status?: string) {
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (status && status !== 'all') {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.leave.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
reportingToId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async managerApproveLeave(id: string, userId: string) {
|
||||||
|
const leave = await prisma.leave.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
reportingToId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!leave) {
|
||||||
|
throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leave.status !== 'PENDING') {
|
||||||
|
throw new AppError(400, 'لا يمكن اعتماد طلب غير معلق - Only pending leave can be approved');
|
||||||
|
}
|
||||||
|
|
||||||
|
const canApprove = await this.canManagerApproveLeave(leave);
|
||||||
|
if (!canApprove) {
|
||||||
|
throw new AppError(403, 'الطلب يتجاوز صلاحية مدير القسم ويحتاج موافقة HR - This leave exceeds manager approval limits and requires HR approval');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.leave.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'APPROVED',
|
||||||
|
approvedBy: userId,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
rejectedReason: null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
employee: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const year = new Date(updated.startDate).getFullYear();
|
||||||
|
await this.updateLeaveEntitlementUsed(updated.employeeId, year, updated.leaveType, updated.days);
|
||||||
|
|
||||||
|
await AuditLogger.log({
|
||||||
|
entityType: 'LEAVE',
|
||||||
|
entityId: updated.id,
|
||||||
|
action: 'MANAGER_APPROVE',
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async managerRejectLeave(id: string, rejectedReason: string, userId: string) {
|
||||||
|
const leave = await prisma.leave.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
reportingToId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!leave) {
|
||||||
|
throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leave.status !== 'PENDING') {
|
||||||
|
throw new AppError(400, 'لا يمكن رفض طلب غير معلق - Only pending leave can be rejected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.leave.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'REJECTED',
|
||||||
|
rejectedReason,
|
||||||
|
approvedBy: null,
|
||||||
|
approvedAt: null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
employee: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await AuditLogger.log({
|
||||||
|
entityType: 'LEAVE',
|
||||||
|
entityId: updated.id,
|
||||||
|
action: 'MANAGER_REJECT',
|
||||||
|
userId,
|
||||||
|
reason: rejectedReason,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
private async updateLeaveEntitlementUsed(employeeId: string, year: number, leaveType: string, days: number) {
|
private async updateLeaveEntitlementUsed(employeeId: string, year: number, leaveType: string, days: number) {
|
||||||
const ent = await prisma.leaveEntitlement.findUnique({
|
const ent = await prisma.leaveEntitlement.findUnique({
|
||||||
where: { employeeId_year_leaveType: { employeeId, year, leaveType } },
|
where: { employeeId_year_leaveType: { employeeId, year, leaveType } },
|
||||||
@@ -534,94 +737,260 @@ class HRService {
|
|||||||
return `${prefix}${next.toString().padStart(4, '0')}`;
|
return `${prefix}${next.toString().padStart(4, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAllLoans(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) {
|
async findAllLoans(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) {
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
const where: any = {};
|
const where: any = {};
|
||||||
if (filters.employeeId) where.employeeId = filters.employeeId;
|
if (filters.employeeId) where.employeeId = filters.employeeId;
|
||||||
if (filters.status && filters.status !== 'all') where.status = filters.status;
|
if (filters.status && filters.status !== 'all') where.status = filters.status;
|
||||||
const [total, loans] = await Promise.all([
|
|
||||||
prisma.loan.count({ where }),
|
const [total, loans] = await Promise.all([
|
||||||
prisma.loan.findMany({
|
prisma.loan.count({ where }),
|
||||||
where,
|
prisma.loan.findMany({
|
||||||
skip,
|
where,
|
||||||
take: pageSize,
|
skip,
|
||||||
include: { employee: { select: { id: true, firstName: true, lastName: true, uniqueEmployeeId: true } }, installmentsList: true },
|
take: pageSize,
|
||||||
orderBy: { createdAt: 'desc' },
|
include: {
|
||||||
}),
|
employee: {
|
||||||
]);
|
select: {
|
||||||
return { loans, total, page, pageSize };
|
id: true,
|
||||||
}
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
basicSalary: true,
|
||||||
|
position: {
|
||||||
|
select: {
|
||||||
|
code: true,
|
||||||
|
title: true,
|
||||||
|
titleAr: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
installmentsList: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { loans, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
async findLoanById(id: string) {
|
async findLoanById(id: string) {
|
||||||
const loan = await prisma.loan.findUnique({
|
const loan = await prisma.loan.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { employee: true, installmentsList: { orderBy: { installmentNumber: 'asc' } } },
|
include: {
|
||||||
});
|
employee: {
|
||||||
if (!loan) throw new AppError(404, 'القرض غير موجود - Loan not found');
|
select: {
|
||||||
return loan;
|
id: true,
|
||||||
}
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
uniqueEmployeeId: true,
|
||||||
|
basicSalary: true,
|
||||||
|
position: {
|
||||||
|
select: {
|
||||||
|
code: true,
|
||||||
|
title: true,
|
||||||
|
titleAr: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
installmentsList: { orderBy: { installmentNumber: 'asc' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loan) throw new AppError(404, 'القرض غير موجود - Loan not found');
|
||||||
|
return loan;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSystemAdminUser(user: any) {
|
||||||
|
const positionCode = user?.employee?.position?.code?.toUpperCase?.() || '';
|
||||||
|
const positionTitle = user?.employee?.position?.title?.toUpperCase?.() || '';
|
||||||
|
const positionTitleAr = user?.employee?.position?.titleAr || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
positionCode === 'SYS_ADMIN' ||
|
||||||
|
positionCode === 'SYSTEM_ADMIN' ||
|
||||||
|
positionTitle === 'SYSTEM ADMINISTRATOR' ||
|
||||||
|
positionTitleAr === 'مدير النظام'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async createLoan(data: { employeeId: string; type: string; amount: number; installments?: number; reason?: string }, userId: string) {
|
async createLoan(data: { employeeId: string; type: string; amount: number; installments?: number; reason?: string }, userId: string) {
|
||||||
const loanNumber = await this.generateLoanNumber();
|
if (!data.reason || !data.reason.trim()) {
|
||||||
const installments = data.installments || 1;
|
throw new AppError(400, 'سبب القرض مطلوب - Loan reason is required');
|
||||||
const monthlyAmount = data.amount / installments;
|
|
||||||
const loan = await prisma.loan.create({
|
|
||||||
data: {
|
|
||||||
loanNumber,
|
|
||||||
employeeId: data.employeeId,
|
|
||||||
type: data.type,
|
|
||||||
amount: data.amount,
|
|
||||||
installments,
|
|
||||||
monthlyAmount,
|
|
||||||
reason: data.reason,
|
|
||||||
status: 'PENDING',
|
|
||||||
},
|
|
||||||
include: { employee: true },
|
|
||||||
});
|
|
||||||
await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId });
|
|
||||||
return loan;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!data.amount || Number(data.amount) <= 0) {
|
||||||
|
throw new AppError(400, 'مبلغ القرض غير صالح - Invalid loan amount');
|
||||||
|
}
|
||||||
|
|
||||||
|
const loanNumber = await this.generateLoanNumber();
|
||||||
|
const installments = data.installments || 1;
|
||||||
|
const monthlyAmount = data.amount / installments;
|
||||||
|
|
||||||
|
const loan = await prisma.loan.create({
|
||||||
|
data: {
|
||||||
|
loanNumber,
|
||||||
|
employeeId: data.employeeId,
|
||||||
|
type: data.type,
|
||||||
|
amount: data.amount,
|
||||||
|
installments,
|
||||||
|
monthlyAmount,
|
||||||
|
reason: data.reason.trim(),
|
||||||
|
status: 'PENDING_HR',
|
||||||
|
},
|
||||||
|
include: { employee: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId });
|
||||||
|
return loan;
|
||||||
|
}
|
||||||
|
|
||||||
async approveLoan(id: string, approvedBy: string, startDate: Date, userId: string) {
|
async approveLoan(id: string, approvedBy: string, startDate: Date, userId: string) {
|
||||||
const loan = await prisma.loan.findUnique({ where: { id }, include: { installmentsList: true } });
|
const loan = await prisma.loan.findUnique({
|
||||||
if (!loan) throw new AppError(404, 'القرض غير موجود - Loan not found');
|
where: { id },
|
||||||
if (loan.status !== 'PENDING') throw new AppError(400, 'لا يمكن الموافقة على قرض غير معلق - Cannot approve non-pending loan');
|
include: {
|
||||||
|
installmentsList: true,
|
||||||
|
employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
basicSalary: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const monthlyAmount = Number(loan.monthlyAmount || loan.amount) / loan.installments;
|
if (!loan) {
|
||||||
const installments: { installmentNumber: number; dueDate: Date; amount: number }[] = [];
|
throw new AppError(404, 'القرض غير موجود - Loan not found');
|
||||||
let d = new Date(startDate);
|
|
||||||
for (let i = 1; i <= loan.installments; i++) {
|
|
||||||
installments.push({ installmentNumber: i, dueDate: new Date(d), amount: monthlyAmount });
|
|
||||||
d.setMonth(d.getMonth() + 1);
|
|
||||||
}
|
|
||||||
const endDate = installments.length ? installments[installments.length - 1].dueDate : null;
|
|
||||||
|
|
||||||
await prisma.$transaction([
|
|
||||||
prisma.loan.update({
|
|
||||||
where: { id },
|
|
||||||
data: { status: 'ACTIVE', approvedBy, approvedAt: new Date(), startDate, endDate },
|
|
||||||
}),
|
|
||||||
...installments.map((inst) =>
|
|
||||||
prisma.loanInstallment.create({
|
|
||||||
data: { loanId: id, installmentNumber: inst.installmentNumber, dueDate: inst.dueDate, amount: inst.amount, status: 'PENDING' },
|
|
||||||
})
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: 'APPROVE', userId });
|
|
||||||
return this.findLoanById(id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!['PENDING_HR', 'PENDING_ADMIN'].includes(loan.status)) {
|
||||||
|
throw new AppError(400, 'لا يمكن الموافقة على هذا القرض بهذه الحالة - Cannot approve this loan in current status');
|
||||||
|
}
|
||||||
|
|
||||||
|
const approverUser = await prisma.user.findUnique({
|
||||||
|
where: { id: approvedBy },
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
include: {
|
||||||
|
position: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!approverUser) {
|
||||||
|
throw new AppError(404, 'المستخدم غير موجود - User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSystemAdmin = this.isSystemAdminUser(approverUser);
|
||||||
|
const basicSalary = Number(loan.employee?.basicSalary || 0);
|
||||||
|
const loanAmount = Number(loan.amount || 0);
|
||||||
|
const needsAdminApproval = basicSalary > 0 && loanAmount > basicSalary * 0.5;
|
||||||
|
|
||||||
|
// المرحلة الأولى: HR approval
|
||||||
|
if (loan.status === 'PENDING_HR') {
|
||||||
|
if (needsAdminApproval) {
|
||||||
|
const updatedLoan = await prisma.loan.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'PENDING_ADMIN',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await AuditLogger.log({
|
||||||
|
entityType: 'LOAN',
|
||||||
|
entityId: id,
|
||||||
|
action: 'HR_APPROVE_FORWARD_TO_ADMIN',
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedLoan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// المرحلة الثانية: Admin approval إذا تجاوز 50%
|
||||||
|
if (loan.status === 'PENDING_ADMIN' && !isSystemAdmin) {
|
||||||
|
throw new AppError(403, 'هذا الطلب يحتاج موافقة مدير النظام - System Administrator approval required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthlyAmount = Number(loan.monthlyAmount || loan.amount) / loan.installments;
|
||||||
|
const installments: { installmentNumber: number; dueDate: Date; amount: number }[] = [];
|
||||||
|
let d = new Date(startDate);
|
||||||
|
|
||||||
|
for (let i = 1; i <= loan.installments; i++) {
|
||||||
|
installments.push({
|
||||||
|
installmentNumber: i,
|
||||||
|
dueDate: new Date(d),
|
||||||
|
amount: monthlyAmount,
|
||||||
|
});
|
||||||
|
d.setMonth(d.getMonth() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const endDate = installments.length ? installments[installments.length - 1].dueDate : null;
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.loan.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'ACTIVE',
|
||||||
|
approvedBy,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...installments.map((inst) =>
|
||||||
|
prisma.loanInstallment.create({
|
||||||
|
data: {
|
||||||
|
loanId: id,
|
||||||
|
installmentNumber: inst.installmentNumber,
|
||||||
|
dueDate: inst.dueDate,
|
||||||
|
amount: inst.amount,
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await AuditLogger.log({
|
||||||
|
entityType: 'LOAN',
|
||||||
|
entityId: id,
|
||||||
|
action: loan.status === 'PENDING_ADMIN' ? 'ADMIN_APPROVE' : 'APPROVE',
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.findLoanById(id);
|
||||||
|
}
|
||||||
|
|
||||||
async rejectLoan(id: string, rejectedReason: string, userId: string) {
|
async rejectLoan(id: string, rejectedReason: string, userId: string) {
|
||||||
const loan = await prisma.loan.update({
|
const existing = await prisma.loan.findUnique({ where: { id } });
|
||||||
where: { id },
|
|
||||||
data: { status: 'REJECTED', rejectedReason },
|
if (!existing) {
|
||||||
include: { employee: true },
|
throw new AppError(404, 'القرض غير موجود - Loan not found');
|
||||||
});
|
|
||||||
await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: 'REJECT', userId, reason: rejectedReason });
|
|
||||||
return loan;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!['PENDING_HR', 'PENDING_ADMIN'].includes(existing.status)) {
|
||||||
|
throw new AppError(400, 'لا يمكن رفض هذا القرض بهذه الحالة - Cannot reject this loan in current status');
|
||||||
|
}
|
||||||
|
|
||||||
|
const loan = await prisma.loan.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'REJECTED', rejectedReason },
|
||||||
|
include: { employee: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await AuditLogger.log({
|
||||||
|
entityType: 'LOAN',
|
||||||
|
entityId: id,
|
||||||
|
action: 'REJECT',
|
||||||
|
userId,
|
||||||
|
reason: rejectedReason,
|
||||||
|
});
|
||||||
|
|
||||||
|
return loan;
|
||||||
|
}
|
||||||
async recordLoanInstallmentPayment(loanId: string, installmentId: string, paidDate: Date, userId: string) {
|
async recordLoanInstallmentPayment(loanId: string, installmentId: string, paidDate: Date, userId: string) {
|
||||||
await prisma.loanInstallment.update({
|
await prisma.loanInstallment.update({
|
||||||
where: { id: installmentId },
|
where: { id: installmentId },
|
||||||
@@ -751,16 +1120,41 @@ class HRService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertLeaveEntitlement(data: { employeeId: string; year: number; leaveType: string; totalDays: number; carriedOver?: number; notes?: string }, userId: string) {
|
async upsertLeaveEntitlement(data: { employeeId: string; year: number; leaveType: string; totalDays: number; carriedOver?: number; notes?: string }, userId: string) {
|
||||||
const ent = await prisma.leaveEntitlement.upsert({
|
const allowedLeaveTypes = ['ANNUAL', 'HOURLY'];
|
||||||
where: { employeeId_year_leaveType: { employeeId: data.employeeId, year: data.year, leaveType: data.leaveType } },
|
const normalizedLeaveType = String(data.leaveType || '').toUpperCase();
|
||||||
create: { employeeId: data.employeeId, year: data.year, leaveType: data.leaveType, totalDays: data.totalDays, carriedOver: data.carriedOver || 0, notes: data.notes },
|
|
||||||
update: { totalDays: data.totalDays, carriedOver: data.carriedOver ?? undefined, notes: data.notes },
|
if (!allowedLeaveTypes.includes(normalizedLeaveType)) {
|
||||||
});
|
throw new AppError(400, 'نوع رصيد الإجازة غير مدعوم - Only ANNUAL and HOURLY leave entitlement types are allowed');
|
||||||
await AuditLogger.log({ entityType: 'LEAVE_ENTITLEMENT', entityId: ent.id, action: 'UPSERT', userId });
|
|
||||||
return ent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ent = await prisma.leaveEntitlement.upsert({
|
||||||
|
where: {
|
||||||
|
employeeId_year_leaveType: {
|
||||||
|
employeeId: data.employeeId,
|
||||||
|
year: data.year,
|
||||||
|
leaveType: normalizedLeaveType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
employeeId: data.employeeId,
|
||||||
|
year: data.year,
|
||||||
|
leaveType: normalizedLeaveType,
|
||||||
|
totalDays: data.totalDays,
|
||||||
|
carriedOver: data.carriedOver || 0,
|
||||||
|
notes: data.notes,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
totalDays: data.totalDays,
|
||||||
|
carriedOver: data.carriedOver ?? undefined,
|
||||||
|
notes: data.notes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await AuditLogger.log({ entityType: 'LEAVE_ENTITLEMENT', entityId: ent.id, action: 'UPSERT', userId });
|
||||||
|
return ent;
|
||||||
|
}
|
||||||
|
|
||||||
// ========== EMPLOYEE CONTRACTS ==========
|
// ========== EMPLOYEE CONTRACTS ==========
|
||||||
|
|
||||||
private async generateContractNumber(): Promise<string> {
|
private async generateContractNumber(): Promise<string> {
|
||||||
|
|||||||
@@ -49,6 +49,100 @@ export class PortalController {
|
|||||||
next(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) {
|
async submitLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ class PortalService {
|
|||||||
position: { select: { title: true, titleAr: true } },
|
position: { select: { title: true, titleAr: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!employee) throw new AppError(404, 'الموظف غير موجود - Employee not found');
|
|
||||||
|
if (!employee) {
|
||||||
|
throw new AppError(404, 'الموظف غير موجود - Employee not found');
|
||||||
|
}
|
||||||
|
|
||||||
const [loansCount, pendingLeaves, pendingPurchaseRequests, leaveBalance] = await Promise.all([
|
const [loansCount, pendingLeaves, pendingPurchaseRequests, leaveBalance] = await Promise.all([
|
||||||
prisma.loan.count({ where: { employeeId: empId, status: { in: ['PENDING', 'ACTIVE'] } } }),
|
prisma.loan.count({ where: { employeeId: empId, status: { in: ['PENDING', 'ACTIVE'] } } }),
|
||||||
@@ -46,6 +49,66 @@ class PortalService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
async getMyLoans(employeeId: string | undefined) {
|
||||||
const empId = this.requireEmployeeId(employeeId);
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
return prisma.loan.findMany({
|
return prisma.loan.findMany({
|
||||||
@@ -55,7 +118,268 @@ class PortalService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async submitLoanRequest(employeeId: string | undefined, data: { type: string; amount: number; installments?: number; reason?: string }, userId: string) {
|
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);
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
return hrService.createLoan({ ...data, employeeId: empId }, userId);
|
return hrService.createLoan({ ...data, employeeId: empId }, userId);
|
||||||
}
|
}
|
||||||
@@ -75,7 +399,31 @@ class PortalService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async submitLeaveRequest(employeeId: string | undefined, data: { leaveType: string; startDate: Date; endDate: Date; reason?: string }, userId: string) {
|
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);
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
return hrService.createLeaveRequest({ ...data, employeeId: empId }, userId);
|
return hrService.createLeaveRequest({ ...data, employeeId: empId }, userId);
|
||||||
}
|
}
|
||||||
@@ -88,7 +436,11 @@ class PortalService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async submitPurchaseRequest(employeeId: string | undefined, data: { items: any[]; reason?: string; priority?: string }, userId: string) {
|
async submitPurchaseRequest(
|
||||||
|
employeeId: string | undefined,
|
||||||
|
data: { items: any[]; reason?: string; priority?: string },
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
const empId = this.requireEmployeeId(employeeId);
|
const empId = this.requireEmployeeId(employeeId);
|
||||||
return hrService.createPurchaseRequest({ ...data, employeeId: empId }, userId);
|
return hrService.createPurchaseRequest({ ...data, employeeId: empId }, userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,6 +227,39 @@ export class TendersController {
|
|||||||
next(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();
|
export const tendersController = new TendersController();
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ const upload = multer({
|
|||||||
limits: { fileSize: config.upload.maxFileSize },
|
limits: { fileSize: config.upload.maxFileSize },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// View attachment
|
||||||
|
router.get(
|
||||||
|
'/attachments/:attachmentId/view',
|
||||||
|
param('attachmentId').isUUID(),
|
||||||
|
validate,
|
||||||
|
tendersController.viewAttachment
|
||||||
|
)
|
||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
|
|
||||||
// Enum/lookup routes (no resource id) - place before /:id routes
|
// Enum/lookup routes (no resource id) - place before /:id routes
|
||||||
@@ -173,3 +182,14 @@ router.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Delete attachment
|
||||||
|
router.delete(
|
||||||
|
'/attachments/:attachmentId',
|
||||||
|
authorize('tenders', 'tenders', 'update'),
|
||||||
|
param('attachmentId').isUUID(),
|
||||||
|
validate,
|
||||||
|
tendersController.deleteAttachment
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { AppError } from '../../shared/middleware/errorHandler';
|
|||||||
import { AuditLogger } from '../../shared/utils/auditLogger';
|
import { AuditLogger } from '../../shared/utils/auditLogger';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import fs from 'fs'
|
||||||
|
|
||||||
|
|
||||||
const TENDER_SOURCE_VALUES = [
|
const TENDER_SOURCE_VALUES = [
|
||||||
'GOVERNMENT_SITE',
|
'GOVERNMENT_SITE',
|
||||||
@@ -33,18 +35,31 @@ export interface CreateTenderData {
|
|||||||
issuingBodyName: string;
|
issuingBodyName: string;
|
||||||
title: string;
|
title: string;
|
||||||
tenderNumber: string;
|
tenderNumber: string;
|
||||||
|
|
||||||
termsValue: number;
|
termsValue: number;
|
||||||
bondValue: number;
|
bondValue: number;
|
||||||
|
|
||||||
|
// new extra fields stored inside notes metadata
|
||||||
|
initialBondValue?: number;
|
||||||
|
finalBondValue?: number;
|
||||||
|
finalBondRefundPeriod?: string;
|
||||||
|
siteVisitRequired?: boolean;
|
||||||
|
siteVisitLocation?: string;
|
||||||
|
termsPickupProvince?: string;
|
||||||
|
|
||||||
announcementDate: string;
|
announcementDate: string;
|
||||||
closingDate: string;
|
closingDate: string;
|
||||||
announcementLink?: string;
|
announcementLink?: string;
|
||||||
|
|
||||||
source: string;
|
source: string;
|
||||||
sourceOther?: string;
|
sourceOther?: string;
|
||||||
announcementType: string;
|
announcementType: string;
|
||||||
|
|
||||||
notes?: string;
|
notes?: string;
|
||||||
contactId?: string;
|
contactId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface CreateDirectiveData {
|
export interface CreateDirectiveData {
|
||||||
type: string;
|
type: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
@@ -71,12 +86,92 @@ class TendersService {
|
|||||||
return `TND-${year}-${seq}`;
|
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[]> {
|
async findPossibleDuplicates(data: CreateTenderData): Promise<any[]> {
|
||||||
const announcementDate = data.announcementDate ? new Date(data.announcementDate) : null;
|
const announcementDate = data.announcementDate ? new Date(data.announcementDate) : null;
|
||||||
const closingDate = data.closingDate ? new Date(data.closingDate) : null;
|
const closingDate = data.closingDate ? new Date(data.closingDate) : null;
|
||||||
const termsValue = Number(data.termsValue);
|
const termsValue = Number(data.termsValue);
|
||||||
const bondValue = Number(data.bondValue);
|
const bondValue = Number(data.initialBondValue ?? data.bondValue ?? 0);
|
||||||
|
|
||||||
const where: Prisma.TenderWhereInput = {
|
const where: Prisma.TenderWhereInput = {
|
||||||
status: { not: 'CANCELLED' },
|
status: { not: 'CANCELLED' },
|
||||||
};
|
};
|
||||||
@@ -135,20 +230,33 @@ class TendersService {
|
|||||||
const announcementDate = new Date(data.announcementDate);
|
const announcementDate = new Date(data.announcementDate);
|
||||||
const closingDate = new Date(data.closingDate);
|
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({
|
const tender = await prisma.tender.create({
|
||||||
data: {
|
data: {
|
||||||
tenderNumber,
|
tenderNumber,
|
||||||
issuingBodyName: data.issuingBodyName.trim(),
|
issuingBodyName: data.issuingBodyName.trim(),
|
||||||
title: data.title.trim(),
|
title: data.title.trim(),
|
||||||
termsValue: data.termsValue,
|
termsValue: data.termsValue,
|
||||||
bondValue: data.bondValue,
|
bondValue: Number(data.initialBondValue ?? data.bondValue ?? 0),
|
||||||
announcementDate,
|
announcementDate,
|
||||||
closingDate,
|
closingDate,
|
||||||
announcementLink: data.announcementLink?.trim() || null,
|
announcementLink: data.announcementLink?.trim() || null,
|
||||||
source: data.source,
|
source: data.source,
|
||||||
sourceOther: data.sourceOther?.trim() || null,
|
sourceOther: data.sourceOther?.trim() || null,
|
||||||
announcementType: data.announcementType,
|
announcementType: data.announcementType,
|
||||||
notes: data.notes?.trim() || null,
|
notes: finalNotes,
|
||||||
contactId: data.contactId || null,
|
contactId: data.contactId || null,
|
||||||
createdById: userId,
|
createdById: userId,
|
||||||
},
|
},
|
||||||
@@ -165,8 +273,10 @@ class TendersService {
|
|||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { tender, possibleDuplicates: possibleDuplicates.length ? possibleDuplicates : undefined };
|
return {
|
||||||
}
|
tender: this.mapTenderExtraFields(tender),
|
||||||
|
possibleDuplicates: possibleDuplicates.length ? possibleDuplicates.map((t) => this.mapTenderExtraFields(t)) : undefined,
|
||||||
|
}; }
|
||||||
|
|
||||||
async findAll(filters: any, page: number, pageSize: number) {
|
async findAll(filters: any, page: number, pageSize: number) {
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
@@ -195,7 +305,12 @@ class TendersService {
|
|||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
return { tenders, total, page, pageSize };
|
return {
|
||||||
|
tenders: tenders.map((t) => this.mapTenderExtraFields(t)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string) {
|
async findById(id: string) {
|
||||||
@@ -216,8 +331,8 @@ class TendersService {
|
|||||||
attachments: true,
|
attachments: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!tender) throw new AppError(404, 'Tender not found');
|
if (!tender) throw new AppError(404, 'Tender not found');
|
||||||
return tender;
|
return this.mapTenderExtraFields(tender);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, data: Partial<CreateTenderData>, userId: string) {
|
async update(id: string, data: Partial<CreateTenderData>, userId: string) {
|
||||||
@@ -228,17 +343,76 @@ class TendersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateData: Prisma.TenderUpdateInput = {};
|
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.title !== undefined) updateData.title = data.title.trim();
|
||||||
if (data.issuingBodyName !== undefined) updateData.issuingBodyName = data.issuingBodyName.trim();
|
if (data.issuingBodyName !== undefined) updateData.issuingBodyName = data.issuingBodyName.trim();
|
||||||
if (data.termsValue !== undefined) updateData.termsValue = data.termsValue;
|
if (data.termsValue !== undefined) updateData.termsValue = data.termsValue;
|
||||||
if (data.bondValue !== undefined) updateData.bondValue = data.bondValue;
|
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.announcementDate !== undefined) updateData.announcementDate = new Date(data.announcementDate);
|
||||||
if (data.closingDate !== undefined) updateData.closingDate = new Date(data.closingDate);
|
if (data.closingDate !== undefined) updateData.closingDate = new Date(data.closingDate);
|
||||||
if (data.announcementLink !== undefined) updateData.announcementLink = data.announcementLink?.trim() || null;
|
if (data.announcementLink !== undefined) updateData.announcementLink = data.announcementLink?.trim() || null;
|
||||||
if (data.source !== undefined) updateData.source = data.source;
|
if (data.source !== undefined) updateData.source = data.source;
|
||||||
if (data.sourceOther !== undefined) updateData.sourceOther = data.sourceOther?.trim() || null;
|
if (data.sourceOther !== undefined) updateData.sourceOther = data.sourceOther?.trim() || null;
|
||||||
if (data.announcementType !== undefined) updateData.announcementType = data.announcementType;
|
if (data.announcementType !== undefined) updateData.announcementType = data.announcementType;
|
||||||
if (data.notes !== undefined) updateData.notes = data.notes?.trim() || null;
|
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) {
|
if (data.contactId !== undefined) {
|
||||||
updateData.contact = data.contactId
|
updateData.contact = data.contactId
|
||||||
? { connect: { id: data.contactId } }
|
? { connect: { id: data.contactId } }
|
||||||
@@ -261,8 +435,8 @@ class TendersService {
|
|||||||
userId,
|
userId,
|
||||||
changes: { before: existing, after: data },
|
changes: { before: existing, after: data },
|
||||||
});
|
});
|
||||||
return tender;
|
return this.mapTenderExtraFields(tender);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createDirective(tenderId: string, data: CreateDirectiveData, userId: string) {
|
async createDirective(tenderId: string, data: CreateDirectiveData, userId: string) {
|
||||||
const tender = await prisma.tender.findUnique({
|
const tender = await prisma.tender.findUnique({
|
||||||
@@ -518,6 +692,34 @@ class TendersService {
|
|||||||
return attachment;
|
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> {
|
private async generateDealNumber(): Promise<string> {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
const prefix = `DEAL-${year}-`;
|
const prefix = `DEAL-${year}-`;
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PORT: 5001
|
PORT: 5001
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
# Default matches postgres service when POSTGRES_PASSWORD is unset (local/staging).
|
||||||
|
# Override via `.env` (Compose loads `.env`, not `.env.production`).
|
||||||
|
DATABASE_URL: ${DATABASE_URL:-postgresql://postgres:${POSTGRES_PASSWORD:-postgres123}@postgres:5432/mind14_crm?schema=public}
|
||||||
JWT_SECRET: ${JWT_SECRET:-z-crm-jwt-secret-change-in-production-NOW}
|
JWT_SECRET: ${JWT_SECRET:-z-crm-jwt-secret-change-in-production-NOW}
|
||||||
JWT_EXPIRES_IN: 7d
|
JWT_EXPIRES_IN: 7d
|
||||||
JWT_REFRESH_EXPIRES_IN: 30d
|
JWT_REFRESH_EXPIRES_IN: 30d
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
RUN addgroup --system --gid 1001 nodejs && \
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
adduser --system --uid 1001 nextjs
|
adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
# Copy necessary files
|
# Standalone first, then static assets; public last so it is not overwritten by any nested folder in standalone.
|
||||||
COPY --from=builder /app/public ./public
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
|
|||||||
903
frontend/package-lock.json
generated
903
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@swc/helpers": "^0.5.21",
|
||||||
"@tanstack/react-query": "^5.17.9",
|
"@tanstack/react-query": "^5.17.9",
|
||||||
"axios": "^1.6.5",
|
"axios": "^1.6.5",
|
||||||
"date-fns": "^3.0.6",
|
"date-fns": "^3.0.6",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 101 KiB |
@@ -14,6 +14,8 @@ const MODULES = [
|
|||||||
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
|
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
|
||||||
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
|
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
|
||||||
{ id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' },
|
{ id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' },
|
||||||
|
{ id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' },
|
||||||
|
{ id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية', nameEn: 'Department Overtime Requests' },
|
||||||
{ id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' },
|
{ id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' },
|
||||||
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
|
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
|
||||||
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
|
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const MODULES = [
|
|||||||
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
|
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
|
||||||
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
|
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
|
||||||
{ id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' },
|
{ id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' },
|
||||||
|
{ id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' },
|
||||||
|
{ id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية', nameEn: 'Department Overtime Requests' },
|
||||||
{ id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' },
|
{ id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' },
|
||||||
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
|
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
|
||||||
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
|
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
|
||||||
|
|||||||
@@ -98,7 +98,15 @@ function ContactDetailContent() {
|
|||||||
INDIVIDUAL: 'bg-blue-100 text-blue-700',
|
INDIVIDUAL: 'bg-blue-100 text-blue-700',
|
||||||
COMPANY: 'bg-green-100 text-green-700',
|
COMPANY: 'bg-green-100 text-green-700',
|
||||||
HOLDING: 'bg-purple-100 text-purple-700',
|
HOLDING: 'bg-purple-100 text-purple-700',
|
||||||
GOVERNMENT: 'bg-orange-100 text-orange-700'
|
GOVERNMENT: 'bg-orange-100 text-orange-700',
|
||||||
|
ORGANIZATION: 'bg-cyan-100 text-cyan-700',
|
||||||
|
EMBASSIES: 'bg-red-100 text-red-700',
|
||||||
|
BANK: 'bg-emerald-100 text-emerald-700',
|
||||||
|
UNIVERSITY: 'bg-indigo-100 text-indigo-700',
|
||||||
|
SCHOOL: 'bg-yellow-100 text-yellow-700',
|
||||||
|
UN: 'bg-sky-100 text-sky-700',
|
||||||
|
NGO: 'bg-pink-100 text-pink-700',
|
||||||
|
INSTITUTION: 'bg-gray-100 text-gray-700'
|
||||||
}
|
}
|
||||||
return colors[type] || 'bg-gray-100 text-gray-700'
|
return colors[type] || 'bg-gray-100 text-gray-700'
|
||||||
}
|
}
|
||||||
@@ -108,7 +116,15 @@ function ContactDetailContent() {
|
|||||||
INDIVIDUAL: 'فرد - Individual',
|
INDIVIDUAL: 'فرد - Individual',
|
||||||
COMPANY: 'شركة - Company',
|
COMPANY: 'شركة - Company',
|
||||||
HOLDING: 'مجموعة - Holding',
|
HOLDING: 'مجموعة - Holding',
|
||||||
GOVERNMENT: 'حكومي - Government'
|
GOVERNMENT: 'حكومي - Government',
|
||||||
|
ORGANIZATION: 'منظمات - Organizations',
|
||||||
|
BANK: 'بنوك - Banks',
|
||||||
|
UNIVERSITY: 'جامعات - Universities',
|
||||||
|
EMBASSIES: 'سفارات - Embassies',
|
||||||
|
SCHOOL: 'مدارس - Schools',
|
||||||
|
UN: 'UN - United Nations',
|
||||||
|
NGO: 'NGO - Non-Governmental Organization',
|
||||||
|
INSTITUTION: 'مؤسسة - Institution'
|
||||||
}
|
}
|
||||||
return labels[type] || type
|
return labels[type] || type
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,20 +41,17 @@ function flattenCategories(cats: Category[], result: Category[] = []): Category[
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ContactsContent() {
|
function ContactsContent() {
|
||||||
// State Management
|
|
||||||
const [contacts, setContacts] = useState<Contact[]>([])
|
const [contacts, setContacts] = useState<Contact[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [selectedContacts, setSelectedContacts] = useState<Set<string>>(new Set())
|
const [selectedContacts, setSelectedContacts] = useState<Set<string>>(new Set())
|
||||||
const [showBulkActions, setShowBulkActions] = useState(false)
|
const [showBulkActions, setShowBulkActions] = useState(false)
|
||||||
|
|
||||||
// Pagination
|
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
const [totalPages, setTotalPages] = useState(1)
|
const [totalPages, setTotalPages] = useState(1)
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const pageSize = 10
|
const pageSize = 10
|
||||||
|
|
||||||
// Filters
|
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const [selectedType, setSelectedType] = useState('all')
|
const [selectedType, setSelectedType] = useState('all')
|
||||||
const [selectedStatus, setSelectedStatus] = useState('all')
|
const [selectedStatus, setSelectedStatus] = useState('all')
|
||||||
@@ -64,7 +61,6 @@ function ContactsContent() {
|
|||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
|
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
|
||||||
|
|
||||||
// Modals
|
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
const [showEditModal, setShowEditModal] = useState(false)
|
const [showEditModal, setShowEditModal] = useState(false)
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
@@ -75,7 +71,6 @@ function ContactsContent() {
|
|||||||
const [exporting, setExporting] = useState(false)
|
const [exporting, setExporting] = useState(false)
|
||||||
const [exportExcludeCompanyEmployees, setExportExcludeCompanyEmployees] = useState(false)
|
const [exportExcludeCompanyEmployees, setExportExcludeCompanyEmployees] = useState(false)
|
||||||
|
|
||||||
// Fetch Contacts (with debouncing for search)
|
|
||||||
const fetchContacts = useCallback(async () => {
|
const fetchContacts = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -104,21 +99,18 @@ function ContactsContent() {
|
|||||||
}
|
}
|
||||||
}, [currentPage, searchTerm, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory])
|
}, [currentPage, searchTerm, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory])
|
||||||
|
|
||||||
// Debounced search
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const debounce = setTimeout(() => {
|
const debounce = setTimeout(() => {
|
||||||
setCurrentPage(1) // Reset to page 1 on new search
|
setCurrentPage(1)
|
||||||
fetchContacts()
|
fetchContacts()
|
||||||
}, 500)
|
}, 500)
|
||||||
return () => clearTimeout(debounce)
|
return () => clearTimeout(debounce)
|
||||||
}, [searchTerm])
|
}, [searchTerm])
|
||||||
|
|
||||||
// Fetch on filter/page change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchContacts()
|
fetchContacts()
|
||||||
}, [currentPage, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory])
|
}, [currentPage, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory])
|
||||||
|
|
||||||
// Create Contact
|
|
||||||
const handleCreate = async (data: CreateContactData) => {
|
const handleCreate = async (data: CreateContactData) => {
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
@@ -136,7 +128,6 @@ function ContactsContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit Contact
|
|
||||||
const handleEdit = async (data: UpdateContactData) => {
|
const handleEdit = async (data: UpdateContactData) => {
|
||||||
if (!selectedContact) return
|
if (!selectedContact) return
|
||||||
|
|
||||||
@@ -156,7 +147,6 @@ function ContactsContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete Contact
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!selectedContact) return
|
if (!selectedContact) return
|
||||||
|
|
||||||
@@ -175,7 +165,6 @@ function ContactsContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility Functions
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setSelectedContact(null)
|
setSelectedContact(null)
|
||||||
}
|
}
|
||||||
@@ -195,7 +184,15 @@ function ContactsContent() {
|
|||||||
INDIVIDUAL: 'bg-blue-100 text-blue-700',
|
INDIVIDUAL: 'bg-blue-100 text-blue-700',
|
||||||
COMPANY: 'bg-green-100 text-green-700',
|
COMPANY: 'bg-green-100 text-green-700',
|
||||||
HOLDING: 'bg-purple-100 text-purple-700',
|
HOLDING: 'bg-purple-100 text-purple-700',
|
||||||
GOVERNMENT: 'bg-orange-100 text-orange-700'
|
GOVERNMENT: 'bg-orange-100 text-orange-700',
|
||||||
|
ORGANIZATION: 'bg-cyan-100 text-cyan-700',
|
||||||
|
EMBASSIES: 'bg-red-100 text-red-700',
|
||||||
|
BANK: 'bg-emerald-100 text-emerald-700',
|
||||||
|
UNIVERSITY: 'bg-indigo-100 text-indigo-700',
|
||||||
|
SCHOOL: 'bg-yellow-100 text-yellow-700',
|
||||||
|
UN: 'bg-sky-100 text-sky-700',
|
||||||
|
NGO: 'bg-pink-100 text-pink-700',
|
||||||
|
INSTITUTION: 'bg-gray-100 text-gray-700'
|
||||||
}
|
}
|
||||||
return colors[type] || 'bg-gray-100 text-gray-700'
|
return colors[type] || 'bg-gray-100 text-gray-700'
|
||||||
}
|
}
|
||||||
@@ -209,19 +206,53 @@ function ContactsContent() {
|
|||||||
INDIVIDUAL: 'فرد',
|
INDIVIDUAL: 'فرد',
|
||||||
COMPANY: 'شركة',
|
COMPANY: 'شركة',
|
||||||
HOLDING: 'مجموعة',
|
HOLDING: 'مجموعة',
|
||||||
GOVERNMENT: 'حكومي'
|
GOVERNMENT: 'حكومي',
|
||||||
|
ORGANIZATION: 'منظمات',
|
||||||
|
EMBASSIES: 'سفارات',
|
||||||
|
BANK: 'بنوك',
|
||||||
|
UNIVERSITY: 'جامعات',
|
||||||
|
SCHOOL: 'مدارس',
|
||||||
|
UN: 'UN',
|
||||||
|
NGO: 'NGO',
|
||||||
|
INSTITUTION: 'مؤسسة'
|
||||||
}
|
}
|
||||||
return labels[type] || type
|
return labels[type] || type
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const organizationTypes = new Set([
|
||||||
|
'COMPANY',
|
||||||
|
'HOLDING',
|
||||||
|
'GOVERNMENT',
|
||||||
|
'ORGANIZATION',
|
||||||
|
'EMBASSIES',
|
||||||
|
'BANK',
|
||||||
|
'UNIVERSITY',
|
||||||
|
'SCHOOL',
|
||||||
|
'UN',
|
||||||
|
'NGO',
|
||||||
|
'INSTITUTION',
|
||||||
|
])
|
||||||
|
|
||||||
|
const isOrganizationContact = (contact: Contact) => organizationTypes.has(contact.type)
|
||||||
|
|
||||||
|
const getListContactName = (contact: Contact) => {
|
||||||
|
return contact.name || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getListCompanyName = (contact: Contact) => {
|
||||||
|
return contact.companyName || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getListContactNameAr = (contact: Contact) => {
|
||||||
|
return (contact as any).nameAr || ''
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Header */}
|
|
||||||
<header className="bg-white shadow-sm border-b">
|
<header className="bg-white shadow-sm border-b">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
@@ -289,7 +320,6 @@ function ContactsContent() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
{/* Stats Cards */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||||
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200">
|
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -344,12 +374,9 @@ function ContactsContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters and Search */}
|
|
||||||
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200 mb-6">
|
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200 mb-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Main Filters Row */}
|
|
||||||
<div className="flex flex-col md:flex-row gap-4">
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
{/* Search */}
|
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<Search className="absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
<Search className="absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
@@ -361,7 +388,6 @@ function ContactsContent() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Type Filter */}
|
|
||||||
<select
|
<select
|
||||||
value={selectedType}
|
value={selectedType}
|
||||||
onChange={(e) => setSelectedType(e.target.value)}
|
onChange={(e) => setSelectedType(e.target.value)}
|
||||||
@@ -372,9 +398,16 @@ function ContactsContent() {
|
|||||||
<option value="COMPANY">Companies</option>
|
<option value="COMPANY">Companies</option>
|
||||||
<option value="HOLDING">Holdings</option>
|
<option value="HOLDING">Holdings</option>
|
||||||
<option value="GOVERNMENT">Government</option>
|
<option value="GOVERNMENT">Government</option>
|
||||||
|
<option value="ORGANIZATION">Organizations</option>
|
||||||
|
<option value="EMBASSIES">Embassies</option>
|
||||||
|
<option value="BANK">Banks</option>
|
||||||
|
<option value="UNIVERSITY">Universities</option>
|
||||||
|
<option value="SCHOOL">Schools</option>
|
||||||
|
<option value="UN">UN</option>
|
||||||
|
<option value="NGO">NGO</option>
|
||||||
|
<option value="INSTITUTION">Institution</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{/* Status Filter */}
|
|
||||||
<select
|
<select
|
||||||
value={selectedStatus}
|
value={selectedStatus}
|
||||||
onChange={(e) => setSelectedStatus(e.target.value)}
|
onChange={(e) => setSelectedStatus(e.target.value)}
|
||||||
@@ -385,7 +418,6 @@ function ContactsContent() {
|
|||||||
<option value="INACTIVE">Inactive</option>
|
<option value="INACTIVE">Inactive</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{/* Advanced Filters Toggle */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
|
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
|
||||||
className={`flex items-center gap-2 px-4 py-3 border rounded-lg transition-colors ${
|
className={`flex items-center gap-2 px-4 py-3 border rounded-lg transition-colors ${
|
||||||
@@ -399,11 +431,9 @@ function ContactsContent() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Advanced Filters */}
|
|
||||||
{showAdvancedFilters && (
|
{showAdvancedFilters && (
|
||||||
<div className="pt-4 border-t border-gray-200">
|
<div className="pt-4 border-t border-gray-200">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
{/* Source Filter */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Source</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">Source</label>
|
||||||
<select
|
<select
|
||||||
@@ -423,7 +453,6 @@ function ContactsContent() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rating Filter */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Rating</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">Rating</label>
|
||||||
<select
|
<select
|
||||||
@@ -440,7 +469,6 @@ function ContactsContent() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category Filter */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Category</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">Category</label>
|
||||||
<select
|
<select
|
||||||
@@ -455,7 +483,6 @@ function ContactsContent() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Clear Filters */}
|
|
||||||
<div className="flex items-end">
|
<div className="flex items-end">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -478,7 +505,6 @@ function ContactsContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contacts Table */}
|
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-12">
|
<div className="p-12">
|
||||||
@@ -525,9 +551,9 @@ function ContactsContent() {
|
|||||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Contact</th>
|
|
||||||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Contact Info</th>
|
|
||||||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Company</th>
|
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Company</th>
|
||||||
|
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Contact Info</th>
|
||||||
|
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Contact</th>
|
||||||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Type</th>
|
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Type</th>
|
||||||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Status</th>
|
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Status</th>
|
||||||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Actions</th>
|
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Actions</th>
|
||||||
@@ -537,105 +563,119 @@ function ContactsContent() {
|
|||||||
{contacts.map((contact) => {
|
{contacts.map((contact) => {
|
||||||
const isSelected = selectedContacts.has(contact.id)
|
const isSelected = selectedContacts.has(contact.id)
|
||||||
return (
|
return (
|
||||||
<tr key={contact.id} className={`hover:bg-gray-50 transition-colors ${isSelected ? 'bg-blue-50' : ''}`}>
|
<tr key={contact.id} className={`hover:bg-gray-50 transition-colors ${isSelected ? 'bg-blue-50' : ''}`}>
|
||||||
<td className="px-6 py-4 text-center">
|
<td className="px-6 py-4 text-center">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newSelected = new Set(selectedContacts)
|
const newSelected = new Set(selectedContacts)
|
||||||
if (e.target.checked) {
|
if (e.target.checked) {
|
||||||
newSelected.add(contact.id)
|
newSelected.add(contact.id)
|
||||||
} else {
|
} else {
|
||||||
newSelected.delete(contact.id)
|
newSelected.delete(contact.id)
|
||||||
}
|
}
|
||||||
setSelectedContacts(newSelected)
|
setSelectedContacts(newSelected)
|
||||||
}}
|
}}
|
||||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{getListCompanyName(contact) !== '-' && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="h-4 w-4 text-gray-400" />
|
||||||
|
<span className="text-sm text-gray-900">
|
||||||
|
{getListCompanyName(contact)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{contact.email && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
{contact.email}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(contact.phone || contact.mobile) && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<Phone className="h-4 w-4" />
|
||||||
|
{contact.phone || contact.mobile}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold">
|
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold">
|
||||||
{contact.name.charAt(0)}
|
{getListContactName(contact).charAt(0)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-gray-900">{contact.name}</p>
|
<p className="font-semibold text-gray-900">
|
||||||
{contact.nameAr && <p className="text-sm text-gray-600">{contact.nameAr}</p>}
|
{getListContactName(contact)}
|
||||||
|
</p>
|
||||||
|
{getListContactNameAr(contact) && (
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{getListContactNameAr(contact)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="space-y-1">
|
<td className="px-6 py-4">
|
||||||
{contact.email && (
|
<span className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium ${getTypeColor(contact.type)}`}>
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
<Tag className="h-3 w-3" />
|
||||||
<Mail className="h-4 w-4" />
|
{getTypeLabel(contact.type)}
|
||||||
{contact.email}
|
</span>
|
||||||
</div>
|
</td>
|
||||||
)}
|
|
||||||
{contact.phone && (
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
|
||||||
<Phone className="h-4 w-4" />
|
contact.status === 'ACTIVE'
|
||||||
{contact.phone}
|
? 'bg-green-100 text-green-700'
|
||||||
</div>
|
: 'bg-gray-100 text-gray-700'
|
||||||
)}
|
}`}>
|
||||||
</div>
|
{contact.status === 'ACTIVE' ? 'Active' : 'Inactive'}
|
||||||
</td>
|
</span>
|
||||||
<td className="px-6 py-4">
|
</td>
|
||||||
{contact.companyName && (
|
|
||||||
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Building2 className="h-4 w-4 text-gray-400" />
|
<Link
|
||||||
<span className="text-sm text-gray-900">{contact.companyName}</span>
|
href={`/contacts/${contact.id}`}
|
||||||
|
className="p-2 hover:bg-blue-50 text-blue-600 rounded-lg transition-colors"
|
||||||
|
title="View"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => openEditModal(contact)}
|
||||||
|
className="p-2 hover:bg-green-50 text-green-600 rounded-lg transition-colors"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openDeleteDialog(contact)}
|
||||||
|
className="p-2 hover:bg-red-50 text-red-600 rounded-lg transition-colors"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
<td className="px-6 py-4">
|
)
|
||||||
<span className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium ${getTypeColor(contact.type)}`}>
|
})}
|
||||||
<Tag className="h-3 w-3" />
|
|
||||||
{getTypeLabel(contact.type)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
|
|
||||||
contact.status === 'ACTIVE'
|
|
||||||
? 'bg-green-100 text-green-700'
|
|
||||||
: 'bg-gray-100 text-gray-700'
|
|
||||||
}`}>
|
|
||||||
{contact.status === 'ACTIVE' ? 'Active' : 'Inactive'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Link
|
|
||||||
href={`/contacts/${contact.id}`}
|
|
||||||
className="p-2 hover:bg-blue-50 text-blue-600 rounded-lg transition-colors"
|
|
||||||
title="View"
|
|
||||||
>
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
onClick={() => openEditModal(contact)}
|
|
||||||
className="p-2 hover:bg-green-50 text-green-600 rounded-lg transition-colors"
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => openDeleteDialog(contact)}
|
|
||||||
className="p-2 hover:bg-red-50 text-red-600 rounded-lg transition-colors"
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Showing <span className="font-semibold">{((currentPage - 1) * pageSize) + 1}</span> to{' '}
|
Showing <span className="font-semibold">{((currentPage - 1) * pageSize) + 1}</span> to{' '}
|
||||||
@@ -681,7 +721,6 @@ function ContactsContent() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Create Modal */}
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showCreateModal}
|
isOpen={showCreateModal}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -692,6 +731,7 @@ function ContactsContent() {
|
|||||||
size="xl"
|
size="xl"
|
||||||
>
|
>
|
||||||
<ContactForm
|
<ContactForm
|
||||||
|
key="create-contact"
|
||||||
onSubmit={async (data) => {
|
onSubmit={async (data) => {
|
||||||
await handleCreate(data as CreateContactData)
|
await handleCreate(data as CreateContactData)
|
||||||
}}
|
}}
|
||||||
@@ -703,7 +743,6 @@ function ContactsContent() {
|
|||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Edit Modal */}
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showEditModal}
|
isOpen={showEditModal}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -714,6 +753,7 @@ function ContactsContent() {
|
|||||||
size="xl"
|
size="xl"
|
||||||
>
|
>
|
||||||
<ContactForm
|
<ContactForm
|
||||||
|
key={selectedContact?.id || 'edit-contact'}
|
||||||
contact={selectedContact || undefined}
|
contact={selectedContact || undefined}
|
||||||
onSubmit={async (data) => {
|
onSubmit={async (data) => {
|
||||||
await handleEdit(data as UpdateContactData)
|
await handleEdit(data as UpdateContactData)
|
||||||
@@ -726,7 +766,6 @@ function ContactsContent() {
|
|||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Export Modal */}
|
|
||||||
{showExportModal && (
|
{showExportModal && (
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowExportModal(false)} />
|
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowExportModal(false)} />
|
||||||
@@ -820,7 +859,6 @@ function ContactsContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
|
||||||
{showDeleteDialog && selectedContact && (
|
{showDeleteDialog && selectedContact && (
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowDeleteDialog(false)} />
|
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowDeleteDialog(false)} />
|
||||||
@@ -869,7 +907,6 @@ function ContactsContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Import Modal */}
|
|
||||||
{showImportModal && (
|
{showImportModal && (
|
||||||
<ContactImport
|
<ContactImport
|
||||||
onClose={() => setShowImportModal(false)}
|
onClose={() => setShowImportModal(false)}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import logoImage from '@/assets/logo.png'
|
||||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||||
import { useAuth } from '@/contexts/AuthContext'
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
import { useLanguage } from '@/contexts/LanguageContext'
|
import { useLanguage } from '@/contexts/LanguageContext'
|
||||||
@@ -141,13 +143,14 @@ function DashboardContent() {
|
|||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-primary-600 p-2 rounded-lg">
|
<Image
|
||||||
<img
|
src={logoImage}
|
||||||
src="/logo.png"
|
alt="Company Logo"
|
||||||
alt="Company Logo"
|
width={48}
|
||||||
className="h-8 w-8 object-contain"
|
height={48}
|
||||||
/>
|
className="object-contain"
|
||||||
</div>
|
priority
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">ATMATA</h1>
|
<h1 className="text-2xl font-bold text-gray-900">ATMATA</h1>
|
||||||
<p className="text-sm text-gray-600">نظام إدارة علاقات العملاء</p>
|
<p className="text-sm text-gray-600">نظام إدارة علاقات العملاء</p>
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ function HRContent() {
|
|||||||
mobile: '',
|
mobile: '',
|
||||||
dateOfBirth: '',
|
dateOfBirth: '',
|
||||||
gender: '',
|
gender: '',
|
||||||
nationality: 'Saudi Arabia',
|
nationality: 'Syria',
|
||||||
nationalId: '',
|
nationalId: '',
|
||||||
employmentType: 'FULL_TIME',
|
employmentType: 'FULL_TIME',
|
||||||
contractType: 'UNLIMITED',
|
contractType: 'UNLIMITED',
|
||||||
@@ -372,8 +372,8 @@ function HRContent() {
|
|||||||
const { leaves } = await hrAdminAPI.getLeaves({ status: 'PENDING', pageSize: 50 })
|
const { leaves } = await hrAdminAPI.getLeaves({ status: 'PENDING', pageSize: 50 })
|
||||||
setLeavesData(leaves)
|
setLeavesData(leaves)
|
||||||
} else if (activeTab === 'loans') {
|
} else if (activeTab === 'loans') {
|
||||||
const { loans } = await hrAdminAPI.getLoans({ status: 'PENDING', pageSize: 50 })
|
const { loans } = await hrAdminAPI.getLoans({ pageSize: 50 })
|
||||||
setLoansData(loans)
|
setLoansData(loans.filter((loan: any) => ['PENDING_HR', 'PENDING_ADMIN'].includes(loan.status)))
|
||||||
} else if (activeTab === 'purchases') {
|
} else if (activeTab === 'purchases') {
|
||||||
const { purchaseRequests } = await hrAdminAPI.getPurchaseRequests({ status: 'PENDING', pageSize: 50 })
|
const { purchaseRequests } = await hrAdminAPI.getPurchaseRequests({ status: 'PENDING', pageSize: 50 })
|
||||||
setPurchasesData(purchaseRequests)
|
setPurchasesData(purchaseRequests)
|
||||||
@@ -1185,19 +1185,75 @@ function HRContent() {
|
|||||||
<p className="text-gray-500">No pending loans</p>
|
<p className="text-gray-500">No pending loans</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{loansData.map((l: any) => (
|
{loansData.map((l: any) => {
|
||||||
|
const salary = Number(l.employee?.basicSalary || 0)
|
||||||
|
const amount = Number(l.amount || 0)
|
||||||
|
const needsAdmin = salary > 0 && amount > salary * 0.5
|
||||||
|
|
||||||
|
return (
|
||||||
<div key={l.id} className="flex justify-between items-center py-3 border-b">
|
<div key={l.id} className="flex justify-between items-center py-3 border-b">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{l.employee?.firstName} {l.employee?.lastName}</p>
|
<p className="font-medium">{l.employee?.firstName} {l.employee?.lastName}</p>
|
||||||
<p className="text-sm text-gray-600">{l.loanNumber} - {l.type} - {Number(l.amount).toLocaleString()} SAR ({l.installments} installments)</p>
|
<p className="text-sm text-gray-600">
|
||||||
{l.reason && <p className="text-xs text-gray-500">{l.reason}</p>}
|
{l.loanNumber} - {l.type} - {amount.toLocaleString()} SAR ({l.installments} installments)
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
الراتب الأساسي: {salary.toLocaleString()} SAR
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
الحالة: {l.status === 'PENDING_HR' ? 'بانتظار HR' : 'بانتظار مدير النظام'}
|
||||||
|
</p>
|
||||||
|
{needsAdmin && (
|
||||||
|
<p className="text-xs text-orange-600">
|
||||||
|
هذا الطلب يتجاوز 50% من الراتب الأساسي ويحتاج موافقة مدير النظام
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{l.reason && <p className="text-xs text-gray-500 mt-1">{l.reason}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={async () => { try { await hrAdminAPI.approveLoan(l.id); toast.success('Approved'); setLoansData((p: any[]) => p.filter((x: any) => x.id !== l.id)) } catch { toast.error('Failed') } }} className="px-3 py-1 bg-green-600 text-white rounded text-sm">Approve</button>
|
<button
|
||||||
<button onClick={async () => { const r = prompt('Rejection reason?'); if (r) { try { await hrAdminAPI.rejectLoan(l.id, r); toast.success('Rejected'); setLoansData((p: any[]) => p.filter((x: any) => x.id !== l.id)) } catch { toast.error('Failed') } } }} className="px-3 py-1 bg-red-600 text-white rounded text-sm">Reject</button>
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const response = await hrAdminAPI.approveLoan(l.id)
|
||||||
|
const updatedLoan = response?.data?.data || response?.data || response
|
||||||
|
|
||||||
|
if (updatedLoan?.status === 'PENDING_ADMIN') {
|
||||||
|
toast.success('تمت موافقة HR وتحويل الطلب إلى مدير النظام')
|
||||||
|
} else {
|
||||||
|
toast.success('Approved')
|
||||||
|
setLoansData((p: any[]) => p.filter((x: any) => x.id !== l.id))
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.response?.data?.message || 'Failed')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 bg-green-600 text-white rounded text-sm"
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const r = prompt('Rejection reason?')
|
||||||
|
if (r) {
|
||||||
|
try {
|
||||||
|
await hrAdminAPI.rejectLoan(l.id, r)
|
||||||
|
toast.success('Rejected')
|
||||||
|
setLoansData((p: any[]) => p.filter((x: any) => x.id !== l.id))
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.response?.data?.message || 'Failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 bg-red-600 text-white rounded text-sm"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,18 +13,31 @@ import {
|
|||||||
DollarSign,
|
DollarSign,
|
||||||
Building2,
|
Building2,
|
||||||
LogOut,
|
LogOut,
|
||||||
User
|
User,
|
||||||
|
CheckCircle2,
|
||||||
|
TimerReset,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
function PortalLayoutContent({ children }: { children: React.ReactNode }) {
|
function PortalLayoutContent({ children }: { children: React.ReactNode }) {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout, hasPermission } = useAuth()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ icon: LayoutDashboard, label: 'لوحة التحكم', labelEn: 'Dashboard', href: '/portal', exact: true },
|
{ icon: LayoutDashboard, label: 'لوحة التحكم', labelEn: 'Dashboard', href: '/portal', exact: true },
|
||||||
{ icon: Banknote, label: 'قروضي', labelEn: 'My Loans', href: '/portal/loans' },
|
{ icon: Banknote, label: 'قروضي', labelEn: 'My Loans', href: '/portal/loans' },
|
||||||
{ icon: Calendar, label: 'إجازاتي', labelEn: 'My Leave', href: '/portal/leave' },
|
{ icon: Calendar, label: 'إجازاتي', labelEn: 'My Leave', href: '/portal/leave' },
|
||||||
{ icon: ShoppingCart, label: 'طلبات الشراء', labelEn: 'Purchase Requests', href: '/portal/purchase-requests' },
|
{ 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: Clock, label: 'حضوري', labelEn: 'My Attendance', href: '/portal/attendance' },
|
||||||
{ icon: DollarSign, label: 'رواتبي', labelEn: 'My Salaries', href: '/portal/salaries' },
|
{ icon: DollarSign, label: 'رواتبي', labelEn: 'My Salaries', href: '/portal/salaries' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,13 +5,11 @@ import { portalAPI } from '@/lib/api/portal'
|
|||||||
import Modal from '@/components/Modal'
|
import Modal from '@/components/Modal'
|
||||||
import LoadingSpinner from '@/components/LoadingSpinner'
|
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||||
import { toast } from 'react-hot-toast'
|
import { toast } from 'react-hot-toast'
|
||||||
import { Calendar, Plus } from 'lucide-react'
|
import { Plus } from 'lucide-react'
|
||||||
|
|
||||||
const LEAVE_TYPES = [
|
const LEAVE_TYPES = [
|
||||||
{ value: 'ANNUAL', label: 'إجازة سنوية' },
|
{ value: 'ANNUAL', label: 'إجازة سنوية' },
|
||||||
{ value: 'SICK', label: 'إجازة مرضية' },
|
{ value: 'HOURLY', label: 'إجازة ساعية' },
|
||||||
{ value: 'EMERGENCY', label: 'طوارئ' },
|
|
||||||
{ value: 'UNPAID', label: 'بدون راتب' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||||
@@ -26,7 +24,16 @@ export default function PortalLeavePage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [form, setForm] = useState({ leaveType: 'ANNUAL', startDate: '', endDate: '', reason: '' })
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
leaveType: 'ANNUAL',
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
leaveDate: '',
|
||||||
|
startTime: '',
|
||||||
|
endTime: '',
|
||||||
|
reason: '',
|
||||||
|
})
|
||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -43,24 +50,54 @@ export default function PortalLeavePage() {
|
|||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!form.startDate || !form.endDate) {
|
|
||||||
toast.error('أدخل تاريخ البداية والنهاية')
|
let payload: any = {
|
||||||
return
|
|
||||||
}
|
|
||||||
if (new Date(form.endDate) < new Date(form.startDate)) {
|
|
||||||
toast.error('تاريخ النهاية يجب أن يكون بعد تاريخ البداية')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setSubmitting(true)
|
|
||||||
portalAPI.submitLeaveRequest({
|
|
||||||
leaveType: form.leaveType,
|
leaveType: form.leaveType,
|
||||||
startDate: form.startDate,
|
|
||||||
endDate: form.endDate,
|
|
||||||
reason: form.reason || undefined,
|
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(() => {
|
.then(() => {
|
||||||
setShowModal(false)
|
setShowModal(false)
|
||||||
setForm({ leaveType: 'ANNUAL', startDate: '', endDate: '', reason: '' })
|
setForm({
|
||||||
|
leaveType: 'ANNUAL',
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
leaveDate: '',
|
||||||
|
startTime: '',
|
||||||
|
endTime: '',
|
||||||
|
reason: '',
|
||||||
|
})
|
||||||
toast.success('تم إرسال طلب الإجازة')
|
toast.success('تم إرسال طلب الإجازة')
|
||||||
load()
|
load()
|
||||||
})
|
})
|
||||||
@@ -72,6 +109,8 @@ export default function PortalLeavePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
||||||
|
{/* HEADER */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">إجازاتي</h1>
|
<h1 className="text-2xl font-bold text-gray-900">إجازاتي</h1>
|
||||||
<button
|
<button
|
||||||
@@ -83,6 +122,7 @@ export default function PortalLeavePage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* الرصيد */}
|
||||||
{leaveBalance.length > 0 && (
|
{leaveBalance.length > 0 && (
|
||||||
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
|
<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>
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">رصيد الإجازات</h2>
|
||||||
@@ -90,36 +130,42 @@ export default function PortalLeavePage() {
|
|||||||
{leaveBalance.map((b) => (
|
{leaveBalance.map((b) => (
|
||||||
<div key={b.leaveType} className="border rounded-lg p-4">
|
<div key={b.leaveType} className="border rounded-lg p-4">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
{b.leaveType === 'ANNUAL' ? 'سنوية' : b.leaveType === 'SICK' ? 'مرضية' : b.leaveType}
|
{b.leaveType === 'ANNUAL' ? 'سنوية' : b.leaveType}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold text-teal-600 mt-1">{b.available} يوم</p>
|
<p className="text-2xl font-bold text-teal-600 mt-1">{b.available} يوم</p>
|
||||||
<p className="text-xs text-gray-500">من {b.totalDays + b.carriedOver} (مستخدم: {b.usedDays})</p>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* الطلبات */}
|
||||||
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
|
<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>
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">طلباتي</h2>
|
||||||
|
|
||||||
{leaves.length === 0 ? (
|
{leaves.length === 0 ? (
|
||||||
<p className="text-gray-500 text-center py-8">لا توجد طلبات إجازة</p>
|
<p className="text-gray-500 text-center py-8">لا توجد طلبات</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{leaves.map((l) => {
|
{leaves.map((l) => {
|
||||||
const statusInfo = STATUS_MAP[l.status] || { label: l.status, color: 'bg-gray-100' }
|
const statusInfo = STATUS_MAP[l.status] || { label: l.status, color: 'bg-gray-100' }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={l.id} className="flex justify-between items-center py-3 border-b last:border-0">
|
<div key={l.id} className="flex justify-between items-center py-3 border-b">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{l.leaveType === 'ANNUAL' ? 'سنوية' : l.leaveType === 'SICK' ? 'مرضية' : l.leaveType} - {l.days} يوم
|
{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>
|
||||||
|
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
{new Date(l.startDate).toLocaleDateString('ar-SA')} - {new Date(l.endDate).toLocaleDateString('ar-SA')}
|
{new Date(l.startDate).toLocaleDateString('ar-SA')} - {new Date(l.endDate).toLocaleDateString('ar-SA')}
|
||||||
</p>
|
</p>
|
||||||
{l.rejectedReason && <p className="text-sm text-red-600">سبب الرفض: {l.rejectedReason}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
|
|
||||||
|
<span className={`px-3 py-1 rounded-full text-xs ${statusInfo.color}`}>
|
||||||
{statusInfo.label}
|
{statusInfo.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,59 +175,116 @@ export default function PortalLeavePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* الفورم */}
|
||||||
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب إجازة جديد">
|
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب إجازة جديد">
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">نوع الإجازة</label>
|
{/* نوع الإجازة */}
|
||||||
<select
|
<select
|
||||||
value={form.leaveType}
|
value={form.leaveType}
|
||||||
onChange={(e) => setForm((p) => ({ ...p, leaveType: e.target.value }))}
|
onChange={(e) =>
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
setForm({
|
||||||
>
|
leaveType: e.target.value,
|
||||||
{LEAVE_TYPES.map((t) => (
|
startDate: '',
|
||||||
<option key={t.value} value={t.value}>{t.label}</option>
|
endDate: '',
|
||||||
))}
|
leaveDate: '',
|
||||||
</select>
|
startTime: '',
|
||||||
</div>
|
endTime: '',
|
||||||
<div className="grid grid-cols-2 gap-4">
|
reason: '',
|
||||||
<div>
|
})
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">من تاريخ</label>
|
}
|
||||||
<input
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
type="date"
|
>
|
||||||
value={form.startDate}
|
{LEAVE_TYPES.map((t) => (
|
||||||
onChange={(e) => setForm((p) => ({ ...p, startDate: e.target.value }))}
|
<option key={t.value} value={t.value}>{t.label}</option>
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
))}
|
||||||
required
|
</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>
|
||||||
<div>
|
) : (
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">إلى تاريخ</label>
|
/* ساعية */
|
||||||
<input
|
<div className="grid grid-cols-3 gap-4">
|
||||||
type="date"
|
<div>
|
||||||
value={form.endDate}
|
<label className="text-sm">التاريخ</label>
|
||||||
onChange={(e) => setForm((p) => ({ ...p, endDate: e.target.value }))}
|
<input
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
type="date"
|
||||||
required
|
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>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">السبب</label>
|
{/* السبب */}
|
||||||
<textarea
|
<textarea
|
||||||
value={form.reason}
|
placeholder="اكتب سبب الإجازة..."
|
||||||
onChange={(e) => setForm((p) => ({ ...p, reason: e.target.value }))}
|
value={form.reason}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
onChange={(e) => setForm(p => ({ ...p, reason: e.target.value }))}
|
||||||
rows={3}
|
className="w-full border p-2 rounded"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
{/* أزرار */}
|
||||||
<div className="flex justify-end gap-2">
|
<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
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg"
|
||||||
|
>
|
||||||
إلغاء
|
إلغاء
|
||||||
</button>
|
</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">
|
|
||||||
|
<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 ? 'جاري الإرسال...' : 'إرسال الطلب'}
|
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { toast } from 'react-hot-toast'
|
|||||||
import { Banknote, Plus, ChevronLeft } from 'lucide-react'
|
import { Banknote, Plus, ChevronLeft } from 'lucide-react'
|
||||||
|
|
||||||
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||||
PENDING: { label: 'قيد المراجعة', color: 'bg-amber-100 text-amber-800' },
|
PENDING_HR: { label: 'بانتظار موافقة الموارد البشرية', color: 'bg-amber-100 text-amber-800' },
|
||||||
APPROVED: { label: 'معتمد', color: 'bg-green-100 text-green-800' },
|
PENDING_ADMIN: { label: 'بانتظار موافقة مدير النظام', color: 'bg-orange-100 text-orange-800' },
|
||||||
REJECTED: { label: 'مرفوض', color: 'bg-red-100 text-red-800' },
|
REJECTED: { label: 'مرفوض', color: 'bg-red-100 text-red-800' },
|
||||||
ACTIVE: { label: 'نشط', color: 'bg-blue-100 text-blue-800' },
|
ACTIVE: { label: 'نشط', color: 'bg-blue-100 text-blue-800' },
|
||||||
PAID_OFF: { label: 'مسدد', color: 'bg-gray-100 text-gray-800' },
|
PAID_OFF: { label: 'مسدد', color: 'bg-gray-100 text-gray-800' },
|
||||||
@@ -32,17 +32,24 @@ export default function PortalLoansPage() {
|
|||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const amount = parseFloat(form.amount)
|
const amount = parseFloat(form.amount)
|
||||||
|
|
||||||
if (!amount || amount <= 0) {
|
if (!amount || amount <= 0) {
|
||||||
toast.error('أدخل مبلغاً صالحاً')
|
toast.error('أدخل مبلغاً صالحاً')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!form.reason.trim()) {
|
||||||
|
toast.error('سبب القرض مطلوب')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
portalAPI.submitLoanRequest({
|
portalAPI.submitLoanRequest({
|
||||||
type: form.type,
|
type: form.type,
|
||||||
amount,
|
amount,
|
||||||
installments: parseInt(form.installments) || 1,
|
installments: parseInt(form.installments) || 1,
|
||||||
reason: form.reason || undefined,
|
reason: form.reason.trim(),
|
||||||
})
|
})
|
||||||
.then((loan) => {
|
.then((loan) => {
|
||||||
setLoans((prev) => [loan, ...prev])
|
setLoans((prev) => [loan, ...prev])
|
||||||
setShowModal(false)
|
setShowModal(false)
|
||||||
@@ -162,6 +169,7 @@ export default function PortalLoansPage() {
|
|||||||
onChange={(e) => setForm((p) => ({ ...p, reason: e.target.value }))}
|
onChange={(e) => setForm((p) => ({ ...p, reason: e.target.value }))}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
rows={3}
|
rows={3}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
|
|||||||
191
frontend/src/app/portal/managed-leaves/page.tsx
Normal file
191
frontend/src/app/portal/managed-leaves/page.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { portalAPI, type ManagedLeave } from '@/lib/api/portal'
|
||||||
|
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||||
|
import { toast } from 'react-hot-toast'
|
||||||
|
import { CheckCircle2, XCircle, Calendar, User, ArrowLeft } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export default function ManagedLeavesPage() {
|
||||||
|
const { hasPermission } = useAuth()
|
||||||
|
const [leaves, setLeaves] = useState<ManagedLeave[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [processingId, setProcessingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const canViewDepartmentLeaveRequests = hasPermission('department_leave_requests', 'view')
|
||||||
|
const canApproveDepartmentLeaveRequests = hasPermission('department_leave_requests', 'approve')
|
||||||
|
|
||||||
|
const fetchLeaves = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const data = await portalAPI.getManagedLeaves('PENDING')
|
||||||
|
setLeaves(data)
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.response?.data?.message || 'فشل تحميل طلبات الإجازات')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (canViewDepartmentLeaveRequests) {
|
||||||
|
fetchLeaves()
|
||||||
|
} else {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [canViewDepartmentLeaveRequests])
|
||||||
|
|
||||||
|
const handleApprove = async (id: string) => {
|
||||||
|
try {
|
||||||
|
setProcessingId(id)
|
||||||
|
await portalAPI.approveManagedLeave(id)
|
||||||
|
toast.success('تمت الموافقة على طلب الإجازة')
|
||||||
|
fetchLeaves()
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.response?.data?.message || 'فشل اعتماد طلب الإجازة')
|
||||||
|
} finally {
|
||||||
|
setProcessingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReject = async (id: string) => {
|
||||||
|
const rejectedReason = window.prompt('اكتب سبب الرفض')
|
||||||
|
if (!rejectedReason || !rejectedReason.trim()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setProcessingId(id)
|
||||||
|
await portalAPI.rejectManagedLeave(id, rejectedReason.trim())
|
||||||
|
toast.success('تم رفض طلب الإجازة')
|
||||||
|
fetchLeaves()
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.response?.data?.message || 'فشل رفض طلب الإجازة')
|
||||||
|
} finally {
|
||||||
|
setProcessingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatLeaveType = (leaveType: string) => {
|
||||||
|
if (leaveType === 'ANNUAL') return 'إجازة سنوية'
|
||||||
|
if (leaveType === 'HOURLY') return 'إجازة ساعية'
|
||||||
|
return leaveType
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canViewDepartmentLeaveRequests) {
|
||||||
|
return <div className="text-center text-gray-500 py-12">الوصول مرفوض</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">طلبات إجازات القسم</h1>
|
||||||
|
<p className="text-gray-600 mt-1">اعتماد أو رفض طلبات موظفي القسم المباشرين</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/portal"
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
العودة
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow border border-gray-100 overflow-hidden">
|
||||||
|
{leaves.length === 0 ? (
|
||||||
|
<div className="p-12 text-center text-gray-500">
|
||||||
|
لا توجد طلبات إجازات معلقة لموظفي القسم
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 text-right font-semibold text-gray-700">الموظف</th>
|
||||||
|
<th className="px-6 py-4 text-right font-semibold text-gray-700">نوع الإجازة</th>
|
||||||
|
<th className="px-6 py-4 text-right font-semibold text-gray-700">الفترة</th>
|
||||||
|
<th className="px-6 py-4 text-right font-semibold text-gray-700">المدة</th>
|
||||||
|
<th className="px-6 py-4 text-right font-semibold text-gray-700">السبب</th>
|
||||||
|
<th className="px-6 py-4 text-right font-semibold text-gray-700">الإجراءات</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{leaves.map((leave) => (
|
||||||
|
<tr key={leave.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User className="h-4 w-4 text-gray-400" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">
|
||||||
|
{leave.employee.firstName} {leave.employee.lastName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">{leave.employee.uniqueEmployeeId}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-gray-900">
|
||||||
|
{formatLeaveType(leave.leaveType)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-gray-600">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4 text-gray-400" />
|
||||||
|
<div>
|
||||||
|
<p>{new Date(leave.startDate).toLocaleString()}</p>
|
||||||
|
<p>{new Date(leave.endDate).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-gray-900">
|
||||||
|
{leave.leaveType === 'HOURLY'
|
||||||
|
? `${new Date(leave.startDate).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${new Date(leave.endDate).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
|
||||||
|
: `${leave.days} يوم`}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-gray-600 max-w-xs">
|
||||||
|
<p className="truncate" title={leave.reason || ''}>
|
||||||
|
{leave.reason || '-'}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{canApproveDepartmentLeaveRequests ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleApprove(leave.id)}
|
||||||
|
disabled={processingId === leave.id}
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
قبول
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleReject(leave.id)}
|
||||||
|
disabled={processingId === leave.id}
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
رفض
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-500">عرض فقط</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
134
frontend/src/app/portal/managed-overtime-requests/page.tsx
Normal file
134
frontend/src/app/portal/managed-overtime-requests/page.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { portalAPI, type PortalOvertimeRequest } from '@/lib/api/portal'
|
||||||
|
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||||
|
import { toast } from 'react-hot-toast'
|
||||||
|
import { CheckCircle2, XCircle, User } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function ManagedOvertimeRequestsPage() {
|
||||||
|
const { hasPermission } = useAuth()
|
||||||
|
const [items, setItems] = useState<PortalOvertimeRequest[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [processingId, setProcessingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const canView = hasPermission('department_overtime_requests', 'view')
|
||||||
|
const canApprove = hasPermission('department_overtime_requests', 'approve')
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const data = await portalAPI.getManagedOvertimeRequests()
|
||||||
|
setItems(data)
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.response?.data?.message || 'فشل تحميل الطلبات')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (canView) {
|
||||||
|
loadData()
|
||||||
|
} else {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [canView])
|
||||||
|
|
||||||
|
const handleApprove = async (attendanceId: string) => {
|
||||||
|
try {
|
||||||
|
setProcessingId(attendanceId)
|
||||||
|
await portalAPI.approveManagedOvertimeRequest(attendanceId)
|
||||||
|
toast.success('تمت الموافقة')
|
||||||
|
loadData()
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.response?.data?.message || 'فشل التنفيذ')
|
||||||
|
} finally {
|
||||||
|
setProcessingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReject = async (attendanceId: string) => {
|
||||||
|
const rejectedReason = window.prompt('اكتب سبب الرفض')
|
||||||
|
if (!rejectedReason || !rejectedReason.trim()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setProcessingId(attendanceId)
|
||||||
|
await portalAPI.rejectManagedOvertimeRequest(attendanceId, rejectedReason.trim())
|
||||||
|
toast.success('تم الرفض')
|
||||||
|
loadData()
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.response?.data?.message || 'فشل التنفيذ')
|
||||||
|
} finally {
|
||||||
|
setProcessingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canView) {
|
||||||
|
return <div className="text-center py-12 text-gray-500">الوصول مرفوض</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">طلبات الساعات الإضافية</h1>
|
||||||
|
<p className="text-gray-600 mt-1">طلبات موظفي القسم المباشرين</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow border border-gray-100 overflow-hidden">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="p-10 text-center text-gray-500">لا توجد طلبات معلقة</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-200">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className="p-6 flex items-start justify-between gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User className="h-4 w-4 text-gray-400" />
|
||||||
|
<p className="font-semibold text-gray-900">
|
||||||
|
{item.employee?.firstName} {item.employee?.lastName}
|
||||||
|
</p>
|
||||||
|
<span className="text-xs text-gray-500">{item.employee?.uniqueEmployeeId}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
التاريخ: {new Date(item.date).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600">عدد الساعات: {item.hours}</p>
|
||||||
|
<p className="text-sm text-gray-600">السبب: {item.reason}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canApprove ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleApprove(item.attendanceId)}
|
||||||
|
disabled={processingId === item.attendanceId}
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
قبول
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleReject(item.attendanceId)}
|
||||||
|
disabled={processingId === item.attendanceId}
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-2 bg-red-600 text-white rounded-lg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
رفض
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-500">عرض فقط</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
191
frontend/src/app/portal/overtime/page.tsx
Normal file
191
frontend/src/app/portal/overtime/page.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { portalAPI, type PortalOvertimeRequest } from '@/lib/api/portal'
|
||||||
|
import Modal from '@/components/Modal'
|
||||||
|
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||||
|
import { toast } from 'react-hot-toast'
|
||||||
|
import { Plus, Clock3 } from 'lucide-react'
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||||
|
PENDING: { label: 'قيد المراجعة', color: 'bg-amber-100 text-amber-800' },
|
||||||
|
APPROVED: { label: 'مقبول', color: 'bg-green-100 text-green-800' },
|
||||||
|
REJECTED: { label: 'مرفوض', color: 'bg-red-100 text-red-800' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PortalOvertimePage() {
|
||||||
|
const [items, setItems] = useState<PortalOvertimeRequest[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
date: '',
|
||||||
|
hours: '',
|
||||||
|
reason: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const data = await portalAPI.getOvertimeRequests()
|
||||||
|
setItems(data)
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.response?.data?.message || 'فشل تحميل الطلبات')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const hours = parseFloat(form.hours)
|
||||||
|
|
||||||
|
if (!form.date) {
|
||||||
|
toast.error('اختر التاريخ')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hours || hours <= 0) {
|
||||||
|
toast.error('أدخل عدد ساعات صالح')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.reason.trim()) {
|
||||||
|
toast.error('سبب الساعات الإضافية مطلوب')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true)
|
||||||
|
await portalAPI.submitOvertimeRequest({
|
||||||
|
date: form.date,
|
||||||
|
hours,
|
||||||
|
reason: form.reason.trim(),
|
||||||
|
})
|
||||||
|
toast.success('تم إرسال الطلب')
|
||||||
|
setOpen(false)
|
||||||
|
setForm({ date: '', hours: '', reason: '' })
|
||||||
|
loadData()
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.response?.data?.message || 'فشل إرسال الطلب')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">الساعات الإضافية</h1>
|
||||||
|
<p className="text-gray-600 mt-1">إرسال ومتابعة طلبات الساعات الإضافية</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
إضافة طلب
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow border border-gray-100 overflow-hidden">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="p-10 text-center text-gray-500">لا توجد طلبات حتى الآن</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-200">
|
||||||
|
{items.map((item) => {
|
||||||
|
const meta = STATUS_MAP[item.status] || STATUS_MAP.PENDING
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="p-6 flex items-start justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock3 className="h-4 w-4 text-gray-500" />
|
||||||
|
<p className="font-semibold text-gray-900">
|
||||||
|
{new Date(item.date).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600">عدد الساعات: {item.hours}</p>
|
||||||
|
<p className="text-sm text-gray-600">السبب: {item.reason}</p>
|
||||||
|
{item.rejectedReason ? (
|
||||||
|
<p className="text-sm text-red-600">سبب الرفض: {item.rejectedReason}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className={`px-3 py-1 rounded-full text-xs font-medium ${meta.color}`}>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal isOpen={open} onClose={() => setOpen(false)} title="طلب ساعات إضافية">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">التاريخ</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.date}
|
||||||
|
onChange={(e) => setForm((p) => ({ ...p, date: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">عدد الساعات</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.5"
|
||||||
|
step="0.5"
|
||||||
|
value={form.hours}
|
||||||
|
onChange={(e) => setForm((p) => ({ ...p, hours: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">السبب</label>
|
||||||
|
<textarea
|
||||||
|
value={form.reason}
|
||||||
|
onChange={(e) => setForm((p) => ({ ...p, reason: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
rows={4}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded-lg"
|
||||||
|
>
|
||||||
|
إلغاء
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? 'جارٍ الإرسال...' : 'إرسال'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
import { portalAPI, type PortalProfile } from '@/lib/api/portal'
|
import { portalAPI, type PortalProfile } from '@/lib/api/portal'
|
||||||
import LoadingSpinner from '@/components/LoadingSpinner'
|
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||||
import { Banknote, Calendar, ShoppingCart, Clock, Plus, ArrowLeft } from 'lucide-react'
|
import { Banknote, Calendar, ShoppingCart, Clock, Plus, ArrowLeft, CheckCircle2 } from 'lucide-react'
|
||||||
import { toast } from 'react-hot-toast'
|
import { toast } from 'react-hot-toast'
|
||||||
|
|
||||||
export default function PortalDashboardPage() {
|
export default function PortalDashboardPage() {
|
||||||
|
const { hasPermission } = useAuth()
|
||||||
const [data, setData] = useState<PortalProfile | null>(null)
|
const [data, setData] = useState<PortalProfile | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
@@ -22,6 +24,8 @@ export default function PortalDashboardPage() {
|
|||||||
if (!data) return <div className="text-center text-gray-500 py-12">لا توجد بيانات</div>
|
if (!data) return <div className="text-center text-gray-500 py-12">لا توجد بيانات</div>
|
||||||
|
|
||||||
const { employee, stats } = data
|
const { employee, stats } = data
|
||||||
|
const canViewDepartmentLeaveRequests = hasPermission('department_leave_requests', 'view')
|
||||||
|
|
||||||
const name = employee.firstNameAr && employee.lastNameAr
|
const name = employee.firstNameAr && employee.lastNameAr
|
||||||
? `${employee.firstNameAr} ${employee.lastNameAr}`
|
? `${employee.firstNameAr} ${employee.lastNameAr}`
|
||||||
: `${employee.firstName} ${employee.lastName}`
|
: `${employee.firstName} ${employee.lastName}`
|
||||||
@@ -35,7 +39,7 @@ export default function PortalDashboardPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<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="bg-white rounded-xl shadow p-6 border border-gray-100">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -53,6 +57,23 @@ export default function PortalDashboardPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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="bg-white rounded-xl shadow p-6 border border-gray-100">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -118,7 +139,9 @@ export default function PortalDashboardPage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{stats.leaveBalance.map((b) => (
|
{stats.leaveBalance.map((b) => (
|
||||||
<tr key={b.leaveType} className="border-b last:border-0">
|
<tr key={b.leaveType} className="border-b last:border-0">
|
||||||
<td className="py-2">{b.leaveType === 'ANNUAL' ? 'سنوية' : b.leaveType === 'SICK' ? 'مرضية' : b.leaveType}</td>
|
<td className="py-2">
|
||||||
|
{b.leaveType === 'ANNUAL' ? 'سنوية' : b.leaveType === 'HOURLY' ? 'ساعية' : b.leaveType}
|
||||||
|
</td>
|
||||||
<td className="py-2">{b.totalDays + b.carriedOver}</td>
|
<td className="py-2">{b.totalDays + b.carriedOver}</td>
|
||||||
<td className="py-2">{b.usedDays}</td>
|
<td className="py-2">{b.usedDays}</td>
|
||||||
<td className="py-2 font-semibold text-teal-600">{b.available}</td>
|
<td className="py-2 font-semibold text-teal-600">{b.available}</td>
|
||||||
@@ -131,24 +154,24 @@ export default function PortalDashboardPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap gap-4">
|
||||||
<Link
|
<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">
|
||||||
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" />
|
<Plus className="h-4 w-4" />
|
||||||
طلب قرض
|
طلب قرض
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
|
||||||
href="/portal/leave"
|
<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">
|
||||||
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" />
|
<Plus className="h-4 w-4" />
|
||||||
طلب إجازة
|
طلب إجازة
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
|
||||||
href="/portal/purchase-requests"
|
{canViewDepartmentLeaveRequests && (
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
<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" />
|
<Plus className="h-4 w-4" />
|
||||||
طلب شراء
|
طلب شراء
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -10,14 +10,13 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
Building2,
|
Building2,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
User,
|
|
||||||
History,
|
History,
|
||||||
Plus,
|
Plus,
|
||||||
Loader2,
|
Loader2,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Upload,
|
Upload,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
AlertCircle,
|
MapPin,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||||
import LoadingSpinner from '@/components/LoadingSpinner'
|
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||||
@@ -51,8 +50,16 @@ function TenderDetailContent() {
|
|||||||
const [employees, setEmployees] = useState<any[]>([])
|
const [employees, setEmployees] = useState<any[]>([])
|
||||||
const [contacts, setContacts] = useState<any[]>([])
|
const [contacts, setContacts] = useState<any[]>([])
|
||||||
const [pipelines, setPipelines] = useState<any[]>([])
|
const [pipelines, setPipelines] = useState<any[]>([])
|
||||||
const [directiveForm, setDirectiveForm] = useState<CreateDirectiveData>({ type: 'BUY_TERMS', assignedToEmployeeId: '', notes: '' })
|
const [directiveForm, setDirectiveForm] = useState<CreateDirectiveData>({
|
||||||
const [convertForm, setConvertForm] = useState({ contactId: '', pipelineId: '', ownerId: '' })
|
type: 'BUY_TERMS',
|
||||||
|
assignedToEmployeeId: '',
|
||||||
|
notes: '',
|
||||||
|
})
|
||||||
|
const [convertForm, setConvertForm] = useState({
|
||||||
|
contactId: '',
|
||||||
|
pipelineId: '',
|
||||||
|
ownerId: '',
|
||||||
|
})
|
||||||
const [completeNotes, setCompleteNotes] = useState('')
|
const [completeNotes, setCompleteNotes] = useState('')
|
||||||
const [directiveTypeValues, setDirectiveTypeValues] = useState<string[]>([])
|
const [directiveTypeValues, setDirectiveTypeValues] = useState<string[]>([])
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
@@ -93,10 +100,17 @@ function TenderDetailContent() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showDirectiveModal || showConvertModal) {
|
if (showDirectiveModal || showConvertModal) {
|
||||||
employeesAPI.getAll({ status: 'ACTIVE', pageSize: 500 }).then((r: any) => setEmployees(r.employees || [])).catch(() => {})
|
employeesAPI
|
||||||
|
.getAll({ status: 'ACTIVE', pageSize: 500 })
|
||||||
|
.then((r: any) => setEmployees(r.employees || []))
|
||||||
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showConvertModal) {
|
if (showConvertModal) {
|
||||||
contactsAPI.getAll({ pageSize: 500 }).then((r: any) => setContacts(r.contacts || [])).catch(() => {})
|
contactsAPI
|
||||||
|
.getAll({ pageSize: 500 })
|
||||||
|
.then((r: any) => setContacts(r.contacts || []))
|
||||||
|
.catch(() => {})
|
||||||
pipelinesAPI.getAll().then(setPipelines).catch(() => {})
|
pipelinesAPI.getAll().then(setPipelines).catch(() => {})
|
||||||
}
|
}
|
||||||
}, [showDirectiveModal, showConvertModal])
|
}, [showDirectiveModal, showConvertModal])
|
||||||
@@ -107,6 +121,7 @@ function TenderDetailContent() {
|
|||||||
toast.error(t('tenders.assignee') + ' ' + t('common.required'))
|
toast.error(t('tenders.assignee') + ' ' + t('common.required'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await tendersAPI.createDirective(tenderId, directiveForm)
|
await tendersAPI.createDirective(tenderId, directiveForm)
|
||||||
@@ -124,9 +139,13 @@ function TenderDetailContent() {
|
|||||||
const handleCompleteDirective = async (e: React.FormEvent) => {
|
const handleCompleteDirective = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!showCompleteModal) return
|
if (!showCompleteModal) return
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await tendersAPI.updateDirective(showCompleteModal.id, { status: 'COMPLETED', completionNotes: completeNotes })
|
await tendersAPI.updateDirective(showCompleteModal.id, {
|
||||||
|
status: 'COMPLETED',
|
||||||
|
completionNotes: completeNotes,
|
||||||
|
})
|
||||||
toast.success('Task completed')
|
toast.success('Task completed')
|
||||||
setShowCompleteModal(null)
|
setShowCompleteModal(null)
|
||||||
setCompleteNotes('')
|
setCompleteNotes('')
|
||||||
@@ -144,6 +163,7 @@ function TenderDetailContent() {
|
|||||||
toast.error('Contact and Pipeline are required')
|
toast.error('Contact and Pipeline are required')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const deal = await tendersAPI.convertToDeal(tenderId, convertForm)
|
const deal = await tendersAPI.convertToDeal(tenderId, convertForm)
|
||||||
@@ -160,6 +180,7 @@ function TenderDetailContent() {
|
|||||||
const handleTenderFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleTenderFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await tendersAPI.uploadTenderAttachment(tenderId, file)
|
await tendersAPI.uploadTenderAttachment(tenderId, file)
|
||||||
@@ -183,7 +204,9 @@ function TenderDetailContent() {
|
|||||||
const directiveId = directiveIdForUpload
|
const directiveId = directiveIdForUpload
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
setDirectiveIdForUpload(null)
|
setDirectiveIdForUpload(null)
|
||||||
|
|
||||||
if (!file || !directiveId) return
|
if (!file || !directiveId) return
|
||||||
|
|
||||||
setUploadingDirectiveId(directiveId)
|
setUploadingDirectiveId(directiveId)
|
||||||
try {
|
try {
|
||||||
await tendersAPI.uploadDirectiveAttachment(directiveId, file)
|
await tendersAPI.uploadDirectiveAttachment(directiveId, file)
|
||||||
@@ -220,10 +243,13 @@ function TenderDetailContent() {
|
|||||||
<ArrowLeft className="h-5 w-5" />
|
<ArrowLeft className="h-5 w-5" />
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">{tender.tenderNumber} – {tender.title}</h1>
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
{tender.tenderNumber} – {tender.title}
|
||||||
|
</h1>
|
||||||
<p className="text-sm text-gray-600">{tender.issuingBodyName}</p>
|
<p className="text-sm text-gray-600">{tender.issuingBodyName}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tender.status === 'ACTIVE' && (
|
{tender.status === 'ACTIVE' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowConvertModal(true)}
|
onClick={() => setShowConvertModal(true)}
|
||||||
@@ -242,7 +268,9 @@ function TenderDetailContent() {
|
|||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id as any)}
|
onClick={() => setActiveTab(tab.id as any)}
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium ${
|
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'
|
activeTab === tab.id
|
||||||
|
? 'bg-indigo-100 text-indigo-800'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<tab.icon className="h-4 w-4" />
|
<tab.icon className="h-4 w-4" />
|
||||||
@@ -262,6 +290,7 @@ function TenderDetailContent() {
|
|||||||
<p>{tender.announcementDate?.split('T')[0]}</p>
|
<p>{tender.announcementDate?.split('T')[0]}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<Calendar className="h-5 w-5 text-gray-400 mt-0.5" />
|
<Calendar className="h-5 w-5 text-gray-400 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
@@ -269,6 +298,7 @@ function TenderDetailContent() {
|
|||||||
<p>{tender.closingDate?.split('T')[0]}</p>
|
<p>{tender.closingDate?.split('T')[0]}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<DollarSign className="h-5 w-5 text-gray-400 mt-0.5" />
|
<DollarSign className="h-5 w-5 text-gray-400 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
@@ -276,21 +306,71 @@ function TenderDetailContent() {
|
|||||||
<p>{Number(tender.termsValue)} SAR</p>
|
<p>{Number(tender.termsValue)} SAR</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<DollarSign className="h-5 w-5 text-gray-400 mt-0.5" />
|
<DollarSign className="h-5 w-5 text-gray-400 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500">{t('tenders.bondValue')}</p>
|
<p className="text-xs text-gray-500">التأمينات الأولية</p>
|
||||||
<p>{Number(tender.bondValue)} SAR</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tender.announcementLink && (
|
{tender.announcementLink && (
|
||||||
<p>
|
<p>
|
||||||
<a href={tender.announcementLink} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:underline">
|
<a
|
||||||
|
href={tender.announcementLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-indigo-600 hover:underline"
|
||||||
|
>
|
||||||
{t('tenders.announcementLink')}
|
{t('tenders.announcementLink')}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tender.notes && (
|
{tender.notes && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500">{t('common.notes')}</p>
|
<p className="text-xs text-gray-500">{t('common.notes')}</p>
|
||||||
@@ -312,19 +392,29 @@ function TenderDetailContent() {
|
|||||||
{t('tenders.addDirective')}
|
{t('tenders.addDirective')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!tender.directives?.length ? (
|
{!tender.directives?.length ? (
|
||||||
<p className="text-gray-500">{t('common.noData')}</p>
|
<p className="text-gray-500">{t('common.noData')}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{tender.directives.map((d) => (
|
{tender.directives.map((d) => (
|
||||||
<li key={d.id} className="border rounded-lg p-4 flex flex-wrap items-center justify-between gap-2">
|
<li
|
||||||
|
key={d.id}
|
||||||
|
className="border rounded-lg p-4 flex flex-wrap items-center justify-between gap-2"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{DIRECTIVE_TYPE_LABELS[d.type] || d.type}</p>
|
<p className="font-medium">{DIRECTIVE_TYPE_LABELS[d.type] || d.type}</p>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
{d.assignedToEmployee ? `${d.assignedToEmployee.firstName} ${d.assignedToEmployee.lastName}` : ''} · {d.status}
|
{d.assignedToEmployee
|
||||||
|
? `${d.assignedToEmployee.firstName} ${d.assignedToEmployee.lastName}`
|
||||||
|
: ''}{' '}
|
||||||
|
· {d.status}
|
||||||
</p>
|
</p>
|
||||||
{d.completionNotes && <p className="text-sm mt-1">{d.completionNotes}</p>}
|
{d.completionNotes && (
|
||||||
|
<p className="text-sm mt-1">{d.completionNotes}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{d.status !== 'COMPLETED' && d.assignedToEmployee?.user?.id && (
|
{d.status !== 'COMPLETED' && d.assignedToEmployee?.user?.id && (
|
||||||
<button
|
<button
|
||||||
@@ -334,19 +424,25 @@ function TenderDetailContent() {
|
|||||||
{t('tenders.completeTask')}
|
{t('tenders.completeTask')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={directiveFileInputRef}
|
ref={directiveFileInputRef}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleDirectiveFileUpload}
|
onChange={handleDirectiveFileUpload}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDirectiveFileSelect(d.id)}
|
onClick={() => handleDirectiveFileSelect(d.id)}
|
||||||
disabled={uploadingDirectiveId === d.id}
|
disabled={uploadingDirectiveId === d.id}
|
||||||
className="text-sm text-indigo-600 hover:underline flex items-center gap-1"
|
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" />}
|
{uploadingDirectiveId === d.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
)}
|
||||||
{t('tenders.uploadFile')}
|
{t('tenders.uploadFile')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -371,17 +467,49 @@ function TenderDetailContent() {
|
|||||||
disabled={submitting}
|
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"
|
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" />}
|
{submitting ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
)}
|
||||||
{t('tenders.uploadFile')}
|
{t('tenders.uploadFile')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!tender.attachments?.length ? (
|
{!tender.attachments?.length ? (
|
||||||
<p className="text-gray-500">{t('common.noData')}</p>
|
<p className="text-gray-500">{t('common.noData')}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{tender.attachments.map((a: any) => (
|
{tender.attachments.map((a: any) => (
|
||||||
<li key={a.id} className="text-sm text-gray-700">
|
<li
|
||||||
{a.originalName || a.fileName}
|
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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -397,7 +525,8 @@ function TenderDetailContent() {
|
|||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{history.map((h: any) => (
|
{history.map((h: any) => (
|
||||||
<li key={h.id} className="text-sm border-b border-gray-100 pb-2">
|
<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]}
|
<span className="font-medium">{h.action}</span> · {h.user?.username} ·{' '}
|
||||||
|
{h.createdAt?.split('T')[0]}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -408,36 +537,54 @@ function TenderDetailContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal isOpen={showDirectiveModal} onClose={() => setShowDirectiveModal(false)} title={t('tenders.addDirective')}>
|
<Modal
|
||||||
|
isOpen={showDirectiveModal}
|
||||||
|
onClose={() => setShowDirectiveModal(false)}
|
||||||
|
title={t('tenders.addDirective')}
|
||||||
|
>
|
||||||
<form onSubmit={handleAddDirective} className="space-y-4">
|
<form onSubmit={handleAddDirective} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.directiveType')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('tenders.directiveType')}
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={directiveForm.type}
|
value={directiveForm.type}
|
||||||
onChange={(e) => setDirectiveForm({ ...directiveForm, type: e.target.value })}
|
onChange={(e) => setDirectiveForm({ ...directiveForm, type: e.target.value })}
|
||||||
className="w-full px-3 py-2 border rounded-lg"
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
>
|
>
|
||||||
{directiveTypeValues.map((v) => (
|
{directiveTypeValues.map((v) => (
|
||||||
<option key={v} value={v}>{DIRECTIVE_TYPE_LABELS[v] || v}</option>
|
<option key={v} value={v}>
|
||||||
|
{DIRECTIVE_TYPE_LABELS[v] || v}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.assignee')} *</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('tenders.assignee')} *
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={directiveForm.assignedToEmployeeId}
|
value={directiveForm.assignedToEmployeeId}
|
||||||
onChange={(e) => setDirectiveForm({ ...directiveForm, assignedToEmployeeId: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setDirectiveForm({ ...directiveForm, assignedToEmployeeId: e.target.value })
|
||||||
|
}
|
||||||
className="w-full px-3 py-2 border rounded-lg"
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">Select employee</option>
|
<option value="">Select employee</option>
|
||||||
{employees.map((emp) => (
|
{employees.map((emp) => (
|
||||||
<option key={emp.id} value={emp.id}>{emp.firstName} {emp.lastName}</option>
|
<option key={emp.id} value={emp.id}>
|
||||||
|
{emp.firstName} {emp.lastName}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('common.notes')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('common.notes')}
|
||||||
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={directiveForm.notes || ''}
|
value={directiveForm.notes || ''}
|
||||||
onChange={(e) => setDirectiveForm({ ...directiveForm, notes: e.target.value })}
|
onChange={(e) => setDirectiveForm({ ...directiveForm, notes: e.target.value })}
|
||||||
@@ -445,9 +592,20 @@ function TenderDetailContent() {
|
|||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<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
|
||||||
<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">
|
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" />}
|
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</button>
|
</button>
|
||||||
@@ -455,10 +613,16 @@ function TenderDetailContent() {
|
|||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal isOpen={!!showCompleteModal} onClose={() => setShowCompleteModal(null)} title={t('tenders.completeTask')}>
|
<Modal
|
||||||
|
isOpen={!!showCompleteModal}
|
||||||
|
onClose={() => setShowCompleteModal(null)}
|
||||||
|
title={t('tenders.completeTask')}
|
||||||
|
>
|
||||||
<form onSubmit={handleCompleteDirective} className="space-y-4">
|
<form onSubmit={handleCompleteDirective} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.completionNotes')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('tenders.completionNotes')}
|
||||||
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={completeNotes}
|
value={completeNotes}
|
||||||
onChange={(e) => setCompleteNotes(e.target.value)}
|
onChange={(e) => setCompleteNotes(e.target.value)}
|
||||||
@@ -466,9 +630,20 @@ function TenderDetailContent() {
|
|||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<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
|
||||||
<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">
|
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" />}
|
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</button>
|
</button>
|
||||||
@@ -476,7 +651,11 @@ function TenderDetailContent() {
|
|||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal isOpen={showConvertModal} onClose={() => setShowConvertModal(false)} title={t('tenders.convertToDeal')}>
|
<Modal
|
||||||
|
isOpen={showConvertModal}
|
||||||
|
onClose={() => setShowConvertModal(false)}
|
||||||
|
title={t('tenders.convertToDeal')}
|
||||||
|
>
|
||||||
<form onSubmit={handleConvertToDeal} className="space-y-4">
|
<form onSubmit={handleConvertToDeal} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Contact *</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Contact *</label>
|
||||||
@@ -488,10 +667,13 @@ function TenderDetailContent() {
|
|||||||
>
|
>
|
||||||
<option value="">Select contact</option>
|
<option value="">Select contact</option>
|
||||||
{contacts.map((c) => (
|
{contacts.map((c) => (
|
||||||
<option key={c.id} value={c.id}>{c.name}</option>
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Pipeline *</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Pipeline *</label>
|
||||||
<select
|
<select
|
||||||
@@ -502,13 +684,26 @@ function TenderDetailContent() {
|
|||||||
>
|
>
|
||||||
<option value="">Select pipeline</option>
|
<option value="">Select pipeline</option>
|
||||||
{pipelines.map((p) => (
|
{pipelines.map((p) => (
|
||||||
<option key={p.id} value={p.id}>{p.name}</option>
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<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
|
||||||
<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">
|
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" />}
|
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
{t('tenders.convertToDeal')}
|
{t('tenders.convertToDeal')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
Calendar,
|
|
||||||
Building2,
|
|
||||||
DollarSign,
|
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -32,6 +29,23 @@ const SOURCE_LABELS: Record<string, string> = {
|
|||||||
MANUAL: 'Manual entry',
|
MANUAL: 'Manual entry',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SYRIA_PROVINCES = [
|
||||||
|
'دمشق',
|
||||||
|
'ريف دمشق',
|
||||||
|
'حلب',
|
||||||
|
'حمص',
|
||||||
|
'حماة',
|
||||||
|
'اللاذقية',
|
||||||
|
'طرطوس',
|
||||||
|
'إدلب',
|
||||||
|
'درعا',
|
||||||
|
'السويداء',
|
||||||
|
'القنيطرة',
|
||||||
|
'دير الزور',
|
||||||
|
'الرقة',
|
||||||
|
'الحسكة',
|
||||||
|
]
|
||||||
|
|
||||||
const ANNOUNCEMENT_LABELS: Record<string, string> = {
|
const ANNOUNCEMENT_LABELS: Record<string, string> = {
|
||||||
FIRST: 'First announcement',
|
FIRST: 'First announcement',
|
||||||
RE_ANNOUNCEMENT_2: 'Re-announcement 2nd',
|
RE_ANNOUNCEMENT_2: 'Re-announcement 2nd',
|
||||||
@@ -39,6 +53,27 @@ const ANNOUNCEMENT_LABELS: Record<string, string> = {
|
|||||||
RE_ANNOUNCEMENT_4: 'Re-announcement 4th',
|
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() {
|
function TendersContent() {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const [tenders, setTenders] = useState<Tender[]>([])
|
const [tenders, setTenders] = useState<Tender[]>([])
|
||||||
@@ -50,17 +85,8 @@ function TendersContent() {
|
|||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const [selectedStatus, setSelectedStatus] = useState('all')
|
const [selectedStatus, setSelectedStatus] = useState('all')
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
const [formData, setFormData] = useState<CreateTenderData>({
|
|
||||||
tenderNumber: '',
|
const [formData, setFormData] = useState<CreateTenderData>(getInitialFormData())
|
||||||
issuingBodyName: '',
|
|
||||||
title: '',
|
|
||||||
termsValue: 0,
|
|
||||||
bondValue: 0,
|
|
||||||
announcementDate: '',
|
|
||||||
closingDate: '',
|
|
||||||
source: 'MANUAL',
|
|
||||||
announcementType: 'FIRST',
|
|
||||||
})
|
|
||||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
|
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [possibleDuplicates, setPossibleDuplicates] = useState<Tender[]>([])
|
const [possibleDuplicates, setPossibleDuplicates] = useState<Tender[]>([])
|
||||||
@@ -68,12 +94,20 @@ function TendersContent() {
|
|||||||
const [sourceValues, setSourceValues] = useState<string[]>([])
|
const [sourceValues, setSourceValues] = useState<string[]>([])
|
||||||
const [announcementTypeValues, setAnnouncementTypeValues] = useState<string[]>([])
|
const [announcementTypeValues, setAnnouncementTypeValues] = useState<string[]>([])
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData(getInitialFormData())
|
||||||
|
setFormErrors({})
|
||||||
|
setPossibleDuplicates([])
|
||||||
|
setShowDuplicateWarning(false)
|
||||||
|
}
|
||||||
|
|
||||||
const fetchTenders = useCallback(async () => {
|
const fetchTenders = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const filters: TenderFilters = { page: currentPage, pageSize }
|
const filters: TenderFilters = { page: currentPage, pageSize }
|
||||||
if (searchTerm) filters.search = searchTerm
|
if (searchTerm) filters.search = searchTerm
|
||||||
if (selectedStatus !== 'all') filters.status = selectedStatus
|
if (selectedStatus !== 'all') filters.status = selectedStatus
|
||||||
|
|
||||||
const data = await tendersAPI.getAll(filters)
|
const data = await tendersAPI.getAll(filters)
|
||||||
setTenders(data.tenders)
|
setTenders(data.tenders)
|
||||||
setTotal(data.total)
|
setTotal(data.total)
|
||||||
@@ -96,24 +130,39 @@ function TendersContent() {
|
|||||||
|
|
||||||
const handleCreate = async (e: React.FormEvent) => {
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
const errors: Record<string, string> = {}
|
const errors: Record<string, string> = {}
|
||||||
|
|
||||||
if (!formData.tenderNumber?.trim()) errors.tenderNumber = t('common.required')
|
if (!formData.tenderNumber?.trim()) errors.tenderNumber = t('common.required')
|
||||||
if (!formData.issuingBodyName?.trim()) errors.issuingBodyName = t('common.required')
|
if (!formData.issuingBodyName?.trim()) errors.issuingBodyName = t('common.required')
|
||||||
if (!formData.title?.trim()) errors.title = t('common.required')
|
if (!formData.title?.trim()) errors.title = t('common.required')
|
||||||
if (!formData.announcementDate) errors.announcementDate = t('common.required')
|
if (!formData.announcementDate) errors.announcementDate = t('common.required')
|
||||||
if (!formData.closingDate) errors.closingDate = t('common.required')
|
if (!formData.closingDate) errors.closingDate = t('common.required')
|
||||||
//if (Number(formData.termsValue) < 0) errors.termsValue = t('common.required')
|
|
||||||
if (Number(formData.bondValue) < 0) errors.bondValue = 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)
|
setFormErrors(errors)
|
||||||
if (Object.keys(errors).length > 0) return
|
if (Object.keys(errors).length > 0) return
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const result = await tendersAPI.create(formData)
|
const result = await tendersAPI.create({
|
||||||
|
...formData,
|
||||||
|
bondValue: Number(formData.initialBondValue ?? formData.bondValue ?? 0),
|
||||||
|
})
|
||||||
|
|
||||||
if (result.possibleDuplicates && result.possibleDuplicates.length > 0) {
|
if (result.possibleDuplicates && result.possibleDuplicates.length > 0) {
|
||||||
setPossibleDuplicates(result.possibleDuplicates)
|
setPossibleDuplicates(result.possibleDuplicates)
|
||||||
setShowDuplicateWarning(true)
|
setShowDuplicateWarning(true)
|
||||||
toast(t('tenders.duplicateWarning') || 'Possible duplicates found. Please review.', { icon: '⚠️' })
|
toast(t('tenders.duplicateWarning') || 'Possible duplicates found. Please review.', {
|
||||||
|
icon: '⚠️',
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
toast.success(t('tenders.createSuccess') || 'Tender created successfully')
|
toast.success(t('tenders.createSuccess') || 'Tender created successfully')
|
||||||
setShowCreateModal(false)
|
setShowCreateModal(false)
|
||||||
@@ -127,23 +176,6 @@ function TendersContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setFormData({
|
|
||||||
tenderNumber: '',
|
|
||||||
issuingBodyName: '',
|
|
||||||
title: '',
|
|
||||||
termsValue: 0,
|
|
||||||
bondValue: 0,
|
|
||||||
announcementDate: '',
|
|
||||||
closingDate: '',
|
|
||||||
source: 'MANUAL',
|
|
||||||
announcementType: 'FIRST',
|
|
||||||
})
|
|
||||||
setFormErrors({})
|
|
||||||
setPossibleDuplicates([])
|
|
||||||
setShowDuplicateWarning(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
@@ -155,16 +187,25 @@ function TendersContent() {
|
|||||||
>
|
>
|
||||||
<ArrowLeft className="h-5 w-5" />
|
<ArrowLeft className="h-5 w-5" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileText className="h-8 w-8 text-indigo-600" />
|
<FileText className="h-8 w-8 text-indigo-600" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">{t('nav.tenders') || 'Tenders'}</h1>
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
<p className="text-sm text-gray-600">{t('tenders.subtitle') || 'Tender Management'}</p>
|
{t('nav.tenders') || 'Tenders'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{t('tenders.subtitle') || 'Tender Management'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowCreateModal(true); resetForm(); }}
|
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"
|
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" />
|
<Plus className="h-5 w-5" />
|
||||||
@@ -184,6 +225,7 @@ function TendersContent() {
|
|||||||
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"
|
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>
|
</div>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
value={selectedStatus}
|
value={selectedStatus}
|
||||||
onChange={(e) => setSelectedStatus(e.target.value)}
|
onChange={(e) => setSelectedStatus(e.target.value)}
|
||||||
@@ -209,26 +251,52 @@ function TendersContent() {
|
|||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<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">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('tenders.title') || 'Title'}</th>
|
{t('tenders.tenderNumber') || 'Number'}
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('tenders.issuingBody') || 'Issuing body'}</th>
|
</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">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('common.status')}</th>
|
{t('tenders.title') || 'Title'}
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">{t('common.actions')}</th>
|
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{tenders.map((tender) => (
|
{tenders.map((tender) => (
|
||||||
<tr key={tender.id} className="hover:bg-gray-50">
|
<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 font-medium text-gray-900">
|
||||||
<td className="px-4 py-3 text-sm text-gray-900">{tender.title}</td>
|
{tender.tenderNumber}
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">{tender.issuingBodyName}</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">{tender.closingDate?.split('T')[0]}</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">
|
<td className="px-4 py-3">
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
<span
|
||||||
tender.status === 'ACTIVE' ? 'bg-green-100 text-green-800' :
|
className={`px-2 py-1 text-xs rounded-full ${
|
||||||
tender.status === 'CONVERTED_TO_DEAL' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'
|
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}
|
{tender.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -251,7 +319,8 @@ function TendersContent() {
|
|||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="px-4 py-3 border-t border-gray-200 flex items-center justify-between">
|
<div className="px-4 py-3 border-t border-gray-200 flex items-center justify-between">
|
||||||
<p className="text-sm text-gray-600">
|
<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}
|
{t('common.showing') || 'Showing'} {(currentPage - 1) * pageSize + 1}–
|
||||||
|
{Math.min(currentPage * pageSize, total)} {t('common.of') || 'of'} {total}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -276,113 +345,259 @@ function TendersContent() {
|
|||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showCreateModal}
|
isOpen={showCreateModal}
|
||||||
onClose={() => { setShowCreateModal(false); setShowDuplicateWarning(false); resetForm(); }}
|
onClose={() => {
|
||||||
|
setShowCreateModal(false)
|
||||||
|
resetForm()
|
||||||
|
}}
|
||||||
title={t('tenders.addTender') || 'Add Tender'}
|
title={t('tenders.addTender') || 'Add Tender'}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleCreate} className="space-y-4">
|
<form onSubmit={handleCreate} className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.tenderNumber')} *</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('tenders.tenderNumber')} *
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.tenderNumber}
|
value={formData.tenderNumber}
|
||||||
onChange={(e) => setFormData({ ...formData, tenderNumber: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, tenderNumber: e.target.value })}
|
||||||
className="w-full px-3 py-2 border rounded-lg"
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
/>
|
/>
|
||||||
{formErrors.tenderNumber && <p className="text-red-500 text-xs mt-1">{formErrors.tenderNumber}</p>}
|
{formErrors.tenderNumber && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{formErrors.tenderNumber}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.issuingBody')} *</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('tenders.issuingBody')} *
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.issuingBodyName}
|
value={formData.issuingBodyName}
|
||||||
onChange={(e) => setFormData({ ...formData, issuingBodyName: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, issuingBodyName: e.target.value })}
|
||||||
className="w-full px-3 py-2 border rounded-lg"
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
/>
|
/>
|
||||||
{formErrors.issuingBodyName && <p className="text-red-500 text-xs mt-1">{formErrors.issuingBodyName}</p>}
|
{formErrors.issuingBodyName && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{formErrors.issuingBodyName}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.titleLabel')} *</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('tenders.titleLabel')} *
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.title}
|
value={formData.title}
|
||||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||||
className="w-full px-3 py-2 border rounded-lg"
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
/>
|
/>
|
||||||
{formErrors.title && <p className="text-red-500 text-xs mt-1">{formErrors.title}</p>}
|
{formErrors.title && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{formErrors.title}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.termsValue')} *</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
<input
|
قيمة دفتر الشروط *
|
||||||
type="number"
|
</label>
|
||||||
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">{t('tenders.bondValue')} *</label>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
value={formData.bondValue || ''}
|
value={formData.termsValue || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, bondValue: Number(e.target.value) || 0 })}
|
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"
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.announcementDate')} *</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('tenders.announcementDate')} *
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={formData.announcementDate}
|
value={formData.announcementDate}
|
||||||
onChange={(e) => setFormData({ ...formData, announcementDate: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, announcementDate: e.target.value })}
|
||||||
className="w-full px-3 py-2 border rounded-lg"
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
/>
|
/>
|
||||||
{formErrors.announcementDate && <p className="text-red-500 text-xs mt-1">{formErrors.announcementDate}</p>}
|
{formErrors.announcementDate && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{formErrors.announcementDate}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.closingDate')} *</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('tenders.closingDate')} *
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={formData.closingDate}
|
value={formData.closingDate}
|
||||||
onChange={(e) => setFormData({ ...formData, closingDate: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, closingDate: e.target.value })}
|
||||||
className="w-full px-3 py-2 border rounded-lg"
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
/>
|
/>
|
||||||
{formErrors.closingDate && <p className="text-red-500 text-xs mt-1">{formErrors.closingDate}</p>}
|
{formErrors.closingDate && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{formErrors.closingDate}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.source')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('tenders.source')}
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={formData.source}
|
value={formData.source}
|
||||||
onChange={(e) => setFormData({ ...formData, source: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, source: e.target.value })}
|
||||||
className="w-full px-3 py-2 border rounded-lg"
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
>
|
>
|
||||||
{sourceValues.map((s) => (
|
{sourceValues.map((s) => (
|
||||||
<option key={s} value={s}>{SOURCE_LABELS[s] || s}</option>
|
<option key={s} value={s}>
|
||||||
|
{SOURCE_LABELS[s] || s}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.announcementType')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('tenders.announcementType')}
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={formData.announcementType}
|
value={formData.announcementType}
|
||||||
onChange={(e) => setFormData({ ...formData, announcementType: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, announcementType: e.target.value })}
|
||||||
className="w-full px-3 py-2 border rounded-lg"
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
>
|
>
|
||||||
{announcementTypeValues.map((a) => (
|
{announcementTypeValues.map((a) => (
|
||||||
<option key={a} value={a}>{ANNOUNCEMENT_LABELS[a] || a}</option>
|
<option key={a} value={a}>
|
||||||
|
{ANNOUNCEMENT_LABELS[a] || a}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.announcementLink')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('tenders.announcementLink')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
value={formData.announcementLink || ''}
|
value={formData.announcementLink || ''}
|
||||||
@@ -390,8 +605,11 @@ function TendersContent() {
|
|||||||
className="w-full px-3 py-2 border rounded-lg"
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('common.notes')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('common.notes')}
|
||||||
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={formData.notes || ''}
|
value={formData.notes || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||||
@@ -399,29 +617,39 @@ function TendersContent() {
|
|||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showDuplicateWarning && possibleDuplicates.length > 0 && (
|
{showDuplicateWarning && possibleDuplicates.length > 0 && (
|
||||||
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-start gap-2">
|
<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" />
|
<AlertCircle className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-amber-800">{t('tenders.duplicateWarning') || 'Possible duplicates found'}</p>
|
<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">
|
<ul className="text-sm text-amber-700 mt-1 list-disc list-inside">
|
||||||
{possibleDuplicates.slice(0, 3).map((d) => (
|
{possibleDuplicates.slice(0, 3).map((d) => (
|
||||||
<li key={d.id}>
|
<li key={d.id}>
|
||||||
<Link href={`/tenders/${d.id}`} className="underline">{d.tenderNumber} - {d.title}</Link>
|
<Link href={`/tenders/${d.id}`} className="underline">
|
||||||
|
{d.tenderNumber} - {d.title}
|
||||||
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-4">
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setShowCreateModal(false); resetForm(); }}
|
onClick={() => {
|
||||||
|
setShowCreateModal(false)
|
||||||
|
resetForm()
|
||||||
|
}}
|
||||||
className="px-4 py-2 border rounded-lg"
|
className="px-4 py-2 border rounded-lg"
|
||||||
>
|
>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
|
|||||||
BIN
frontend/src/assets/logo.png
Normal file
BIN
frontend/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
@@ -15,40 +15,47 @@ interface ContactFormProps {
|
|||||||
submitting?: boolean
|
submitting?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildInitialFormData = (contact?: Contact): CreateContactData => ({
|
||||||
|
type: contact?.type || 'INDIVIDUAL',
|
||||||
|
name: contact?.name || '',
|
||||||
|
nameAr: contact?.nameAr,
|
||||||
|
email: contact?.email,
|
||||||
|
phone: contact?.phone,
|
||||||
|
mobile: contact?.mobile,
|
||||||
|
website: contact?.website,
|
||||||
|
companyName: contact?.companyName,
|
||||||
|
companyNameAr: contact?.companyNameAr,
|
||||||
|
taxNumber: contact?.taxNumber,
|
||||||
|
commercialRegister: contact?.commercialRegister,
|
||||||
|
address: contact?.address,
|
||||||
|
city: contact?.city,
|
||||||
|
country: contact?.country || 'Syria',
|
||||||
|
postalCode: contact?.postalCode,
|
||||||
|
source: contact?.source || 'WEBSITE',
|
||||||
|
categories: contact?.categories?.map((c: any) => c.id || c) || [],
|
||||||
|
tags: contact?.tags || [],
|
||||||
|
parentId: contact?.parent?.id,
|
||||||
|
employeeId: contact?.employeeId ?? undefined,
|
||||||
|
customFields: contact?.customFields
|
||||||
|
})
|
||||||
|
|
||||||
export default function ContactForm({ contact, onSubmit, onCancel, submitting = false }: ContactFormProps) {
|
export default function ContactForm({ contact, onSubmit, onCancel, submitting = false }: ContactFormProps) {
|
||||||
const isEdit = !!contact
|
const isEdit = !!contact
|
||||||
|
|
||||||
// Form state
|
const [formData, setFormData] = useState<CreateContactData>(buildInitialFormData(contact))
|
||||||
const [formData, setFormData] = useState<CreateContactData>({
|
|
||||||
type: contact?.type || 'INDIVIDUAL',
|
|
||||||
name: contact?.name || '',
|
|
||||||
nameAr: contact?.nameAr,
|
|
||||||
email: contact?.email,
|
|
||||||
phone: contact?.phone,
|
|
||||||
mobile: contact?.mobile,
|
|
||||||
website: contact?.website,
|
|
||||||
companyName: contact?.companyName,
|
|
||||||
companyNameAr: contact?.companyNameAr,
|
|
||||||
taxNumber: contact?.taxNumber,
|
|
||||||
commercialRegister: contact?.commercialRegister,
|
|
||||||
address: contact?.address,
|
|
||||||
city: contact?.city,
|
|
||||||
country: contact?.country || 'Saudi Arabia',
|
|
||||||
postalCode: contact?.postalCode,
|
|
||||||
source: contact?.source || 'WEBSITE',
|
|
||||||
categories: contact?.categories?.map((c: any) => c.id || c) || [],
|
|
||||||
tags: contact?.tags || [],
|
|
||||||
parentId: contact?.parent?.id,
|
|
||||||
employeeId: contact?.employeeId ?? undefined,
|
|
||||||
customFields: contact?.customFields
|
|
||||||
})
|
|
||||||
|
|
||||||
const [rating, setRating] = useState<number>(contact?.rating || 0)
|
const [rating, setRating] = useState<number>(contact?.rating || 0)
|
||||||
const [newTag, setNewTag] = useState('')
|
const [newTag, setNewTag] = useState('')
|
||||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
|
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
const [employees, setEmployees] = useState<Employee[]>([])
|
const [employees, setEmployees] = useState<Employee[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFormData(buildInitialFormData(contact))
|
||||||
|
setRating(contact?.rating || 0)
|
||||||
|
setNewTag('')
|
||||||
|
setFormErrors({})
|
||||||
|
}, [contact])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
categoriesAPI.getTree().then(setCategories).catch(() => {})
|
categoriesAPI.getTree().then(setCategories).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
@@ -72,7 +79,23 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
|
|
||||||
const isCompanyEmployeeSelected = companyEmployeeCategoryId && (formData.categories || []).includes(companyEmployeeCategoryId)
|
const isCompanyEmployeeSelected = companyEmployeeCategoryId && (formData.categories || []).includes(companyEmployeeCategoryId)
|
||||||
|
|
||||||
// Validation
|
const organizationTypes = new Set([
|
||||||
|
'COMPANY',
|
||||||
|
'HOLDING',
|
||||||
|
'GOVERNMENT',
|
||||||
|
'ORGANIZATION',
|
||||||
|
'EMBASSIES',
|
||||||
|
'BANK',
|
||||||
|
'UNIVERSITY',
|
||||||
|
'SCHOOL',
|
||||||
|
'UN',
|
||||||
|
'NGO',
|
||||||
|
'INSTITUTION',
|
||||||
|
])
|
||||||
|
|
||||||
|
const isOrganizationType = organizationTypes.has(formData.type)
|
||||||
|
const showCompanyFields = isOrganizationType
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
const errors: Record<string, string> = {}
|
const errors: Record<string, string> = {}
|
||||||
|
|
||||||
@@ -103,28 +126,49 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!validateForm()) return
|
if (!validateForm()) return
|
||||||
|
const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, value]) => {
|
||||||
|
const requiredFields = ['type', 'name', 'source', 'country']
|
||||||
|
|
||||||
// Clean up empty strings to undefined for optional fields
|
// keep required fields as-is
|
||||||
const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, value]) => {
|
if (requiredFields.includes(key)) {
|
||||||
// Keep the value if it's not an empty string, or if it's a required field
|
acc[key] = value
|
||||||
if (value !== '' || ['type', 'name', 'source', 'country'].includes(key)) {
|
|
||||||
acc[key] = value
|
|
||||||
}
|
|
||||||
return acc
|
return acc
|
||||||
}, {} as any)
|
}
|
||||||
|
|
||||||
|
// in edit mode, allow clearing optional fields by sending null
|
||||||
|
if (isEdit && value === '') {
|
||||||
|
acc[key] = null
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
|
// in create mode, ignore empty optional fields
|
||||||
|
if (value !== '') {
|
||||||
|
acc[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc
|
||||||
|
}, {} as any)
|
||||||
|
|
||||||
// Remove parentId if it's empty or undefined
|
|
||||||
if (!cleanData.parentId) {
|
if (!cleanData.parentId) {
|
||||||
delete cleanData.parentId
|
delete cleanData.parentId
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove categories if empty array
|
|
||||||
if (cleanData.categories && cleanData.categories.length === 0) {
|
if (cleanData.categories && cleanData.categories.length === 0) {
|
||||||
delete cleanData.categories
|
delete cleanData.categories
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove employeeId if empty
|
if (!cleanData.parentId) {
|
||||||
if (!cleanData.employeeId) {
|
delete cleanData.parentId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanData.categories && cleanData.categories.length === 0) {
|
||||||
|
delete cleanData.categories
|
||||||
|
}
|
||||||
|
|
||||||
|
// employeeId:
|
||||||
|
// - in create: remove if empty
|
||||||
|
// - in edit: keep null if user cleared it
|
||||||
|
if (!isEdit && !cleanData.employeeId) {
|
||||||
delete cleanData.employeeId
|
delete cleanData.employeeId
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,16 +196,12 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const showCompanyFields = ['COMPANY', 'HOLDING', 'GOVERNMENT'].includes(formData.type)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} noValidate className="space-y-6">
|
||||||
{/* Basic Information Section */}
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Basic Information</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Basic Information</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{/* Contact Type */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Contact Type <span className="text-red-500">*</span>
|
Contact Type <span className="text-red-500">*</span>
|
||||||
@@ -175,11 +215,18 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
<option value="COMPANY">Company - شركة</option>
|
<option value="COMPANY">Company - شركة</option>
|
||||||
<option value="HOLDING">Holding - مجموعة</option>
|
<option value="HOLDING">Holding - مجموعة</option>
|
||||||
<option value="GOVERNMENT">Government - حكومي</option>
|
<option value="GOVERNMENT">Government - حكومي</option>
|
||||||
|
<option value="ORGANIZATION">Organizations - منظمات</option>
|
||||||
|
<option value="EMBASSIES">Embassies - سفارات</option>
|
||||||
|
<option value="BANK">Banks - بنوك</option>
|
||||||
|
<option value="UNIVERSITY">Universities - جامعات</option>
|
||||||
|
<option value="SCHOOL">Schools - مدارس</option>
|
||||||
|
<option value="UN">UN - الأمم المتحدة</option>
|
||||||
|
<option value="NGO">NGO - منظمة غير حكومية</option>
|
||||||
|
<option value="INSTITUTION">Institution - مؤسسة</option>
|
||||||
</select>
|
</select>
|
||||||
{formErrors.type && <p className="text-red-500 text-xs mt-1">{formErrors.type}</p>}
|
{formErrors.type && <p className="text-red-500 text-xs mt-1">{formErrors.type}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Source */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Source <span className="text-red-500">*</span>
|
Source <span className="text-red-500">*</span>
|
||||||
@@ -202,37 +249,20 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Name */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Name <span className="text-red-500">*</span>
|
{isOrganizationType ? 'Contact Person Name' : 'Name'} <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||||
placeholder="Enter contact name"
|
placeholder={isOrganizationType ? 'Enter contact person name' : 'Enter contact name'}
|
||||||
/>
|
/>
|
||||||
{formErrors.name && <p className="text-red-500 text-xs mt-1">{formErrors.name}</p>}
|
{formErrors.name && <p className="text-red-500 text-xs mt-1">{formErrors.name}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Arabic Name */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Arabic Name - الاسم بالعربية
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.nameAr || ''}
|
|
||||||
onChange={(e) => setFormData({ ...formData, nameAr: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
|
||||||
placeholder="أدخل الاسم بالعربية"
|
|
||||||
dir="rtl"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rating */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Rating
|
Rating
|
||||||
@@ -268,12 +298,10 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contact Methods Section */}
|
|
||||||
<div className="pt-6 border-t">
|
<div className="pt-6 border-t">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Contact Methods</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Contact Methods</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{/* Email */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Email
|
Email
|
||||||
@@ -288,7 +316,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
{formErrors.email && <p className="text-red-500 text-xs mt-1">{formErrors.email}</p>}
|
{formErrors.email && <p className="text-red-500 text-xs mt-1">{formErrors.email}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Phone */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Phone
|
Phone
|
||||||
@@ -305,7 +332,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{/* Mobile */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Mobile
|
Mobile
|
||||||
@@ -319,7 +345,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Website */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Website
|
Website
|
||||||
@@ -336,44 +361,27 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Company Information Section (conditional) */}
|
|
||||||
{showCompanyFields && (
|
{showCompanyFields && (
|
||||||
<div className="pt-6 border-t">
|
<div className="pt-6 border-t">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Company Information</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Company Information</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{/* Company Name */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Company Name
|
{formData.type === 'EMBASSIES' ? 'Embassy Name' : 'Company / Organization Name'}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.companyName || ''}
|
value={formData.companyName || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, companyName: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, companyName: e.target.value })}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||||
placeholder="Company name"
|
placeholder={formData.type === 'EMBASSIES' ? 'Embassy name' : 'Company / organization name'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Company Name Arabic */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Company Name (Arabic) - اسم الشركة
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.companyNameAr || ''}
|
|
||||||
onChange={(e) => setFormData({ ...formData, companyNameAr: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
|
||||||
placeholder="اسم الشركة بالعربية"
|
|
||||||
dir="rtl"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{/* Tax Number */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Tax Number
|
Tax Number
|
||||||
@@ -387,7 +395,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Commercial Register */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Commercial Register
|
Commercial Register
|
||||||
@@ -405,11 +412,9 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Address Section */}
|
|
||||||
<div className="pt-6 border-t">
|
<div className="pt-6 border-t">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Address Information</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Address Information</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Address */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Street Address
|
Street Address
|
||||||
@@ -424,7 +429,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
{/* City */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
City
|
City
|
||||||
@@ -438,7 +442,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Country */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Country
|
Country
|
||||||
@@ -452,7 +455,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Postal Code */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Postal Code
|
Postal Code
|
||||||
@@ -469,7 +471,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Categories Section */}
|
|
||||||
<div className="pt-6 border-t">
|
<div className="pt-6 border-t">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Categories</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Categories</h3>
|
||||||
<CategorySelector
|
<CategorySelector
|
||||||
@@ -479,7 +480,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Employee Link - when Company Employee category is selected */}
|
|
||||||
{isCompanyEmployeeSelected && (
|
{isCompanyEmployeeSelected && (
|
||||||
<div className="pt-6 border-t">
|
<div className="pt-6 border-t">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Link to Employee (Optional)</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Link to Employee (Optional)</h3>
|
||||||
@@ -501,11 +501,9 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tags Section */}
|
|
||||||
<div className="pt-6 border-t">
|
<div className="pt-6 border-t">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Tags</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Tags</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Tag input */}
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -524,7 +522,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tags display */}
|
|
||||||
{formData.tags && formData.tags.length > 0 && (
|
{formData.tags && formData.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{formData.tags.map((tag, index) => (
|
{formData.tags.map((tag, index) => (
|
||||||
@@ -547,7 +544,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Duplicate Detection */}
|
|
||||||
<DuplicateAlert
|
<DuplicateAlert
|
||||||
email={formData.email}
|
email={formData.email}
|
||||||
phone={formData.phone}
|
phone={formData.phone}
|
||||||
@@ -556,14 +552,12 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
commercialRegister={formData.commercialRegister}
|
commercialRegister={formData.commercialRegister}
|
||||||
excludeId={contact?.id}
|
excludeId={contact?.id}
|
||||||
onMerge={(contactId) => {
|
onMerge={(contactId) => {
|
||||||
// Navigate to merge page with pre-selected contacts
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.location.href = `/contacts/merge?sourceId=${contactId}`
|
window.location.href = `/contacts/merge?sourceId=${contactId}`
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Form Actions */}
|
|
||||||
<div className="flex items-center justify-end gap-3 pt-6 border-t">
|
<div className="flex items-center justify-end gap-3 pt-6 border-t">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -66,6 +66,24 @@ export interface Leave {
|
|||||||
createdAt: string
|
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 {
|
export interface PurchaseRequest {
|
||||||
id: string
|
id: string
|
||||||
requestNumber: string
|
requestNumber: string
|
||||||
@@ -90,6 +108,24 @@ export interface Attendance {
|
|||||||
status: string
|
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 {
|
export interface Salary {
|
||||||
id: string
|
id: string
|
||||||
month: number
|
month: number
|
||||||
@@ -106,6 +142,24 @@ export interface Salary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const portalAPI = {
|
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> => {
|
getMe: async (): Promise<PortalProfile> => {
|
||||||
const response = await api.get('/hr/portal/me')
|
const response = await api.get('/hr/portal/me')
|
||||||
return response.data.data
|
return response.data.data
|
||||||
@@ -121,6 +175,40 @@ export const portalAPI = {
|
|||||||
return response.data.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']> => {
|
getLeaveBalance: async (year?: number): Promise<PortalProfile['stats']['leaveBalance']> => {
|
||||||
const params = year ? `?year=${year}` : ''
|
const params = year ? `?year=${year}` : ''
|
||||||
const response = await api.get(`/hr/portal/leave-balance${params}`)
|
const response = await api.get(`/hr/portal/leave-balance${params}`)
|
||||||
|
|||||||
@@ -5,8 +5,18 @@ export interface Tender {
|
|||||||
tenderNumber: string
|
tenderNumber: string
|
||||||
issuingBodyName: string
|
issuingBodyName: string
|
||||||
title: string
|
title: string
|
||||||
|
|
||||||
termsValue: number
|
termsValue: number
|
||||||
bondValue: 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
|
announcementDate: string
|
||||||
closingDate: string
|
closingDate: string
|
||||||
announcementLink?: string
|
announcementLink?: string
|
||||||
@@ -26,6 +36,7 @@ export interface Tender {
|
|||||||
_count?: { directives: number }
|
_count?: { directives: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface TenderDirective {
|
export interface TenderDirective {
|
||||||
id: string
|
id: string
|
||||||
tenderId: string
|
tenderId: string
|
||||||
@@ -48,8 +59,18 @@ export interface CreateTenderData {
|
|||||||
tenderNumber: string
|
tenderNumber: string
|
||||||
issuingBodyName: string
|
issuingBodyName: string
|
||||||
title: string
|
title: string
|
||||||
|
|
||||||
termsValue: number
|
termsValue: number
|
||||||
bondValue: number
|
bondValue: number
|
||||||
|
|
||||||
|
// extra UI/backend fields without DB migration
|
||||||
|
initialBondValue?: number
|
||||||
|
finalBondValue?: number
|
||||||
|
finalBondRefundPeriod?: string
|
||||||
|
siteVisitRequired?: boolean
|
||||||
|
siteVisitLocation?: string
|
||||||
|
termsPickupProvince?: string
|
||||||
|
|
||||||
announcementDate: string
|
announcementDate: string
|
||||||
closingDate: string
|
closingDate: string
|
||||||
announcementLink?: string
|
announcementLink?: string
|
||||||
@@ -166,6 +187,10 @@ export const tendersAPI = {
|
|||||||
return response.data.data
|
return response.data.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
deleteAttachment: async (attachmentId: string): Promise<void> => {
|
||||||
|
await api.delete(`/tenders/attachments/${attachmentId}`)
|
||||||
|
},
|
||||||
|
|
||||||
getSourceValues: async (): Promise<string[]> => {
|
getSourceValues: async (): Promise<string[]> => {
|
||||||
const response = await api.get('/tenders/source-values')
|
const response = await api.get('/tenders/source-values')
|
||||||
return response.data.data
|
return response.data.data
|
||||||
|
|||||||
Reference in New Issue
Block a user