diff --git a/backend/prisma/migrations/20250311000000_add_tender_management/migration.sql b/backend/prisma/migrations/20250311000000_add_tender_management/migration.sql new file mode 100644 index 0000000..2ef5d15 --- /dev/null +++ b/backend/prisma/migrations/20250311000000_add_tender_management/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 69c39ea..6738565 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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") } diff --git a/backend/prisma/seed.js b/backend/prisma/seed.js index c0d87cf..89aaa1b 100644 --- a/backend/prisma/seed.js +++ b/backend/prisma/seed.js @@ -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'] }, ], }); diff --git a/backend/src/modules/admin/admin.service.ts b/backend/src/modules/admin/admin.service.ts index 1282f4b..96de1a9 100644 --- a/backend/src/modules/admin/admin.service.ts +++ b/backend/src/modules/admin/admin.service.ts @@ -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'); } diff --git a/backend/src/modules/tenders/tenders.controller.ts b/backend/src/modules/tenders/tenders.controller.ts new file mode 100644 index 0000000..6b6fa7a --- /dev/null +++ b/backend/src/modules/tenders/tenders.controller.ts @@ -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(); diff --git a/backend/src/modules/tenders/tenders.routes.ts b/backend/src/modules/tenders/tenders.routes.ts new file mode 100644 index 0000000..790990e --- /dev/null +++ b/backend/src/modules/tenders/tenders.routes.ts @@ -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; diff --git a/backend/src/modules/tenders/tenders.service.ts b/backend/src/modules/tenders/tenders.service.ts new file mode 100644 index 0000000..70eed64 --- /dev/null +++ b/backend/src/modules/tenders/tenders.service.ts @@ -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 { + 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 { + 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 { + 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, 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 = { + 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 { + 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(); diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 7b06cd4..d94ec76 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -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', ], }); }); diff --git a/docs/SRS_TENDER_MANAGEMENT.md b/docs/SRS_TENDER_MANAGEMENT.md new file mode 100644 index 0000000..5c1c355 --- /dev/null +++ b/docs/SRS_TENDER_MANAGEMENT.md @@ -0,0 +1,259 @@ +# Software Requirements Specification: Tender Management Module +# مواصفات متطلبات البرمجيات: موديول إدارة المناقصات + +**Version:** 1.0 +**Module name (EN):** Tender Management +**Module name (AR):** نظام إدارة المناقصات + +--- + +## 1. Introduction | مقدمة + +### 1.1 Purpose | الهدف من الموديول + +The Tender Management Module enables the sales team to register all tenders available in the market and track them in an organised way from the moment the announcement is discovered until the appropriate management decision is made. Supported decisions include: + +- Purchase of terms booklet (شراء دفتر الشروط) or securing the terms booklet (تأمين دفتر الشروط) +- Visiting the issuing body (زيارة الجهة الطارحة) +- Getting to know the relevant committee (التعرف على اللجنة المختصة) +- Preparing to enter the tender (الاستعداد للدخول في المناقصة) + +**Scope boundary:** The module’s role ends at this stage. After the initial follow-up and the decision to proceed, the tender is **converted to an Opportunity (Deal)** in the CRM module so the engineering team can study the project. + +--- + +## 2. Integration with Other Systems | التكامل مع الأنظمة الأخرى + +### 2.1 HR Module | موديول الموارد البشرية + +The HR module is used for: + +- **User definition:** Users are linked to employees. +- **Permissions:** Access control and role-based permissions. +- **Assignee selection:** When issuing directives, the responsible employee is chosen from the HR employee list. + +### 2.2 CRM Module | موديول CRM + +After the initial follow-up phase, a tender can be **converted to an Opportunity (Deal)** in CRM. The Deal is then handled by the engineering team. In this system, “Opportunity” is implemented as the **Deal** entity (no separate Opportunity model). + +--- + +## 3. Users and Permissions | المستخدمون والصلاحيات + +### 3.1 Sales Team | فريق المبيعات + +**Permissions:** + +- Add new tenders +- Edit tender data +- Follow up on tenders +- Execute assigned tasks +- Upload documents +- Add notes + +### 3.2 Sales Manager | مدير المبيعات + +**Permissions:** + +- View all tenders +- Issue directives +- Assign employees to tasks +- Add notes +- Monitor execution + +### 3.3 Executive Manager | المدير التنفيذي + +**Permissions:** + +- Issue directives +- Assign employees +- Monitor tenders +- View all documents and notes + +**Implementation note:** The system uses a `tenders` module with resources (e.g. `tenders`, `directives`) and actions (`read`, `create`, `update`, `delete`). Roles (Sales, Sales Manager, Executive) are configured in Admin with the appropriate permissions for this module. + +--- + +## 4. Creating a New Tender | إنشاء مناقصة جديدة + +Sales registers tenders discovered in the market. + +### 4.1 Basic Tender Data | البيانات الأساسية للمناقصة + +| Field (EN) | Field (AR) | Type | Required | Notes | +|--------------------|----------------|--------|----------|--------| +| Issuing body name | اسم الجهة الطارحة | Text | Yes | | +| Tender title | عنوان المناقصة | Text | Yes | | +| Tender number | رقم المناقصة | Text | Yes | **Unique** | +| Terms booklet value| قيمة دفتر الشروط | Decimal| Yes | | +| Bond value | قيمة التأمينات | Decimal| Yes | | +| Announcement date | تاريخ الإعلان | Date | Yes | | +| Closing date | تاريخ الإغلاق | Date | Yes | | +| Announcement link | رابط الإعلان | URL | No | | +| Source | مصدر المناقصة | See §5 | Yes | | +| Notes | ملاحظات | Text | No | | +| Announcement file | صورة/ملف الإعلان | File | No | Image or document | + +### 4.2 Announcement Type | نوع إعلان المناقصة + +When registering the tender, the announcement type must be set: + +- First announcement (إعلان للمرة الأولى) +- Re-announcement, 2nd time (إعلان معاد للمرة الثانية) +- Re-announcement, 3rd time (إعلان معاد للمرة الثالثة) +- Re-announcement, 4th time (إعلان معاد للمرة الرابعة) + +--- + +## 5. Tender Source | مصدر المناقصة + +The system must support recording the tender source by multiple means, including: + +- Government sites (مواقع حكومية) +- Official gazette (جريدة رسمية) +- Personal relations (علاقات شخصية) +- Partner companies (شركات صديقة) +- WhatsApp or Telegram groups (مجموعات واتساب أو تلغرام) +- Tender portals (بوابات المناقصات) +- Email (البريد الإلكتروني) +- Manual entry (إدخال يدوي) + +**User interaction:** The user may: + +- Select a source from a predefined list, or +- Enter the source as free text, or +- Paste the announcement link (stored as link; source may be derived or manual). + +--- + +## 6. Duplicate Prevention | منع التكرار + +The system must detect potential duplicate tenders. + +**When:** On creation of a new tender (and optionally on update). + +**Matching criteria:** The system checks for similar tenders using: + +- Issuing body name (اسم الزبون / الجهة الطارحة) +- Tender title (عنوان المناقصة) +- Terms booklet value (قيمة دفتر الشروط) +- Bond value (قيمة التأمينات) +- Closing date (تاريخ الإغلاق) +- Announcement date (تاريخ الإعلان) + +**Behaviour:** If one or more tenders with matching or very similar data are found: + +1. The system shows a **warning** to the user that a possible duplicate exists. +2. The similar record(s) are displayed so the user can: + - Review the existing tender + - Confirm whether it is a duplicate or a different tender + - Decide whether to proceed with creating the new tender or cancel. + +The user can still choose to continue after the warning; the system does not block creation. + +--- + +## 7. Administrative Directive | التوجيه الإداري + +After the tender is registered, an **administrative directive** (توجيه إداري) can be issued by the Sales Manager or the Executive. + +### 7.1 Directive Contents | مكونات التوجيه + +- **Directive type:** Selected from a list (e.g. Buy terms booklet, Visit client, Meet committee, Prepare to bid). +- **Additional notes:** Free text. +- **Responsible employee:** Selected from the HR employee list (the person who will execute the task). + +### 7.2 Examples of Directive Types | أمثلة على التوجيهات + +- Purchase terms booklet (شراء دفتر الشروط) +- Visit the client/issuing body (زيارة الزبون) +- Get to know the relevant committee (التعرف على اللجنة المختصة) +- Prepare to enter the tender (الاستعداد للدخول في المناقصة) + +--- + +## 8. Assigning the Responsible Employee | تعيين الموظف المسؤول + +When issuing a directive, the **employee responsible for executing the task** must be selected. The employee is chosen from the list of employees provided by the HR module. + +--- + +## 9. Notifications | الإشعارات + +When an employee is assigned to execute a directive: + +- The system sends an **in-app notification** to the **user** linked to that employee. +- The notification includes: + - Tender name and number (اسم المناقصة + الرقم) + - Task/directive type (نوع المهمة) + - The administrative directive text + - Manager notes (ملاحظات المدير) + +No separate notification table is required; the existing Notification entity is used with a type such as `TENDER_DIRECTIVE_ASSIGNED`. + +--- + +## 10. Executing the Task | تنفيذ المهمة + +After receiving the task, the assigned employee can: + +1. Perform the required action. +2. Record in the system what was done. +3. Add notes or a short report. +4. Upload files related to the task (e.g. receipt, visit report). + +--- + +## 11. File Management | إدارة الملفات + +### 11.1 Announcement Files | ملفات الإعلان + +- One main file per tender for the announcement (image or document). +- Stored and linked to the tender record. + +### 11.2 Task Execution Files | ملفات تنفيذ المهام + +The employee may attach multiple files per directive/task, for example: + +- Terms booklet purchase receipt +- Terms booklets (documents) +- Visit reports +- Other documents related to the tender + +The system must support upload, storage, and association of these files with the tender or the directive/task. + +--- + +## 12. Activity Log | سجل النشاط + +The system must log all operations performed on a tender, including: + +- Tender creation +- Data updates +- Issuing directives +- Assigning employees +- Executing tasks +- Uploading files +- Adding notes + +This log is used to display a timeline or history on the tender detail view. + +--- + +## 13. End of Module Scope | نهاية دور الموديول + +The Tender Management Module’s scope ends when: + +- Initial directives have been executed, +- Initial information has been gathered, and +- The decision to prepare to enter the tender has been taken. + +At that point, the user can **convert the tender to an Opportunity (Deal)** in the CRM module. The resulting Deal is then used by the engineering team to study the project. Conversion creates a Deal (Opportunity) and may store a reference to the source tender for traceability. + +--- + +## Document History + +| Version | Date | Author/Notes | +|--------|------------|--------------| +| 1.0 | 2025-03-11 | Initial SRS from client requirements (Arabic). | diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index b5da2d2..f205bf8 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -18,7 +18,8 @@ import { Building2, Settings, Bell, - Shield + Shield, + FileText } from 'lucide-react' import { dashboardAPI } from '@/lib/api' @@ -56,6 +57,16 @@ function DashboardContent() { description: 'الفرص التجارية والعروض والصفقات', permission: 'crm' }, + { + id: 'tenders', + name: 'إدارة المناقصات', + nameEn: 'Tender Management', + icon: FileText, + color: 'bg-indigo-500', + href: '/tenders', + description: 'تسجيل ومتابعة المناقصات وتحويلها إلى فرص', + permission: 'crm' + }, { id: 'inventory', name: 'المخزون والأصول', diff --git a/frontend/src/app/tenders/[id]/page.tsx b/frontend/src/app/tenders/[id]/page.tsx new file mode 100644 index 0000000..83cb7cb --- /dev/null +++ b/frontend/src/app/tenders/[id]/page.tsx @@ -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 = { + 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(null) + const [history, setHistory] = useState([]) + 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(null) + const [employees, setEmployees] = useState([]) + const [contacts, setContacts] = useState([]) + const [pipelines, setPipelines] = useState([]) + const [directiveForm, setDirectiveForm] = useState({ type: 'BUY_TERMS', assignedToEmployeeId: '', notes: '' }) + const [convertForm, setConvertForm] = useState({ contactId: '', pipelineId: '', ownerId: '' }) + const [completeNotes, setCompleteNotes] = useState('') + const [directiveTypeValues, setDirectiveTypeValues] = useState([]) + const [submitting, setSubmitting] = useState(false) + const fileInputRef = useRef(null) + const directiveFileInputRef = useRef(null) + const [uploadingDirectiveId, setUploadingDirectiveId] = useState(null) + const [directiveIdForUpload, setDirectiveIdForUpload] = useState(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) => { + 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) => { + 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 ( +
+ +
+ ) + } + + 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 ( +
+
+
+
+ + + +
+

{tender.tenderNumber} – {tender.title}

+

{tender.issuingBodyName}

+
+
+ {tender.status === 'ACTIVE' && ( + + )} +
+ +
+
+ {tabs.map((tab) => ( + + ))} +
+ +
+ {activeTab === 'info' && ( +
+
+
+ +
+

{t('tenders.announcementDate')}

+

{tender.announcementDate?.split('T')[0]}

+
+
+
+ +
+

{t('tenders.closingDate')}

+

{tender.closingDate?.split('T')[0]}

+
+
+
+ +
+

{t('tenders.termsValue')}

+

{Number(tender.termsValue)} SAR

+
+
+
+ +
+

{t('tenders.bondValue')}

+

{Number(tender.bondValue)} SAR

+
+
+
+ {tender.announcementLink && ( +

+ + {t('tenders.announcementLink')} + +

+ )} + {tender.notes && ( +
+

{t('common.notes')}

+

{tender.notes}

+
+ )} +
+ )} + + {activeTab === 'directives' && ( +
+
+

{t('tenders.directives')}

+ +
+ {!tender.directives?.length ? ( +

{t('common.noData')}

+ ) : ( +
    + {tender.directives.map((d) => ( +
  • +
    +

    {DIRECTIVE_TYPE_LABELS[d.type] || d.type}

    +

    + {d.assignedToEmployee ? `${d.assignedToEmployee.firstName} ${d.assignedToEmployee.lastName}` : ''} · {d.status} +

    + {d.completionNotes &&

    {d.completionNotes}

    } +
    +
    + {d.status !== 'COMPLETED' && d.assignedToEmployee?.user?.id && ( + + )} + + +
    +
  • + ))} +
+ )} +
+ )} + + {activeTab === 'attachments' && ( +
+
+ + +
+ {!tender.attachments?.length ? ( +

{t('common.noData')}

+ ) : ( +
    + {tender.attachments.map((a: any) => ( +
  • + {a.originalName || a.fileName} +
  • + ))} +
+ )} +
+ )} + + {activeTab === 'history' && ( +
+ {history.length === 0 ? ( +

{t('common.noData')}

+ ) : ( +
    + {history.map((h: any) => ( +
  • + {h.action} · {h.user?.username} · {h.createdAt?.split('T')[0]} +
  • + ))} +
+ )} +
+ )} +
+
+
+ + setShowDirectiveModal(false)} title={t('tenders.addDirective')}> +
+
+ + +
+
+ + +
+
+ +