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
This commit is contained in:
Talal Sharabi
2026-03-11 16:57:40 +04:00
parent 18c13cdf7c
commit 4c139429e2
14 changed files with 2623 additions and 17 deletions

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

View File

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

View File

@@ -0,0 +1,232 @@
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);
}
}
}
export const tendersController = new TendersController();

View File

@@ -0,0 +1,175 @@
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 },
});
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;

View File

@@ -0,0 +1,538 @@
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';
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;
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}`;
}
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.bondValue);
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);
const tender = await prisma.tender.create({
data: {
tenderNumber,
issuingBodyName: data.issuingBodyName.trim(),
title: data.title.trim(),
termsValue: data.termsValue,
bondValue: data.bondValue,
announcementDate,
closingDate,
announcementLink: data.announcementLink?.trim() || null,
source: data.source,
sourceOther: data.sourceOther?.trim() || null,
announcementType: data.announcementType,
notes: data.notes?.trim() || null,
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, possibleDuplicates: possibleDuplicates.length ? possibleDuplicates : 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, 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 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 = {};
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) updateData.bondValue = data.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) updateData.notes = data.notes?.trim() || null;
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 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;
}
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 inventoryRoutes from '../modules/inventory/inventory.routes';
import projectsRoutes from '../modules/projects/projects.routes'; import projectsRoutes from '../modules/projects/projects.routes';
import marketingRoutes from '../modules/marketing/marketing.routes'; import marketingRoutes from '../modules/marketing/marketing.routes';
import tendersRoutes from '../modules/tenders/tenders.routes';
const router = Router(); const router = Router();
@@ -21,6 +22,7 @@ router.use('/hr', hrRoutes);
router.use('/inventory', inventoryRoutes); router.use('/inventory', inventoryRoutes);
router.use('/projects', projectsRoutes); router.use('/projects', projectsRoutes);
router.use('/marketing', marketingRoutes); router.use('/marketing', marketingRoutes);
router.use('/tenders', tendersRoutes);
// API info // API info
router.get('/', (req, res) => { router.get('/', (req, res) => {
@@ -36,6 +38,7 @@ router.get('/', (req, res) => {
'Inventory & Assets', 'Inventory & Assets',
'Tasks & Projects', 'Tasks & Projects',
'Marketing', 'Marketing',
'Tender Management',
], ],
}); });
}); });

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

@@ -18,7 +18,8 @@ import {
Building2, Building2,
Settings, Settings,
Bell, Bell,
Shield Shield,
FileText
} from 'lucide-react' } from 'lucide-react'
import { dashboardAPI } from '@/lib/api' import { dashboardAPI } from '@/lib/api'
@@ -56,6 +57,16 @@ function DashboardContent() {
description: 'الفرص التجارية والعروض والصفقات', description: 'الفرص التجارية والعروض والصفقات',
permission: 'crm' permission: 'crm'
}, },
{
id: 'tenders',
name: 'إدارة المناقصات',
nameEn: 'Tender Management',
icon: FileText,
color: 'bg-indigo-500',
href: '/tenders',
description: 'تسجيل ومتابعة المناقصات وتحويلها إلى فرص',
permission: 'crm'
},
{ {
id: 'inventory', id: 'inventory',
name: 'المخزون والأصول', name: 'المخزون والأصول',

View File

@@ -0,0 +1,528 @@
'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,
User,
History,
Plus,
Loader2,
CheckCircle2,
Upload,
ExternalLink,
AlertCircle,
} 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">{t('tenders.bondValue')}</p>
<p>{Number(tender.bondValue)} SAR</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="text-sm text-gray-700">
{a.originalName || a.fileName}
</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,447 @@
'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,
Calendar,
Building2,
DollarSign,
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 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',
}
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>({
tenderNumber: '',
issuingBodyName: '',
title: '',
termsValue: 0,
bondValue: 0,
announcementDate: '',
closingDate: '',
source: 'MANUAL',
announcementType: 'FIRST',
})
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 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.termsValue) < 0) errors.termsValue = t('common.required')
if (Number(formData.bondValue) < 0) errors.bondValue = t('common.required')
setFormErrors(errors)
if (Object.keys(errors).length > 0) return
setSubmitting(true)
try {
const result = await tendersAPI.create(formData)
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)
}
}
const resetForm = () => {
setFormData({
tenderNumber: '',
issuingBodyName: '',
title: '',
termsValue: 0,
bondValue: 0,
announcementDate: '',
closingDate: '',
source: 'MANUAL',
announcementType: 'FIRST',
})
setFormErrors({})
setPossibleDuplicates([])
setShowDuplicateWarning(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={() => { setShowCreateModal(true); resetForm(); }}
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((t) => (
<tr key={t.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm font-medium text-gray-900">{t.tenderNumber}</td>
<td className="px-4 py-3 text-sm text-gray-900">{t.title}</td>
<td className="px-4 py-3 text-sm text-gray-600">{t.issuingBodyName}</td>
<td className="px-4 py-3 text-sm text-gray-600">{t.closingDate?.split('T')[0]}</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 text-xs rounded-full ${
t.status === 'ACTIVE' ? 'bg-green-100 text-green-800' :
t.status === 'CONVERTED_TO_DEAL' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'
}`}>
{t.status}
</span>
</td>
<td className="px-4 py-3 text-right">
<Link
href={`/tenders/${t.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); setShowDuplicateWarning(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">{t('tenders.termsValue')} *</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">{t('tenders.bondValue')} *</label>
<input
type="number"
min={0}
value={formData.bondValue || ''}
onChange={(e) => setFormData({ ...formData, bondValue: Number(e.target.value) || 0 })}
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>
<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>
)
}

View File

@@ -99,12 +99,17 @@ const translations = {
active: 'Active', active: 'Active',
inactive: 'Inactive', inactive: 'Inactive',
archived: 'Archived', archived: 'Archived',
deleted: 'Deleted' deleted: 'Deleted',
all: 'All',
view: 'View',
showing: 'Showing',
of: 'of'
}, },
nav: { nav: {
dashboard: 'Dashboard', dashboard: 'Dashboard',
contacts: 'Contacts', contacts: 'Contacts',
crm: 'CRM', crm: 'CRM',
tenders: 'Tenders',
projects: 'Projects', projects: 'Projects',
inventory: 'Inventory', inventory: 'Inventory',
hr: 'HR', hr: 'HR',
@@ -318,6 +323,36 @@ const translations = {
paidAmount: 'Paid Amount', paidAmount: 'Paid Amount',
paidDate: 'Paid Date' paidDate: 'Paid Date'
}, },
tenders: {
title: 'Tenders',
subtitle: 'Tender Management',
addTender: 'Add Tender',
tenderNumber: 'Tender number',
issuingBody: 'Issuing body',
titleLabel: 'Title',
termsValue: 'Terms booklet value',
bondValue: 'Bond value',
announcementDate: 'Announcement date',
closingDate: 'Closing date',
announcementLink: 'Announcement link',
source: 'Source',
announcementType: 'Announcement type',
searchPlaceholder: 'Search by number, title, issuing body...',
noTenders: 'No tenders found.',
loadError: 'Failed to load tenders',
createSuccess: 'Tender created successfully',
duplicateWarning: 'Possible duplicates found. Please review.',
directives: 'Directives',
addDirective: 'Add directive',
directiveType: 'Directive type',
assignee: 'Assignee',
convertToDeal: 'Convert to Opportunity',
history: 'History',
attachments: 'Attachments',
uploadFile: 'Upload file',
completeTask: 'Complete task',
completionNotes: 'Completion notes'
},
import: { import: {
title: 'Import Contacts', title: 'Import Contacts',
downloadTemplate: 'Download Excel Template', downloadTemplate: 'Download Excel Template',
@@ -381,6 +416,7 @@ const translations = {
dashboard: 'لوحة التحكم', dashboard: 'لوحة التحكم',
contacts: 'جهات الاتصال', contacts: 'جهات الاتصال',
crm: 'إدارة العملاء', crm: 'إدارة العملاء',
tenders: 'المناقصات',
projects: 'المشاريع', projects: 'المشاريع',
inventory: 'المخزون', inventory: 'المخزون',
hr: 'الموارد البشرية', hr: 'الموارد البشرية',
@@ -388,6 +424,36 @@ const translations = {
settings: 'الإعدادات', settings: 'الإعدادات',
logout: 'تسجيل الخروج' logout: 'تسجيل الخروج'
}, },
tenders: {
title: 'المناقصات',
subtitle: 'نظام إدارة المناقصات',
addTender: 'إضافة مناقصة',
tenderNumber: 'رقم المناقصة',
issuingBody: 'الجهة الطارحة',
titleLabel: 'عنوان المناقصة',
termsValue: 'قيمة دفتر الشروط',
bondValue: 'قيمة التأمينات',
announcementDate: 'تاريخ الإعلان',
closingDate: 'تاريخ الإغلاق',
announcementLink: 'رابط الإعلان',
source: 'مصدر المناقصة',
announcementType: 'نوع الإعلان',
searchPlaceholder: 'البحث بالرقم أو العنوان أو الجهة الطارحة...',
noTenders: 'لم يتم العثور على مناقصات.',
loadError: 'فشل تحميل المناقصات',
createSuccess: 'تم إنشاء المناقصة بنجاح',
duplicateWarning: 'يوجد مناقصات مشابهة. يرجى المراجعة.',
directives: 'التوجيهات',
addDirective: 'إضافة توجيه',
directiveType: 'نوع التوجيه',
assignee: 'الموظف المسؤول',
convertToDeal: 'تحويل إلى فرصة',
history: 'السجل',
attachments: 'المرفقات',
uploadFile: 'رفع ملف',
completeTask: 'إتمام المهمة',
completionNotes: 'ملاحظات الإنجاز'
},
contacts: { contacts: {
title: 'جهات الاتصال', title: 'جهات الاتصال',
addContact: 'إضافة جهة اتصال', addContact: 'إضافة جهة اتصال',

View File

@@ -0,0 +1,183 @@
import { api } from '../api'
export interface Tender {
id: string
tenderNumber: string
issuingBodyName: string
title: string
termsValue: number
bondValue: number
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
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
},
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
},
}