Compare commits

...

26 Commits

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

Made-with: Cursor
2026-03-11 16:57:40 +04:00
Talal Sharabi
18c13cdf7c feat(crm): add contracts, cost sheets, invoices modules and API clients
Made-with: Cursor
2026-03-11 16:40:25 +04:00
yotakii
8a20927044 add atmata brand 2026-03-10 11:54:46 +03:00
yotakii
6d82c5007c fix login 2026-03-05 12:16:29 +03:00
66 changed files with 8012 additions and 1102 deletions

View File

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

174
DEVELOPER_STAGING_DEPLOY.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -70,7 +70,10 @@ model User {
projectMembers ProjectMember[]
campaigns Campaign[]
userRoles UserRole[]
tendersCreated Tender[]
tenderDirectivesIssued TenderDirective[]
tenderDirectivesCompleted TenderDirective[] @relation("TenderDirectiveCompletedBy")
@@map("users")
}
@@ -199,7 +202,8 @@ model Employee {
purchaseRequests PurchaseRequest[]
leaveEntitlements LeaveEntitlement[]
employeeContracts EmployeeContract[]
tenderDirectivesAssigned TenderDirective[]
@@index([departmentId])
@@index([positionId])
@@index([status])
@@ -610,7 +614,8 @@ model Contact {
deals Deal[]
attachments Attachment[]
notes Note[]
tenders Tender[]
@@index([type])
@@index([status])
@@index([email])
@@ -705,10 +710,14 @@ model Deal {
// Status
status String @default("ACTIVE") // ACTIVE, WON, LOST, CANCELLED, ARCHIVED
// Source (when converted from Tender)
sourceTenderId String? @unique
sourceTender Tender? @relation(fields: [sourceTenderId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
quotes Quote[]
costSheets CostSheet[]
@@ -718,7 +727,7 @@ model Deal {
contracts Contract[]
invoices Invoice[]
commissions Commission[]
@@index([contactId])
@@index([ownerId])
@@index([pipelineId])
@@ -873,6 +882,66 @@ model Invoice {
@@map("invoices")
}
// ============================================
// TENDER MANAGEMENT - إدارة المناقصات
// ============================================
model Tender {
id String @id @default(uuid())
tenderNumber String @unique
issuingBodyName String
title String
termsValue Decimal @db.Decimal(15, 2)
bondValue Decimal @db.Decimal(15, 2)
announcementDate DateTime @db.Date
closingDate DateTime @db.Date
announcementLink String?
source String // GOVERNMENT_SITE, OFFICIAL_GAZETTE, PERSONAL, PARTNER, WHATSAPP_TELEGRAM, PORTAL, EMAIL, MANUAL
sourceOther String? // Free text when source is MANUAL or other
announcementType String // FIRST, RE_ANNOUNCEMENT_2, RE_ANNOUNCEMENT_3, RE_ANNOUNCEMENT_4
notes String?
status String @default("ACTIVE") // ACTIVE, CONVERTED_TO_DEAL, CANCELLED
contactId String? // Optional link to Contact (issuing body)
contact Contact? @relation(fields: [contactId], references: [id])
createdById String
createdBy User @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
directives TenderDirective[]
attachments Attachment[]
convertedDeal Deal?
@@index([tenderNumber])
@@index([status])
@@index([createdById])
@@index([announcementDate])
@@index([closingDate])
@@map("tenders")
}
model TenderDirective {
id String @id @default(uuid())
tenderId String
tender Tender @relation(fields: [tenderId], references: [id], onDelete: Cascade)
type String // BUY_TERMS, VISIT_CLIENT, MEET_COMMITTEE, PREPARE_TO_BID
notes String?
assignedToEmployeeId String
assignedToEmployee Employee @relation(fields: [assignedToEmployeeId], references: [id])
issuedById String
issuedBy User @relation(fields: [issuedById], references: [id])
status String @default("PENDING") // PENDING, IN_PROGRESS, COMPLETED, CANCELLED
completedAt DateTime?
completionNotes String?
completedById String?
completedBy User? @relation("TenderDirectiveCompletedBy", fields: [completedById], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
attachments Attachment[]
@@index([tenderId])
@@index([assignedToEmployeeId])
@@index([status])
@@map("tender_directives")
}
// ============================================
// MODULE 3: INVENTORY & ASSETS
// ============================================
@@ -1420,11 +1489,11 @@ model Note {
model Attachment {
id String @id @default(uuid())
// Related Entity
entityType String
entityId String
// Relations
contactId String?
contact Contact? @relation(fields: [contactId], references: [id])
@@ -1434,7 +1503,11 @@ model Attachment {
project Project? @relation(fields: [projectId], references: [id])
taskId String?
task Task? @relation(fields: [taskId], references: [id])
tenderId String?
tender Tender? @relation(fields: [tenderId], references: [id], onDelete: Cascade)
tenderDirectiveId String?
tenderDirective TenderDirective? @relation(fields: [tenderDirectiveId], references: [id], onDelete: Cascade)
// File Info
fileName String
originalName String
@@ -1442,19 +1515,21 @@ model Attachment {
size Int
path String
url String?
// Metadata
description String?
category String?
uploadedBy String
uploadedAt DateTime @default(now())
@@index([entityType, entityId])
@@index([contactId])
@@index([dealId])
@@index([projectId])
@@index([taskId])
@@index([tenderId])
@@index([tenderDirectiveId])
@@map("attachments")
}

View File

@@ -30,7 +30,7 @@ async function main() {
},
});
const modules = ['contacts', 'crm', 'inventory', 'projects', 'hr', 'marketing', 'admin'];
const modules = ['contacts', 'crm', 'tenders', 'inventory', 'projects', 'hr', 'marketing', 'admin'];
for (const module of modules) {
await prisma.positionPermission.create({
data: {
@@ -67,6 +67,8 @@ async function main() {
data: [
{ positionId: salesRepPosition.id, module: 'contacts', resource: '*', actions: ['read', 'create', 'update'] },
{ positionId: salesRepPosition.id, module: 'crm', resource: 'deals', actions: ['read', 'create', 'update'] },
{ positionId: salesRepPosition.id, module: 'tenders', resource: 'tenders', actions: ['read', 'create', 'update'] },
{ positionId: salesRepPosition.id, module: 'tenders', resource: 'directives', actions: ['read', 'create', 'update'] },
],
});

View File

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

View File

@@ -148,7 +148,7 @@ class AdminService {
throw new AppError(400, 'هذا الموظف مرتبط بحساب مستخدم بالفعل - Employee already has a user account');
}
const emailExists = await prisma.user.findUnique({
const emailExists = await prisma.user.findFirst({
where: { email: data.email },
});
if (emailExists) {
@@ -206,7 +206,7 @@ class AdminService {
}
if (data.email && data.email !== existing.email) {
const emailExists = await prisma.user.findUnique({ where: { email: data.email } });
const emailExists = await prisma.user.findFirst({ where: { email: data.email } });
if (emailExists) {
throw new AppError(400, 'البريد الإلكتروني مستخدم بالفعل - Email already in use');
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -328,46 +328,50 @@ class ContactsService {
// Update contact
const contact = await prisma.contact.update({
where: { id },
data: {
name: data.name,
nameAr: data.nameAr,
email: data.email,
phone: data.phone,
mobile: data.mobile,
website: data.website,
companyName: data.companyName,
companyNameAr: data.companyNameAr,
taxNumber: data.taxNumber,
commercialRegister: data.commercialRegister,
address: data.address,
city: data.city,
country: data.country,
postalCode: data.postalCode,
categories: data.categories ? {
set: data.categories.map(id => ({ id }))
} : undefined,
tags: data.tags,
employeeId: data.employeeId !== undefined ? (data.employeeId || null) : undefined,
source: data.source,
status: data.status,
rating: data.rating,
customFields: data.customFields,
where: { id },
data: {
type: data.type,
name: data.name,
nameAr: data.nameAr,
email: data.email === '' || data.email === undefined ? null : data.email,
phone: data.phone,
mobile: data.mobile,
website: data.website,
companyName: data.companyName,
companyNameAr: data.companyNameAr,
taxNumber: data.taxNumber,
commercialRegister: data.commercialRegister,
address: data.address,
city: data.city,
country: data.country,
postalCode: data.postalCode,
categories: data.categories
? {
set: data.categories.map((id) => ({ id })),
}
: undefined,
tags: data.tags,
employeeId:
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
await AuditLogger.log({
@@ -679,7 +683,7 @@ class ContactsService {
}
// Validate type
if (!['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT'].includes(row.type)) {
if (!['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT', 'ORGANIZATION','EMBASSIES','BANK','UNIVERSITY','SCHOOL','UN','NGO','INSTITUTION',].includes(row.type)) {
results.errors.push({
row: rowNumber,
field: 'type',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,11 @@ class DashboardController {
const userId = req.user!.id;
const [contactsCount, activeTasksCount, unreadNotificationsCount] = await Promise.all([
prisma.contact.count(),
prisma.contact.count({
where: {
archivedAt: null,
},
}),
prisma.task.count({
where: {
status: { notIn: ['COMPLETED', 'CANCELLED'] },

View File

@@ -14,6 +14,46 @@ router.post('/portal/loans', portalController.submitLoanRequest);
router.get('/portal/leave-balance', portalController.getMyLeaveBalance);
router.get('/portal/leaves', portalController.getMyLeaves);
router.post('/portal/leaves', portalController.submitLeaveRequest);
router.get(
'/portal/managed-leaves',
authorize('department_leave_requests', '*', 'read'),
portalController.getManagedLeaves
);
router.post(
'/portal/managed-leaves/:id/approve',
authorize('department_leave_requests', '*', 'approve'),
portalController.approveManagedLeave
);
router.post(
'/portal/managed-leaves/:id/reject',
authorize('department_leave_requests', '*', 'approve'),
portalController.rejectManagedLeave
);
router.get('/portal/overtime-requests', portalController.getMyOvertimeRequests);
router.post('/portal/overtime-requests', portalController.submitOvertimeRequest);
router.get(
'/portal/managed-overtime-requests',
authorize('department_overtime_requests', '*', 'view'),
portalController.getManagedOvertimeRequests
);
router.post(
'/portal/managed-overtime-requests/:attendanceId/approve',
authorize('department_overtime_requests', '*', 'approve'),
portalController.approveManagedOvertimeRequest
);
router.post(
'/portal/managed-overtime-requests/:attendanceId/reject',
authorize('department_overtime_requests', '*', 'approve'),
portalController.rejectManagedOvertimeRequest
);
router.get('/portal/purchase-requests', portalController.getMyPurchaseRequests);
router.post('/portal/purchase-requests', portalController.submitPurchaseRequest);
router.get('/portal/attendance', portalController.getMyAttendance);
@@ -86,5 +126,4 @@ router.get('/contracts/:id', authorize('hr', 'all', 'read'), hrController.findEm
router.post('/contracts', authorize('hr', 'all', 'create'), hrController.createEmployeeContract);
router.put('/contracts/:id', authorize('hr', 'all', 'update'), hrController.updateEmployeeContract);
export default router;
export default router;

View File

@@ -313,40 +313,54 @@ class HRService {
// ========== LEAVES ==========
async createLeaveRequest(data: any, userId: string) {
const days = this.calculateLeaveDays(data.startDate, data.endDate);
const startDate = new Date(data.startDate);
const year = startDate.getFullYear();
const allowedLeaveTypes = ['ANNUAL', 'HOURLY'];
const ent = await prisma.leaveEntitlement.findUnique({
where: { employeeId_year_leaveType: { employeeId: data.employeeId, year, leaveType: data.leaveType } },
});
if (ent) {
const available = ent.totalDays + ent.carriedOver - ent.usedDays;
if (days > available) {
throw new AppError(400, `رصيد الإجازة غير كافٍ - Insufficient leave balance. Available: ${available}, Requested: ${days}`);
}
}
const leave = await prisma.leave.create({
data: {
...data,
days,
},
include: {
employee: true,
},
});
await AuditLogger.log({
entityType: 'LEAVE',
entityId: leave.id,
action: 'CREATE',
userId,
});
return leave;
if (!allowedLeaveTypes.includes(String(data.leaveType || '').toUpperCase())) {
throw new AppError(400, 'نوع الإجازة غير مدعوم - Only ANNUAL and HOURLY leave types are allowed');
}
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) {
const leave = await prisma.leave.update({
where: { id },
@@ -418,6 +432,195 @@ class HRService {
return { leaves, total, page, pageSize };
}
private calculateLeaveHours(startDate: Date, endDate: Date) {
const diffMs = new Date(endDate).getTime() - new Date(startDate).getTime();
const diffHours = diffMs / (1000 * 60 * 60);
return diffHours > 0 ? diffHours : 0;
}
private async canManagerApproveLeave(leave: any) {
if (leave.leaveType === 'ANNUAL') {
const yearStart = new Date(new Date(leave.startDate).getFullYear(), 0, 1);
const yearEnd = new Date(new Date(leave.startDate).getFullYear(), 11, 31, 23, 59, 59, 999);
const approvedAnnualLeaves = await prisma.leave.findMany({
where: {
employeeId: leave.employeeId,
leaveType: 'ANNUAL',
status: 'APPROVED',
startDate: {
gte: yearStart,
lte: yearEnd,
},
},
select: {
id: true,
days: true,
},
});
const usedDays = approvedAnnualLeaves.reduce((sum, item) => sum + Number(item.days || 0), 0);
return usedDays + Number(leave.days || 0) <= 12;
}
if (leave.leaveType === 'HOURLY') {
const start = new Date(leave.startDate);
const monthStart = new Date(start.getFullYear(), start.getMonth(), 1);
const monthEnd = new Date(start.getFullYear(), start.getMonth() + 1, 0, 23, 59, 59, 999);
const approvedHourlyLeaves = await prisma.leave.findMany({
where: {
employeeId: leave.employeeId,
leaveType: 'HOURLY',
status: 'APPROVED',
startDate: {
gte: monthStart,
lte: monthEnd,
},
},
select: {
id: true,
startDate: true,
endDate: true,
},
});
const usedHours = approvedHourlyLeaves.reduce((sum, item) => {
return sum + this.calculateLeaveHours(item.startDate, item.endDate);
}, 0);
const requestedHours = this.calculateLeaveHours(leave.startDate, leave.endDate);
return usedHours + requestedHours <= 3;
}
return false;
}
async findManagedLeaves(status?: string) {
const where: any = {};
if (status && status !== 'all') {
where.status = status;
}
return prisma.leave.findMany({
where,
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
reportingToId: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
take: 100,
});
}
async managerApproveLeave(id: string, userId: string) {
const leave = await prisma.leave.findUnique({
where: { id },
include: {
employee: {
select: {
id: true,
reportingToId: true,
},
},
},
});
if (!leave) {
throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found');
}
if (leave.status !== 'PENDING') {
throw new AppError(400, 'لا يمكن اعتماد طلب غير معلق - Only pending leave can be approved');
}
const canApprove = await this.canManagerApproveLeave(leave);
if (!canApprove) {
throw new AppError(403, 'الطلب يتجاوز صلاحية مدير القسم ويحتاج موافقة HR - This leave exceeds manager approval limits and requires HR approval');
}
const updated = await prisma.leave.update({
where: { id },
data: {
status: 'APPROVED',
approvedBy: userId,
approvedAt: new Date(),
rejectedReason: null,
},
include: {
employee: true,
},
});
const year = new Date(updated.startDate).getFullYear();
await this.updateLeaveEntitlementUsed(updated.employeeId, year, updated.leaveType, updated.days);
await AuditLogger.log({
entityType: 'LEAVE',
entityId: updated.id,
action: 'MANAGER_APPROVE',
userId,
});
return updated;
}
async managerRejectLeave(id: string, rejectedReason: string, userId: string) {
const leave = await prisma.leave.findUnique({
where: { id },
include: {
employee: {
select: {
id: true,
reportingToId: true,
},
},
},
});
if (!leave) {
throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found');
}
if (leave.status !== 'PENDING') {
throw new AppError(400, 'لا يمكن رفض طلب غير معلق - Only pending leave can be rejected');
}
const updated = await prisma.leave.update({
where: { id },
data: {
status: 'REJECTED',
rejectedReason,
approvedBy: null,
approvedAt: null,
},
include: {
employee: true,
},
});
await AuditLogger.log({
entityType: 'LEAVE',
entityId: updated.id,
action: 'MANAGER_REJECT',
userId,
reason: rejectedReason,
});
return updated;
}
private async updateLeaveEntitlementUsed(employeeId: string, year: number, leaveType: string, days: number) {
const ent = await prisma.leaveEntitlement.findUnique({
where: { employeeId_year_leaveType: { employeeId, year, leaveType } },
@@ -534,94 +737,260 @@ class HRService {
return `${prefix}${next.toString().padStart(4, '0')}`;
}
async findAllLoans(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) {
const skip = (page - 1) * pageSize;
const where: any = {};
if (filters.employeeId) where.employeeId = filters.employeeId;
if (filters.status && filters.status !== 'all') where.status = filters.status;
const [total, loans] = await Promise.all([
prisma.loan.count({ where }),
prisma.loan.findMany({
where,
skip,
take: pageSize,
include: { employee: { select: { id: true, firstName: true, lastName: true, uniqueEmployeeId: true } }, installmentsList: true },
orderBy: { createdAt: 'desc' },
}),
]);
return { loans, total, page, pageSize };
}
async findAllLoans(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) {
const skip = (page - 1) * pageSize;
const where: any = {};
if (filters.employeeId) where.employeeId = filters.employeeId;
if (filters.status && filters.status !== 'all') where.status = filters.status;
const [total, loans] = await Promise.all([
prisma.loan.count({ where }),
prisma.loan.findMany({
where,
skip,
take: pageSize,
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
basicSalary: true,
position: {
select: {
code: true,
title: true,
titleAr: true,
},
},
},
},
installmentsList: true,
},
orderBy: { createdAt: 'desc' },
}),
]);
return { loans, total, page, pageSize };
}
async findLoanById(id: string) {
const loan = await prisma.loan.findUnique({
where: { id },
include: { employee: true, installmentsList: { orderBy: { installmentNumber: 'asc' } } },
});
if (!loan) throw new AppError(404, 'القرض غير موجود - Loan not found');
return loan;
}
const loan = await prisma.loan.findUnique({
where: { id },
include: {
employee: {
select: {
id: true,
firstName: true,
lastName: true,
uniqueEmployeeId: true,
basicSalary: true,
position: {
select: {
code: true,
title: true,
titleAr: true,
},
},
},
},
installmentsList: { orderBy: { installmentNumber: 'asc' } },
},
});
if (!loan) throw new AppError(404, 'القرض غير موجود - Loan not found');
return loan;
}
private isSystemAdminUser(user: any) {
const positionCode = user?.employee?.position?.code?.toUpperCase?.() || '';
const positionTitle = user?.employee?.position?.title?.toUpperCase?.() || '';
const positionTitleAr = user?.employee?.position?.titleAr || '';
return (
positionCode === 'SYS_ADMIN' ||
positionCode === 'SYSTEM_ADMIN' ||
positionTitle === 'SYSTEM ADMINISTRATOR' ||
positionTitleAr === 'مدير النظام'
);
}
async createLoan(data: { employeeId: string; type: string; amount: number; installments?: number; reason?: string }, userId: string) {
const loanNumber = await this.generateLoanNumber();
const installments = data.installments || 1;
const monthlyAmount = data.amount / installments;
const loan = await prisma.loan.create({
data: {
loanNumber,
employeeId: data.employeeId,
type: data.type,
amount: data.amount,
installments,
monthlyAmount,
reason: data.reason,
status: 'PENDING',
},
include: { employee: true },
});
await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId });
return loan;
if (!data.reason || !data.reason.trim()) {
throw new AppError(400, 'سبب القرض مطلوب - Loan reason is required');
}
if (!data.amount || Number(data.amount) <= 0) {
throw new AppError(400, 'مبلغ القرض غير صالح - Invalid loan amount');
}
const loanNumber = await this.generateLoanNumber();
const installments = data.installments || 1;
const monthlyAmount = data.amount / installments;
const loan = await prisma.loan.create({
data: {
loanNumber,
employeeId: data.employeeId,
type: data.type,
amount: data.amount,
installments,
monthlyAmount,
reason: data.reason.trim(),
status: 'PENDING_HR',
},
include: { employee: true },
});
await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId });
return loan;
}
async approveLoan(id: string, approvedBy: string, startDate: Date, userId: string) {
const loan = await prisma.loan.findUnique({ where: { id }, include: { installmentsList: true } });
if (!loan) throw new AppError(404, 'القرض غير موجود - Loan not found');
if (loan.status !== 'PENDING') throw new AppError(400, 'لا يمكن الموافقة على قرض غير معلق - Cannot approve non-pending loan');
const loan = await prisma.loan.findUnique({
where: { id },
include: {
installmentsList: true,
employee: {
select: {
id: true,
basicSalary: true,
},
},
},
});
const monthlyAmount = Number(loan.monthlyAmount || loan.amount) / loan.installments;
const installments: { installmentNumber: number; dueDate: Date; amount: number }[] = [];
let d = new Date(startDate);
for (let i = 1; i <= loan.installments; i++) {
installments.push({ installmentNumber: i, dueDate: new Date(d), amount: monthlyAmount });
d.setMonth(d.getMonth() + 1);
}
const endDate = installments.length ? installments[installments.length - 1].dueDate : null;
await prisma.$transaction([
prisma.loan.update({
where: { id },
data: { status: 'ACTIVE', approvedBy, approvedAt: new Date(), startDate, endDate },
}),
...installments.map((inst) =>
prisma.loanInstallment.create({
data: { loanId: id, installmentNumber: inst.installmentNumber, dueDate: inst.dueDate, amount: inst.amount, status: 'PENDING' },
})
),
]);
await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: 'APPROVE', userId });
return this.findLoanById(id);
if (!loan) {
throw new AppError(404, 'القرض غير موجود - Loan not found');
}
if (!['PENDING_HR', 'PENDING_ADMIN'].includes(loan.status)) {
throw new AppError(400, 'لا يمكن الموافقة على هذا القرض بهذه الحالة - Cannot approve this loan in current status');
}
const approverUser = await prisma.user.findUnique({
where: { id: approvedBy },
include: {
employee: {
include: {
position: true,
},
},
},
});
if (!approverUser) {
throw new AppError(404, 'المستخدم غير موجود - User not found');
}
const isSystemAdmin = this.isSystemAdminUser(approverUser);
const basicSalary = Number(loan.employee?.basicSalary || 0);
const loanAmount = Number(loan.amount || 0);
const needsAdminApproval = basicSalary > 0 && loanAmount > basicSalary * 0.5;
// المرحلة الأولى: HR approval
if (loan.status === 'PENDING_HR') {
if (needsAdminApproval) {
const updatedLoan = await prisma.loan.update({
where: { id },
data: {
status: 'PENDING_ADMIN',
},
});
await AuditLogger.log({
entityType: 'LOAN',
entityId: id,
action: 'HR_APPROVE_FORWARD_TO_ADMIN',
userId,
});
return updatedLoan;
}
}
// المرحلة الثانية: Admin approval إذا تجاوز 50%
if (loan.status === 'PENDING_ADMIN' && !isSystemAdmin) {
throw new AppError(403, 'هذا الطلب يحتاج موافقة مدير النظام - System Administrator approval required');
}
const monthlyAmount = Number(loan.monthlyAmount || loan.amount) / loan.installments;
const installments: { installmentNumber: number; dueDate: Date; amount: number }[] = [];
let d = new Date(startDate);
for (let i = 1; i <= loan.installments; i++) {
installments.push({
installmentNumber: i,
dueDate: new Date(d),
amount: monthlyAmount,
});
d.setMonth(d.getMonth() + 1);
}
const endDate = installments.length ? installments[installments.length - 1].dueDate : null;
await prisma.$transaction([
prisma.loan.update({
where: { id },
data: {
status: 'ACTIVE',
approvedBy,
approvedAt: new Date(),
startDate,
endDate,
},
}),
...installments.map((inst) =>
prisma.loanInstallment.create({
data: {
loanId: id,
installmentNumber: inst.installmentNumber,
dueDate: inst.dueDate,
amount: inst.amount,
status: 'PENDING',
},
})
),
]);
await AuditLogger.log({
entityType: 'LOAN',
entityId: id,
action: loan.status === 'PENDING_ADMIN' ? 'ADMIN_APPROVE' : 'APPROVE',
userId,
});
return this.findLoanById(id);
}
async rejectLoan(id: string, rejectedReason: string, userId: string) {
const 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;
const existing = await prisma.loan.findUnique({ where: { id } });
if (!existing) {
throw new AppError(404, 'القرض غير موجود - Loan not found');
}
if (!['PENDING_HR', 'PENDING_ADMIN'].includes(existing.status)) {
throw new AppError(400, 'لا يمكن رفض هذا القرض بهذه الحالة - Cannot reject this loan in current status');
}
const loan = await prisma.loan.update({
where: { id },
data: { status: 'REJECTED', rejectedReason },
include: { employee: true },
});
await AuditLogger.log({
entityType: 'LOAN',
entityId: id,
action: 'REJECT',
userId,
reason: rejectedReason,
});
return loan;
}
async recordLoanInstallmentPayment(loanId: string, installmentId: string, paidDate: Date, userId: string) {
await prisma.loanInstallment.update({
where: { id: installmentId },
@@ -751,16 +1120,41 @@ class HRService {
});
}
async upsertLeaveEntitlement(data: { employeeId: string; year: number; leaveType: string; totalDays: number; carriedOver?: number; notes?: string }, userId: string) {
const ent = await prisma.leaveEntitlement.upsert({
where: { employeeId_year_leaveType: { employeeId: data.employeeId, year: data.year, leaveType: data.leaveType } },
create: { employeeId: data.employeeId, year: data.year, leaveType: data.leaveType, totalDays: data.totalDays, carriedOver: data.carriedOver || 0, notes: data.notes },
update: { totalDays: data.totalDays, carriedOver: data.carriedOver ?? undefined, notes: data.notes },
});
await AuditLogger.log({ entityType: 'LEAVE_ENTITLEMENT', entityId: ent.id, action: 'UPSERT', userId });
return ent;
async upsertLeaveEntitlement(data: { employeeId: string; year: number; leaveType: string; totalDays: number; carriedOver?: number; notes?: string }, userId: string) {
const allowedLeaveTypes = ['ANNUAL', 'HOURLY'];
const normalizedLeaveType = String(data.leaveType || '').toUpperCase();
if (!allowedLeaveTypes.includes(normalizedLeaveType)) {
throw new AppError(400, 'نوع رصيد الإجازة غير مدعوم - Only ANNUAL and HOURLY leave entitlement types are allowed');
}
const ent = await prisma.leaveEntitlement.upsert({
where: {
employeeId_year_leaveType: {
employeeId: data.employeeId,
year: data.year,
leaveType: normalizedLeaveType,
},
},
create: {
employeeId: data.employeeId,
year: data.year,
leaveType: normalizedLeaveType,
totalDays: data.totalDays,
carriedOver: data.carriedOver || 0,
notes: data.notes,
},
update: {
totalDays: data.totalDays,
carriedOver: data.carriedOver ?? undefined,
notes: data.notes,
},
});
await AuditLogger.log({ entityType: 'LEAVE_ENTITLEMENT', entityId: ent.id, action: 'UPSERT', userId });
return ent;
}
// ========== EMPLOYEE CONTRACTS ==========
private async generateContractNumber(): Promise<string> {

View File

@@ -49,6 +49,100 @@ export class PortalController {
next(error);
}
}
async getManagedLeaves(req: AuthRequest, res: Response, next: NextFunction) {
try {
const status = req.query.status as string | undefined;
const leaves = await portalService.getManagedLeaves(req.user?.employeeId, status);
res.json(ResponseFormatter.success(leaves));
} catch (error) {
next(error);
}
}
async approveManagedLeave(req: AuthRequest, res: Response, next: NextFunction) {
try {
const leave = await portalService.approveManagedLeave(req.user?.employeeId, req.params.id, req.user!.id);
res.json(ResponseFormatter.success(leave, 'تمت الموافقة على الإجازة من قبل مدير القسم - Leave approved by department manager'));
} catch (error) {
next(error);
}
}
async rejectManagedLeave(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { rejectedReason } = req.body;
const leave = await portalService.rejectManagedLeave(
req.user?.employeeId,
req.params.id,
rejectedReason || '',
req.user!.id
);
res.json(ResponseFormatter.success(leave, 'تم رفض طلب الإجازة من قبل مدير القسم - Leave rejected by department manager'));
} catch (error) {
next(error);
}
}
async getMyOvertimeRequests(req: AuthRequest, res: Response, next: NextFunction) {
try {
const data = await portalService.getMyOvertimeRequests(req.user?.employeeId);
res.json(ResponseFormatter.success(data));
} catch (error) {
next(error);
}
}
async submitOvertimeRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const data = {
date: req.body.date,
hours: req.body.hours,
reason: req.body.reason,
};
const result = await portalService.submitOvertimeRequest(req.user?.employeeId, data, req.user!.id);
res.status(201).json(ResponseFormatter.success(result, 'تم إرسال طلب الساعات الإضافية'));
} catch (error) {
next(error);
}
}
async getManagedOvertimeRequests(req: AuthRequest, res: Response, next: NextFunction) {
try {
const data = await portalService.getManagedOvertimeRequests(req.user?.employeeId);
res.json(ResponseFormatter.success(data));
} catch (error) {
next(error);
}
}
async approveManagedOvertimeRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const result = await portalService.approveManagedOvertimeRequest(
req.user?.employeeId,
req.params.attendanceId,
req.user!.id
);
res.json(ResponseFormatter.success(result, 'تمت الموافقة على طلب الساعات الإضافية'));
} catch (error) {
next(error);
}
}
async rejectManagedOvertimeRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const result = await portalService.rejectManagedOvertimeRequest(
req.user?.employeeId,
req.params.attendanceId,
req.body.rejectedReason || '',
req.user!.id
);
res.json(ResponseFormatter.success(result, 'تم رفض طلب الساعات الإضافية'));
} catch (error) {
next(error);
}
}
async submitLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {

View File

@@ -26,7 +26,10 @@ class PortalService {
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([
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) {
const empId = this.requireEmployeeId(employeeId);
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);
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);
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);
return hrService.createPurchaseRequest({ ...data, employeeId: empId }, userId);
}
@@ -111,4 +463,4 @@ class PortalService {
}
}
export const portalService = new PortalService();
export const portalService = new PortalService();

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import hrRoutes from '../modules/hr/hr.routes';
import inventoryRoutes from '../modules/inventory/inventory.routes';
import projectsRoutes from '../modules/projects/projects.routes';
import marketingRoutes from '../modules/marketing/marketing.routes';
import tendersRoutes from '../modules/tenders/tenders.routes';
const router = Router();
@@ -21,6 +22,7 @@ router.use('/hr', hrRoutes);
router.use('/inventory', inventoryRoutes);
router.use('/projects', projectsRoutes);
router.use('/marketing', marketingRoutes);
router.use('/tenders', tendersRoutes);
// API info
router.get('/', (req, res) => {
@@ -36,6 +38,7 @@ router.get('/', (req, res) => {
'Inventory & Assets',
'Tasks & Projects',
'Marketing',
'Tender Management',
],
});
});

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -10,9 +10,13 @@ import LoadingSpinner from '@/components/LoadingSpinner';
const MODULES = [
{ id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' },
{ id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' },
{ id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' },
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
{ id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' },
{ id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' },
{ id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية', nameEn: 'Department Overtime Requests' },
{ id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' },
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
];

View File

@@ -11,9 +11,13 @@ import LoadingSpinner from '@/components/LoadingSpinner';
const MODULES = [
{ id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' },
{ id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' },
{ id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' },
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
{ id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' },
{ id: 'department_leave_requests', name: 'طلبات إجازات القسم', nameEn: 'Department Leave Requests' },
{ id: 'department_overtime_requests', name: 'طلبات الساعات الإضافية', nameEn: 'Department Overtime Requests' },
{ id: 'portal', name: 'البوابة الذاتية', nameEn: 'My Portal' },
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
];

View File

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

View File

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

View File

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

View File

@@ -725,7 +725,7 @@ function CRMContent() {
<DollarSign className="h-8 w-8 text-blue-600" />
</div>
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200">
<div className="flex items-center justify-between">
@@ -1113,7 +1113,7 @@ function CRMContent() {
</div>
</div>
)}
{/* Lose Dialog */}
{showLoseDialog && selectedDeal && (
<div className="fixed inset-0 z-50 overflow-y-auto">

View File

@@ -1,6 +1,8 @@
'use client'
import { useState, useEffect } from 'react'
import Image from 'next/image'
import logoImage from '@/assets/logo.png'
import ProtectedRoute from '@/components/ProtectedRoute'
import { useAuth } from '@/contexts/AuthContext'
import { useLanguage } from '@/contexts/LanguageContext'
@@ -18,7 +20,8 @@ import {
Building2,
Settings,
Bell,
Shield
Shield,
FileText
} from 'lucide-react'
import { dashboardAPI } from '@/lib/api'
@@ -56,6 +59,16 @@ function DashboardContent() {
description: 'الفرص التجارية والعروض والصفقات',
permission: 'crm'
},
{
id: 'tenders',
name: 'إدارة المناقصات',
nameEn: 'Tender Management',
icon: FileText,
color: 'bg-indigo-500',
href: '/tenders',
description: 'تسجيل ومتابعة المناقصات وتحويلها إلى فرص',
permission: 'tenders'
},
{
id: 'inventory',
name: 'المخزون والأصول',
@@ -94,7 +107,7 @@ function DashboardContent() {
color: 'bg-cyan-500',
href: '/portal',
description: 'قروضي، إجازاتي، طلبات الشراء والرواتب',
permission: 'hr'
permission: 'portal'
},
{
id: 'marketing',
@@ -130,11 +143,16 @@ function DashboardContent() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="bg-primary-600 p-2 rounded-lg">
<Building2 className="h-8 w-8 text-white" />
</div>
<Image
src={logoImage}
alt="Company Logo"
width={48}
height={48}
className="object-contain"
priority
/>
<div>
<h1 className="text-2xl font-bold text-gray-900">Z.CRM</h1>
<h1 className="text-2xl font-bold text-gray-900">ATMATA</h1>
<p className="text-sm text-gray-600">نظام إدارة علاقات العملاء</p>
</div>
</div>

View File

@@ -277,7 +277,7 @@ function HRContent() {
mobile: '',
dateOfBirth: '',
gender: '',
nationality: 'Saudi Arabia',
nationality: 'Syria',
nationalId: '',
employmentType: 'FULL_TIME',
contractType: 'UNLIMITED',
@@ -372,8 +372,8 @@ function HRContent() {
const { leaves } = await hrAdminAPI.getLeaves({ status: 'PENDING', pageSize: 50 })
setLeavesData(leaves)
} else if (activeTab === 'loans') {
const { loans } = await hrAdminAPI.getLoans({ status: 'PENDING', pageSize: 50 })
setLoansData(loans)
const { loans } = await hrAdminAPI.getLoans({ pageSize: 50 })
setLoansData(loans.filter((loan: any) => ['PENDING_HR', 'PENDING_ADMIN'].includes(loan.status)))
} else if (activeTab === 'purchases') {
const { purchaseRequests } = await hrAdminAPI.getPurchaseRequests({ status: 'PENDING', pageSize: 50 })
setPurchasesData(purchaseRequests)
@@ -1185,19 +1185,75 @@ function HRContent() {
<p className="text-gray-500">No pending loans</p>
) : (
<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>
<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>
{l.reason && <p className="text-xs text-gray-500">{l.reason}</p>}
<p className="text-sm text-gray-600">
{l.loanNumber} - {l.type} - {amount.toLocaleString()} SAR ({l.installments} installments)
</p>
<p className="text-xs text-gray-500">
الراتب الأساسي: {salary.toLocaleString()} SAR
</p>
<p className="text-xs text-gray-500">
الحالة: {l.status === 'PENDING_HR' ? 'بانتظار HR' : 'بانتظار مدير النظام'}
</p>
{needsAdmin && (
<p className="text-xs text-orange-600">
هذا الطلب يتجاوز 50% من الراتب الأساسي ويحتاج موافقة مدير النظام
</p>
)}
{l.reason && <p className="text-xs text-gray-500 mt-1">{l.reason}</p>}
</div>
<div className="flex gap-2">
<button onClick={async () => { try { 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 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>
<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>

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="4" fill="#2563eb"/>
<text x="16" y="22" text-anchor="middle" fill="white" font-size="14" font-family="sans-serif" font-weight="bold">Z</text>
</svg>

Before

Width:  |  Height:  |  Size: 247 B

View File

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

View File

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

View File

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

View File

@@ -13,18 +13,31 @@ import {
DollarSign,
Building2,
LogOut,
User
User,
CheckCircle2,
TimerReset,
} from 'lucide-react'
function PortalLayoutContent({ children }: { children: React.ReactNode }) {
const { user, logout } = useAuth()
const { user, logout, hasPermission } = useAuth()
const pathname = usePathname()
const menuItems = [
{ icon: LayoutDashboard, label: 'لوحة التحكم', labelEn: 'Dashboard', href: '/portal', exact: true },
{ icon: Banknote, label: 'قروضي', labelEn: 'My Loans', href: '/portal/loans' },
{ icon: Calendar, label: 'إجازاتي', labelEn: 'My Leave', href: '/portal/leave' },
{ icon: 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: DollarSign, label: 'رواتبي', labelEn: 'My Salaries', href: '/portal/salaries' },
]

View File

@@ -5,13 +5,11 @@ import { portalAPI } from '@/lib/api/portal'
import Modal from '@/components/Modal'
import LoadingSpinner from '@/components/LoadingSpinner'
import { toast } from 'react-hot-toast'
import { Calendar, Plus } from 'lucide-react'
import { Plus } from 'lucide-react'
const LEAVE_TYPES = [
{ value: 'ANNUAL', label: 'إجازة سنوية' },
{ value: 'SICK', label: 'إجازة مرضية' },
{ value: 'EMERGENCY', label: 'طوارئ' },
{ value: 'UNPAID', label: 'بدون راتب' },
{ value: 'HOURLY', label: 'إجازة ساعية' },
]
const STATUS_MAP: Record<string, { label: string; color: string }> = {
@@ -26,7 +24,16 @@ export default function PortalLeavePage() {
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [form, setForm] = useState({ leaveType: 'ANNUAL', startDate: '', endDate: '', reason: '' })
const [form, setForm] = useState({
leaveType: 'ANNUAL',
startDate: '',
endDate: '',
leaveDate: '',
startTime: '',
endTime: '',
reason: '',
})
const load = () => {
setLoading(true)
@@ -43,24 +50,54 @@ export default function PortalLeavePage() {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!form.startDate || !form.endDate) {
toast.error('أدخل تاريخ البداية والنهاية')
return
}
if (new Date(form.endDate) < new Date(form.startDate)) {
toast.error('تاريخ النهاية يجب أن يكون بعد تاريخ البداية')
return
}
setSubmitting(true)
portalAPI.submitLeaveRequest({
let payload: any = {
leaveType: form.leaveType,
startDate: form.startDate,
endDate: form.endDate,
reason: form.reason || undefined,
})
}
if (form.leaveType === 'ANNUAL') {
if (!form.startDate || !form.endDate) {
toast.error('أدخل تاريخ البداية والنهاية')
return
}
if (new Date(form.endDate) < new Date(form.startDate)) {
toast.error('تاريخ النهاية يجب أن يكون بعد البداية')
return
}
payload.startDate = form.startDate
payload.endDate = form.endDate
} else {
if (!form.leaveDate || !form.startTime || !form.endTime) {
toast.error('أدخل التاريخ والوقت للإجازة الساعية')
return
}
if (form.startTime >= form.endTime) {
toast.error('وقت النهاية يجب أن يكون بعد البداية')
return
}
payload.startDate = `${form.leaveDate}T${form.startTime}:00`
payload.endDate = `${form.leaveDate}T${form.endTime}:00`
}
setSubmitting(true)
portalAPI.submitLeaveRequest(payload)
.then(() => {
setShowModal(false)
setForm({ leaveType: 'ANNUAL', startDate: '', endDate: '', reason: '' })
setForm({
leaveType: 'ANNUAL',
startDate: '',
endDate: '',
leaveDate: '',
startTime: '',
endTime: '',
reason: '',
})
toast.success('تم إرسال طلب الإجازة')
load()
})
@@ -72,6 +109,8 @@ export default function PortalLeavePage() {
return (
<div className="space-y-6">
{/* HEADER */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">إجازاتي</h1>
<button
@@ -83,6 +122,7 @@ export default function PortalLeavePage() {
</button>
</div>
{/* الرصيد */}
{leaveBalance.length > 0 && (
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
<h2 className="text-lg font-semibold text-gray-900 mb-4">رصيد الإجازات</h2>
@@ -90,36 +130,42 @@ export default function PortalLeavePage() {
{leaveBalance.map((b) => (
<div key={b.leaveType} className="border rounded-lg p-4">
<p className="text-sm text-gray-600">
{b.leaveType === 'ANNUAL' ? 'سنوية' : b.leaveType === 'SICK' ? 'مرضية' : b.leaveType}
{b.leaveType === 'ANNUAL' ? 'سنوية' : b.leaveType}
</p>
<p className="text-2xl font-bold text-teal-600 mt-1">{b.available} يوم</p>
<p className="text-xs text-gray-500">من {b.totalDays + b.carriedOver} (مستخدم: {b.usedDays})</p>
</div>
))}
</div>
</div>
)}
{/* الطلبات */}
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
<h2 className="text-lg font-semibold text-gray-900 mb-4">طلباتي</h2>
{leaves.length === 0 ? (
<p className="text-gray-500 text-center py-8">لا توجد طلبات إجازة</p>
<p className="text-gray-500 text-center py-8">لا توجد طلبات</p>
) : (
<div className="space-y-3">
{leaves.map((l) => {
const statusInfo = STATUS_MAP[l.status] || { label: l.status, color: 'bg-gray-100' }
return (
<div key={l.id} className="flex justify-between items-center py-3 border-b last:border-0">
<div key={l.id} className="flex justify-between items-center py-3 border-b">
<div>
<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 className="text-sm text-gray-600">
{new Date(l.startDate).toLocaleDateString('ar-SA')} - {new Date(l.endDate).toLocaleDateString('ar-SA')}
</p>
{l.rejectedReason && <p className="text-sm text-red-600">سبب الرفض: {l.rejectedReason}</p>}
</div>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
<span className={`px-3 py-1 rounded-full text-xs ${statusInfo.color}`}>
{statusInfo.label}
</span>
</div>
@@ -129,61 +175,118 @@ export default function PortalLeavePage() {
)}
</div>
{/* الفورم */}
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="طلب إجازة جديد">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">نوع الإجازة</label>
<select
value={form.leaveType}
onChange={(e) => setForm((p) => ({ ...p, leaveType: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
>
{LEAVE_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">من تاريخ</label>
<input
type="date"
value={form.startDate}
onChange={(e) => setForm((p) => ({ ...p, startDate: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
required
/>
{/* نوع الإجازة */}
<select
value={form.leaveType}
onChange={(e) =>
setForm({
leaveType: e.target.value,
startDate: '',
endDate: '',
leaveDate: '',
startTime: '',
endTime: '',
reason: '',
})
}
className="w-full px-3 py-2 border rounded-lg"
>
{LEAVE_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
{/* سنوية */}
{form.leaveType === 'ANNUAL' ? (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm">من تاريخ</label>
<input
type="date"
value={form.startDate}
onChange={(e) => setForm(p => ({ ...p, startDate: e.target.value }))}
className="border p-2 rounded w-full"
/>
</div>
<div>
<label className="text-sm">إلى تاريخ</label>
<input
type="date"
value={form.endDate}
onChange={(e) => setForm(p => ({ ...p, endDate: e.target.value }))}
className="border p-2 rounded w-full"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">إلى تاريخ</label>
<input
type="date"
value={form.endDate}
onChange={(e) => setForm((p) => ({ ...p, endDate: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
required
/>
) : (
/* ساعية */
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-sm">التاريخ</label>
<input
type="date"
value={form.leaveDate}
onChange={(e) => setForm(p => ({ ...p, leaveDate: e.target.value }))}
className="border p-2 rounded w-full"
/>
</div>
<div>
<label className="text-sm">من الساعة</label>
<input
type="time"
value={form.startTime}
onChange={(e) => setForm(p => ({ ...p, startTime: e.target.value }))}
className="border p-2 rounded w-full"
/>
</div>
<div>
<label className="text-sm">إلى الساعة</label>
<input
type="time"
value={form.endTime}
onChange={(e) => setForm(p => ({ ...p, endTime: e.target.value }))}
className="border p-2 rounded w-full"
/>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">السبب</label>
<textarea
value={form.reason}
onChange={(e) => setForm((p) => ({ ...p, reason: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
rows={3}
/>
</div>
)}
{/* السبب */}
<textarea
placeholder="اكتب سبب الإجازة..."
value={form.reason}
onChange={(e) => setForm(p => ({ ...p, reason: e.target.value }))}
className="w-full border p-2 rounded"
/>
{/* أزرار */}
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setShowModal(false)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">
<button
type="button"
onClick={() => setShowModal(false)}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg"
>
إلغاء
</button>
<button type="submit" disabled={submitting} className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50">
<button
type="submit"
disabled={submitting}
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
>
{submitting ? 'جاري الإرسال...' : 'إرسال الطلب'}
</button>
</div>
</form>
</Modal>
</div>
)
}
}

View File

@@ -8,8 +8,8 @@ import { toast } from 'react-hot-toast'
import { Banknote, Plus, ChevronLeft } from 'lucide-react'
const STATUS_MAP: Record<string, { label: string; color: string }> = {
PENDING: { label: 'قيد المراجعة', color: 'bg-amber-100 text-amber-800' },
APPROVED: { label: 'معتمد', color: 'bg-green-100 text-green-800' },
PENDING_HR: { label: 'بانتظار موافقة الموارد البشرية', color: 'bg-amber-100 text-amber-800' },
PENDING_ADMIN: { label: 'بانتظار موافقة مدير النظام', color: 'bg-orange-100 text-orange-800' },
REJECTED: { label: 'مرفوض', color: 'bg-red-100 text-red-800' },
ACTIVE: { label: 'نشط', color: 'bg-blue-100 text-blue-800' },
PAID_OFF: { label: 'مسدد', color: 'bg-gray-100 text-gray-800' },
@@ -32,17 +32,24 @@ export default function PortalLoansPage() {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const amount = parseFloat(form.amount)
if (!amount || amount <= 0) {
toast.error('أدخل مبلغاً صالحاً')
return
}
if (!form.reason.trim()) {
toast.error('سبب القرض مطلوب')
return
}
setSubmitting(true)
portalAPI.submitLoanRequest({
type: form.type,
amount,
installments: parseInt(form.installments) || 1,
reason: form.reason || undefined,
})
reason: form.reason.trim(),
})
.then((loan) => {
setLoans((prev) => [loan, ...prev])
setShowModal(false)
@@ -162,6 +169,7 @@ export default function PortalLoansPage() {
onChange={(e) => setForm((p) => ({ ...p, reason: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
rows={3}
required
/>
</div>
<div className="flex justify-end gap-2">

View File

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

View File

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

View File

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

View File

@@ -2,12 +2,14 @@
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { useAuth } from '@/contexts/AuthContext'
import { portalAPI, type PortalProfile } from '@/lib/api/portal'
import LoadingSpinner from '@/components/LoadingSpinner'
import { Banknote, Calendar, ShoppingCart, Clock, Plus, ArrowLeft } from 'lucide-react'
import { Banknote, Calendar, ShoppingCart, Clock, Plus, ArrowLeft, CheckCircle2 } from 'lucide-react'
import { toast } from 'react-hot-toast'
export default function PortalDashboardPage() {
const { hasPermission } = useAuth()
const [data, setData] = useState<PortalProfile | null>(null)
const [loading, setLoading] = useState(true)
@@ -22,6 +24,8 @@ export default function PortalDashboardPage() {
if (!data) return <div className="text-center text-gray-500 py-12">لا توجد بيانات</div>
const { employee, stats } = data
const canViewDepartmentLeaveRequests = hasPermission('department_leave_requests', 'view')
const name = employee.firstNameAr && employee.lastNameAr
? `${employee.firstNameAr} ${employee.lastNameAr}`
: `${employee.firstName} ${employee.lastName}`
@@ -35,7 +39,7 @@ export default function PortalDashboardPage() {
</p>
</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="flex items-center justify-between">
<div>
@@ -53,6 +57,23 @@ export default function PortalDashboardPage() {
</Link>
</div>
{canViewDepartmentLeaveRequests && (
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">طلبات إجازات القسم</p>
<p className="text-sm text-gray-500 mt-1">مراجعة واعتماد</p>
</div>
<div className="bg-emerald-100 p-3 rounded-lg">
<CheckCircle2 className="h-6 w-6 text-emerald-600" />
</div>
</div>
<Link href="/portal/managed-leaves" className="mt-4 text-sm text-emerald-600 hover:underline flex items-center gap-1">
عرض الطلبات <ArrowLeft className="h-4 w-4" />
</Link>
</div>
)}
<div className="bg-white rounded-xl shadow p-6 border border-gray-100">
<div className="flex items-center justify-between">
<div>
@@ -118,7 +139,9 @@ export default function PortalDashboardPage() {
<tbody>
{stats.leaveBalance.map((b) => (
<tr key={b.leaveType} className="border-b last:border-0">
<td className="py-2">{b.leaveType === 'ANNUAL' ? 'سنوية' : b.leaveType === 'SICK' ? 'مرضية' : b.leaveType}</td>
<td className="py-2">
{b.leaveType === 'ANNUAL' ? 'سنوية' : b.leaveType === 'HOURLY' ? 'ساعية' : b.leaveType}
</td>
<td className="py-2">{b.totalDays + b.carriedOver}</td>
<td className="py-2">{b.usedDays}</td>
<td className="py-2 font-semibold text-teal-600">{b.available}</td>
@@ -131,28 +154,28 @@ export default function PortalDashboardPage() {
)}
<div className="flex flex-wrap gap-4">
<Link
href="/portal/loans"
className="inline-flex items-center gap-2 px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700"
>
<Link href="/portal/loans" className="inline-flex items-center gap-2 px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700">
<Plus className="h-4 w-4" />
طلب قرض
</Link>
<Link
href="/portal/leave"
className="inline-flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"
>
<Link href="/portal/leave" className="inline-flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700">
<Plus className="h-4 w-4" />
طلب إجازة
</Link>
<Link
href="/portal/purchase-requests"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
{canViewDepartmentLeaveRequests && (
<Link href="/portal/managed-leaves" className="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700">
<CheckCircle2 className="h-4 w-4" />
طلبات إجازات القسم
</Link>
)}
<Link href="/portal/purchase-requests" className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
<Plus className="h-4 w-4" />
طلب شراء
</Link>
</div>
</div>
)
}
}

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -66,6 +66,24 @@ export interface Leave {
createdAt: string
}
export interface PortalOvertimeRequest {
id: string
attendanceId: string
date: string
hours: number
reason: string
status: string
rejectedReason?: string
createdAt: string
employee?: {
id: string
firstName: string
lastName: string
uniqueEmployeeId: string
reportingToId?: string
}
}
export interface PurchaseRequest {
id: string
requestNumber: string
@@ -90,6 +108,24 @@ export interface Attendance {
status: string
}
export interface ManagedLeave {
id: string
leaveType: string
startDate: string
endDate: string
days: number
status: string
reason?: string
rejectedReason?: string
employee: {
id: string
firstName: string
lastName: string
uniqueEmployeeId: string
reportingToId?: string
}
}
export interface Salary {
id: string
month: number
@@ -106,6 +142,24 @@ export interface Salary {
}
export const portalAPI = {
getManagedLeaves: async (status?: string): Promise<ManagedLeave[]> => {
const q = new URLSearchParams()
if (status && status !== 'all') q.append('status', status)
const response = await api.get(`/hr/portal/managed-leaves?${q.toString()}`)
return response.data.data || []
},
approveManagedLeave: async (id: string) => {
const response = await api.post(`/hr/portal/managed-leaves/${id}/approve`)
return response.data.data
},
rejectManagedLeave: async (id: string, rejectedReason: string) => {
const response = await api.post(`/hr/portal/managed-leaves/${id}/reject`, { rejectedReason })
return response.data.data
},
getMe: async (): Promise<PortalProfile> => {
const response = await api.get('/hr/portal/me')
return response.data.data
@@ -121,6 +175,40 @@ export const portalAPI = {
return response.data.data
},
getOvertimeRequests: async (): Promise<PortalOvertimeRequest[]> => {
const response = await api.get('/hr/portal/overtime-requests')
return response.data.data || []
},
submitOvertimeRequest: async (data: {
date: string
hours: number
reason: string
}): Promise<PortalOvertimeRequest> => {
const response = await api.post('/hr/portal/overtime-requests', data)
return response.data.data
},
getManagedOvertimeRequests: async (): Promise<PortalOvertimeRequest[]> => {
const response = await api.get('/hr/portal/managed-overtime-requests')
return response.data.data || []
},
approveManagedOvertimeRequest: async (attendanceId: string): Promise<PortalOvertimeRequest> => {
const response = await api.post(`/hr/portal/managed-overtime-requests/${attendanceId}/approve`)
return response.data.data
},
rejectManagedOvertimeRequest: async (
attendanceId: string,
rejectedReason: string
): Promise<PortalOvertimeRequest> => {
const response = await api.post(`/hr/portal/managed-overtime-requests/${attendanceId}/reject`, {
rejectedReason,
})
return response.data.data
},
getLeaveBalance: async (year?: number): Promise<PortalProfile['stats']['leaveBalance']> => {
const params = year ? `?year=${year}` : ''
const response = await api.get(`/hr/portal/leave-balance${params}`)

View File

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