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

View File

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