diff --git a/backend/src/modules/contacts/contacts.routes.ts b/backend/src/modules/contacts/contacts.routes.ts index 0654d66..0d53491 100644 --- a/backend/src/modules/contacts/contacts.routes.ts +++ b/backend/src/modules/contacts/contacts.routes.ts @@ -42,7 +42,7 @@ router.post( '/', authorize('contacts', 'contacts', 'create'), [ - body('type').isIn(['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT','ORGANIZATION', + body('type').isIn(['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT','ORGANIZATION','EMBASSIES', 'BANK','UNIVERSITY','SCHOOL','UN','NGO','INSTITUTION',]), body('name').notEmpty().trim(), body('email').optional().isEmail(), diff --git a/backend/src/modules/contacts/contacts.service.ts b/backend/src/modules/contacts/contacts.service.ts index 83ae71f..dee2ed9 100644 --- a/backend/src/modules/contacts/contacts.service.ts +++ b/backend/src/modules/contacts/contacts.service.ts @@ -679,7 +679,7 @@ class ContactsService { } // Validate type - if (!['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT', 'ORGANIZATION','BANK','UNIVERSITY','SCHOOL','UN','NGO','INSTITUTION',].includes(row.type)) { + if (!['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT', 'ORGANIZATION','EMBASSIES','BANK','UNIVERSITY','SCHOOL','UN','NGO','INSTITUTION',].includes(row.type)) { results.errors.push({ row: rowNumber, field: 'type', diff --git a/backend/src/modules/tenders/tenders.controller.ts b/backend/src/modules/tenders/tenders.controller.ts index 6b6fa7a..5d51733 100644 --- a/backend/src/modules/tenders/tenders.controller.ts +++ b/backend/src/modules/tenders/tenders.controller.ts @@ -227,6 +227,39 @@ export class TendersController { next(error); } } -} + + + + async viewAttachment(req: AuthRequest, res: Response, next: NextFunction) { + try { + const file = await tendersService.getAttachmentFile(req.params.attachmentId) + + const fs = require('fs') + if (!fs.existsSync(file)) { + return res.status(404).json( + ResponseFormatter.error('File not found', 'الملف غير موجود') + ) + } + + const path = require('path') + return res.sendFile(path.resolve(file)) + + } catch (error) { + console.error(error) + next(error) + } + } + + async deleteAttachment(req: AuthRequest, res: Response, next: NextFunction) { + try { + await tendersService.deleteAttachment(req.params.attachmentId) + res.json(ResponseFormatter.success(null, 'Deleted')) + } catch (error) { + next(error) + } + } + } export const tendersController = new TendersController(); + + diff --git a/backend/src/modules/tenders/tenders.routes.ts b/backend/src/modules/tenders/tenders.routes.ts index 790990e..daba50f 100644 --- a/backend/src/modules/tenders/tenders.routes.ts +++ b/backend/src/modules/tenders/tenders.routes.ts @@ -28,6 +28,15 @@ const upload = multer({ limits: { fileSize: config.upload.maxFileSize }, }); + +// View attachment +router.get( + '/attachments/:attachmentId/view', + param('attachmentId').isUUID(), + validate, + tendersController.viewAttachment +) + router.use(authenticate); // Enum/lookup routes (no resource id) - place before /:id routes @@ -173,3 +182,14 @@ router.post( ); export default router; + + + +// Delete attachment +router.delete( + '/attachments/:attachmentId', + authorize('tenders', 'tenders', 'update'), + param('attachmentId').isUUID(), + validate, + tendersController.deleteAttachment +) diff --git a/backend/src/modules/tenders/tenders.service.ts b/backend/src/modules/tenders/tenders.service.ts index 70eed64..f350b3f 100644 --- a/backend/src/modules/tenders/tenders.service.ts +++ b/backend/src/modules/tenders/tenders.service.ts @@ -3,6 +3,8 @@ import { AppError } from '../../shared/middleware/errorHandler'; import { AuditLogger } from '../../shared/utils/auditLogger'; import { Prisma } from '@prisma/client'; import path from 'path'; + import fs from 'fs' + const TENDER_SOURCE_VALUES = [ 'GOVERNMENT_SITE', @@ -33,18 +35,31 @@ export interface CreateTenderData { issuingBodyName: string; title: string; tenderNumber: string; + termsValue: number; bondValue: number; + + // new extra fields stored inside notes metadata + initialBondValue?: number; + finalBondValue?: number; + finalBondRefundPeriod?: string; + siteVisitRequired?: boolean; + siteVisitLocation?: string; + termsPickupProvince?: string; + announcementDate: string; closingDate: string; announcementLink?: string; + source: string; sourceOther?: string; announcementType: string; + notes?: string; contactId?: string; } + export interface CreateDirectiveData { type: string; notes?: string; @@ -71,12 +86,92 @@ class TendersService { return `TND-${year}-${seq}`; } + private readonly EXTRA_META_START = '[EXTRA_TENDER_META]'; + private readonly EXTRA_META_END = '[/EXTRA_TENDER_META]'; + + private extractTenderExtraMeta(notes?: string | null) { + if (!notes) { + return { + cleanNotes: '', + meta: {}, + }; + } + + const start = notes.indexOf(this.EXTRA_META_START); + const end = notes.indexOf(this.EXTRA_META_END); + + if (start === -1 || end === -1 || end < start) { + return { + cleanNotes: notes, + meta: {}, + }; + } + + const jsonPart = notes.slice(start + this.EXTRA_META_START.length, end).trim(); + const before = notes.slice(0, start).trim(); + const after = notes.slice(end + this.EXTRA_META_END.length).trim(); + const cleanNotes = [before, after].filter(Boolean).join('\n').trim(); + + try { + return { + cleanNotes, + meta: JSON.parse(jsonPart || '{}'), + }; + } catch { + return { + cleanNotes: notes, + meta: {}, + }; + } + } + + private buildTenderNotes( + plainNotes?: string | null, + extra?: { + initialBondValue?: number | null; + finalBondValue?: number | null; + finalBondRefundPeriod?: string | null; + siteVisitRequired?: boolean; + siteVisitLocation?: string | null; + termsPickupProvince?: string | null; + } + ) { + const cleanedNotes = plainNotes?.trim() || ''; + + const meta = { + initialBondValue: extra?.initialBondValue ?? null, + finalBondValue: extra?.finalBondValue ?? null, + finalBondRefundPeriod: extra?.finalBondRefundPeriod?.trim() || null, + siteVisitRequired: !!extra?.siteVisitRequired, + siteVisitLocation: extra?.siteVisitLocation?.trim() || null, + termsPickupProvince: extra?.termsPickupProvince?.trim() || null, + }; + + const metaBlock = `${this.EXTRA_META_START}${JSON.stringify(meta)}${this.EXTRA_META_END}`; + + return cleanedNotes ? `${cleanedNotes}\n${metaBlock}` : metaBlock; + } + + private mapTenderExtraFields(tender: T) { + const { cleanNotes, meta } = this.extractTenderExtraMeta(tender.notes); + + return { + ...tender, + notes: cleanNotes || null, + initialBondValue: meta.initialBondValue ?? Number(tender.bondValue ?? 0), + finalBondValue: meta.finalBondValue ?? null, + finalBondRefundPeriod: meta.finalBondRefundPeriod ?? null, + siteVisitRequired: !!meta.siteVisitRequired, + siteVisitLocation: meta.siteVisitLocation ?? null, + termsPickupProvince: meta.termsPickupProvince ?? null, + }; + } + async findPossibleDuplicates(data: CreateTenderData): Promise { 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 bondValue = Number(data.initialBondValue ?? data.bondValue ?? 0); const where: Prisma.TenderWhereInput = { status: { not: 'CANCELLED' }, }; @@ -135,20 +230,33 @@ class TendersService { const announcementDate = new Date(data.announcementDate); const closingDate = new Date(data.closingDate); + if (data.siteVisitRequired && !data.siteVisitLocation?.trim()) { + throw new AppError(400, 'مكان زيارة الموقع مطلوب عند اختيار زيارة موقع إجبارية'); + } + + const finalNotes = this.buildTenderNotes(data.notes, { + initialBondValue: data.initialBondValue ?? data.bondValue ?? 0, + finalBondValue: data.finalBondValue ?? null, + finalBondRefundPeriod: data.finalBondRefundPeriod ?? null, + siteVisitRequired: !!data.siteVisitRequired, + siteVisitLocation: data.siteVisitRequired ? data.siteVisitLocation ?? null : null, + termsPickupProvince: data.termsPickupProvince ?? null, + }); + const tender = await prisma.tender.create({ data: { tenderNumber, issuingBodyName: data.issuingBodyName.trim(), title: data.title.trim(), termsValue: data.termsValue, - bondValue: data.bondValue, + bondValue: Number(data.initialBondValue ?? data.bondValue ?? 0), announcementDate, closingDate, announcementLink: data.announcementLink?.trim() || null, source: data.source, sourceOther: data.sourceOther?.trim() || null, announcementType: data.announcementType, - notes: data.notes?.trim() || null, + notes: finalNotes, contactId: data.contactId || null, createdById: userId, }, @@ -165,8 +273,10 @@ class TendersService { userId, }); - return { tender, possibleDuplicates: possibleDuplicates.length ? possibleDuplicates : undefined }; - } + return { + tender: this.mapTenderExtraFields(tender), + possibleDuplicates: possibleDuplicates.length ? possibleDuplicates.map((t) => this.mapTenderExtraFields(t)) : undefined, + }; } async findAll(filters: any, page: number, pageSize: number) { const skip = (page - 1) * pageSize; @@ -195,7 +305,12 @@ class TendersService { }, orderBy: { createdAt: 'desc' }, }); - return { tenders, total, page, pageSize }; + return { + tenders: tenders.map((t) => this.mapTenderExtraFields(t)), + total, + page, + pageSize, + }; } async findById(id: string) { @@ -216,8 +331,8 @@ class TendersService { attachments: true, }, }); - if (!tender) throw new AppError(404, 'Tender not found'); - return tender; + if (!tender) throw new AppError(404, 'Tender not found'); + return this.mapTenderExtraFields(tender); } async update(id: string, data: Partial, userId: string) { @@ -228,17 +343,76 @@ class TendersService { } const updateData: Prisma.TenderUpdateInput = {}; + const existingMapped = this.mapTenderExtraFields(existing as any); + + const mergedExtra = { + initialBondValue: + data.initialBondValue !== undefined + ? Number(data.initialBondValue) + : existingMapped.initialBondValue ?? Number(existing.bondValue ?? 0), + + finalBondValue: + data.finalBondValue !== undefined + ? Number(data.finalBondValue) + : existingMapped.finalBondValue ?? null, + + finalBondRefundPeriod: + data.finalBondRefundPeriod !== undefined + ? data.finalBondRefundPeriod + : existingMapped.finalBondRefundPeriod ?? null, + + siteVisitRequired: + data.siteVisitRequired !== undefined + ? !!data.siteVisitRequired + : !!existingMapped.siteVisitRequired, + + siteVisitLocation: + data.siteVisitLocation !== undefined + ? data.siteVisitLocation + : existingMapped.siteVisitLocation ?? null, + + termsPickupProvince: + data.termsPickupProvince !== undefined + ? data.termsPickupProvince + : existingMapped.termsPickupProvince ?? null, + }; + + if (mergedExtra.siteVisitRequired && !mergedExtra.siteVisitLocation?.trim()) { + throw new AppError(400, 'مكان زيارة الموقع مطلوب عند اختيار زيارة موقع إجبارية'); + } if (data.title !== undefined) updateData.title = data.title.trim(); if (data.issuingBodyName !== undefined) updateData.issuingBodyName = data.issuingBodyName.trim(); if (data.termsValue !== undefined) updateData.termsValue = data.termsValue; - if (data.bondValue !== undefined) updateData.bondValue = data.bondValue; + if (data.bondValue !== undefined || data.initialBondValue !== undefined) { + updateData.bondValue = Number(data.initialBondValue ?? data.bondValue ?? existing.bondValue); + } if (data.announcementDate !== undefined) updateData.announcementDate = new Date(data.announcementDate); if (data.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.notes !== undefined || + data.initialBondValue !== undefined || + data.finalBondValue !== undefined || + data.finalBondRefundPeriod !== undefined || + data.siteVisitRequired !== undefined || + data.siteVisitLocation !== undefined || + data.termsPickupProvince !== undefined + ) { + updateData.notes = this.buildTenderNotes( + data.notes !== undefined ? data.notes : existingMapped.notes, + { + initialBondValue: mergedExtra.initialBondValue, + finalBondValue: mergedExtra.finalBondValue, + finalBondRefundPeriod: mergedExtra.finalBondRefundPeriod, + siteVisitRequired: mergedExtra.siteVisitRequired, + siteVisitLocation: mergedExtra.siteVisitRequired ? mergedExtra.siteVisitLocation : null, + termsPickupProvince: mergedExtra.termsPickupProvince, + } + ); + } if (data.contactId !== undefined) { updateData.contact = data.contactId ? { connect: { id: data.contactId } } @@ -261,8 +435,8 @@ class TendersService { userId, changes: { before: existing, after: data }, }); - return tender; - } + return this.mapTenderExtraFields(tender); + } async createDirective(tenderId: string, data: CreateDirectiveData, userId: string) { const tender = await prisma.tender.findUnique({ @@ -518,6 +692,34 @@ class TendersService { return attachment; } + async getAttachmentFile(attachmentId: string): Promise { + const attachment = await prisma.attachment.findUnique({ + where: { id: attachmentId }, + }) + + if (!attachment) throw new AppError(404, 'File not found') + + return attachment.path + } + + async deleteAttachment(attachmentId: string): Promise { + const attachment = await prisma.attachment.findUnique({ + where: { id: attachmentId }, + }) + + if (!attachment) throw new AppError(404, 'File not found') + + // حذف من الديسك + if (attachment.path && fs.existsSync(attachment.path)) { + fs.unlinkSync(attachment.path) + } + + // حذف من DB + await prisma.attachment.delete({ + where: { id: attachmentId }, + }) + } + private async generateDealNumber(): Promise { const year = new Date().getFullYear(); const prefix = `DEAL-${year}-`; diff --git a/frontend/src/app/contacts/[id]/page.tsx b/frontend/src/app/contacts/[id]/page.tsx index bab352f..fb5c599 100644 --- a/frontend/src/app/contacts/[id]/page.tsx +++ b/frontend/src/app/contacts/[id]/page.tsx @@ -100,6 +100,7 @@ function ContactDetailContent() { HOLDING: 'bg-purple-100 text-purple-700', GOVERNMENT: 'bg-orange-100 text-orange-700', ORGANIZATION: 'bg-cyan-100 text-cyan-700', + EMBASSIES: 'bg-red-100 text-red-700', BANK: 'bg-emerald-100 text-emerald-700', UNIVERSITY: 'bg-indigo-100 text-indigo-700', SCHOOL: 'bg-yellow-100 text-yellow-700', @@ -119,6 +120,7 @@ function ContactDetailContent() { ORGANIZATION: 'منظمات - Organizations', BANK: 'بنوك - Banks', UNIVERSITY: 'جامعات - Universities', + EMBASSIES: 'سفارات - Embassies', SCHOOL: 'مدارس - Schools', UN: 'UN - United Nations', NGO: 'NGO - Non-Governmental Organization', diff --git a/frontend/src/app/contacts/page.tsx b/frontend/src/app/contacts/page.tsx index e6b20e0..708dc35 100644 --- a/frontend/src/app/contacts/page.tsx +++ b/frontend/src/app/contacts/page.tsx @@ -387,6 +387,7 @@ function ContactsContent() { + diff --git a/frontend/src/app/hr/page.tsx b/frontend/src/app/hr/page.tsx index 449088d..a19bc9c 100644 --- a/frontend/src/app/hr/page.tsx +++ b/frontend/src/app/hr/page.tsx @@ -277,7 +277,7 @@ function HRContent() { mobile: '', dateOfBirth: '', gender: '', - nationality: 'Saudi Arabia', + nationality: 'Syria', nationalId: '', employmentType: 'FULL_TIME', contractType: 'UNLIMITED', diff --git a/frontend/src/app/tenders/[id]/page.tsx b/frontend/src/app/tenders/[id]/page.tsx index 83cb7cb..3782d94 100644 --- a/frontend/src/app/tenders/[id]/page.tsx +++ b/frontend/src/app/tenders/[id]/page.tsx @@ -10,14 +10,13 @@ import { Calendar, Building2, DollarSign, - User, History, Plus, Loader2, CheckCircle2, Upload, ExternalLink, - AlertCircle, + MapPin, } from 'lucide-react' import ProtectedRoute from '@/components/ProtectedRoute' import LoadingSpinner from '@/components/LoadingSpinner' @@ -51,8 +50,16 @@ function TenderDetailContent() { 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 [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) @@ -93,10 +100,17 @@ function TenderDetailContent() { useEffect(() => { if (showDirectiveModal || showConvertModal) { - employeesAPI.getAll({ status: 'ACTIVE', pageSize: 500 }).then((r: any) => setEmployees(r.employees || [])).catch(() => {}) + employeesAPI + .getAll({ status: 'ACTIVE', pageSize: 500 }) + .then((r: any) => setEmployees(r.employees || [])) + .catch(() => {}) } + if (showConvertModal) { - contactsAPI.getAll({ pageSize: 500 }).then((r: any) => setContacts(r.contacts || [])).catch(() => {}) + contactsAPI + .getAll({ pageSize: 500 }) + .then((r: any) => setContacts(r.contacts || [])) + .catch(() => {}) pipelinesAPI.getAll().then(setPipelines).catch(() => {}) } }, [showDirectiveModal, showConvertModal]) @@ -107,6 +121,7 @@ function TenderDetailContent() { toast.error(t('tenders.assignee') + ' ' + t('common.required')) return } + setSubmitting(true) try { await tendersAPI.createDirective(tenderId, directiveForm) @@ -124,9 +139,13 @@ function TenderDetailContent() { const handleCompleteDirective = async (e: React.FormEvent) => { e.preventDefault() if (!showCompleteModal) return + setSubmitting(true) try { - await tendersAPI.updateDirective(showCompleteModal.id, { status: 'COMPLETED', completionNotes: completeNotes }) + await tendersAPI.updateDirective(showCompleteModal.id, { + status: 'COMPLETED', + completionNotes: completeNotes, + }) toast.success('Task completed') setShowCompleteModal(null) setCompleteNotes('') @@ -144,6 +163,7 @@ function TenderDetailContent() { toast.error('Contact and Pipeline are required') return } + setSubmitting(true) try { const deal = await tendersAPI.convertToDeal(tenderId, convertForm) @@ -160,6 +180,7 @@ function TenderDetailContent() { const handleTenderFileUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return + setSubmitting(true) try { await tendersAPI.uploadTenderAttachment(tenderId, file) @@ -183,7 +204,9 @@ function TenderDetailContent() { const directiveId = directiveIdForUpload e.target.value = '' setDirectiveIdForUpload(null) + if (!file || !directiveId) return + setUploadingDirectiveId(directiveId) try { await tendersAPI.uploadDirectiveAttachment(directiveId, file) @@ -220,10 +243,13 @@ function TenderDetailContent() {
-

{tender.tenderNumber} – {tender.title}

+

+ {tender.tenderNumber} – {tender.title} +

{tender.issuingBodyName}

+ {tender.status === 'ACTIVE' && ( + {!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.assignedToEmployee + ? `${d.assignedToEmployee.firstName} ${d.assignedToEmployee.lastName}` + : ''}{' '} + · {d.status}

    - {d.completionNotes &&

    {d.completionNotes}

    } + {d.completionNotes && ( +

    {d.completionNotes}

    + )}
    +
    {d.status !== 'COMPLETED' && d.assignedToEmployee?.user?.id && (
    @@ -371,17 +467,49 @@ function TenderDetailContent() { 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 ? : } + {submitting ? ( + + ) : ( + + )} {t('tenders.uploadFile')} + {!tender.attachments?.length ? (

    {t('common.noData')}

    ) : (
      {tender.attachments.map((a: any) => ( -
    • - {a.originalName || a.fileName} +
    • + + + {a.originalName || a.fileName} + + +
    • ))}
    @@ -397,7 +525,8 @@ function TenderDetailContent() {
      {history.map((h: any) => (
    • - {h.action} · {h.user?.username} · {h.createdAt?.split('T')[0]} + {h.action} · {h.user?.username} ·{' '} + {h.createdAt?.split('T')[0]}
    • ))}
    @@ -408,36 +537,54 @@ function TenderDetailContent() { - setShowDirectiveModal(false)} title={t('tenders.addDirective')}> + setShowDirectiveModal(false)} + title={t('tenders.addDirective')} + >
    - +
    +
    - +
    +
    - +