diff --git a/backend/src/modules/tenders/tenders.service.ts b/backend/src/modules/tenders/tenders.service.ts index 1c0ee4f..91fa62e 100644 --- a/backend/src/modules/tenders/tenders.service.ts +++ b/backend/src/modules/tenders/tenders.service.ts @@ -209,11 +209,25 @@ class TendersService { return cleanedNotes ? `${cleanedNotes}\n${metaBlock}` : metaBlock; } - private mapTenderExtraFields(tender: T) { + private getComputedTenderStatus(tender: T) { + if (tender.status === 'CONVERTED_TO_DEAL' || tender.status === 'CANCELLED') { + return tender.status; + } + + if (tender.closingDate && new Date(tender.closingDate) < new Date()) { + return 'EXPIRED'; + } + + return tender.status || 'ACTIVE'; + } + + private mapTenderExtraFields(tender: T) { const { cleanNotes, meta } = this.extractTenderExtraMeta(tender.notes); return { ...tender, + status: this.getComputedTenderStatus(tender), + originalStatus: tender.status, notes: cleanNotes || null, initialBondValue: meta.initialBondValue ?? Number(tender.bondValue ?? 0), finalBondValue: meta.finalBondValue ?? null, @@ -346,7 +360,9 @@ class TendersService { { issuingBodyName: { contains: filters.search, mode: 'insensitive' } }, ]; } - if (filters.status) where.status = filters.status; + if (filters.status && filters.status !== 'EXPIRED') { + where.status = filters.status; + } if (filters.source) where.source = filters.source; if (filters.announcementType) where.announcementType = filters.announcementType; @@ -362,9 +378,15 @@ class TendersService { }, orderBy: { createdAt: 'desc' }, }); - return { - tenders: tenders.map((t) => this.mapTenderExtraFields(t)), - total, + const mappedTenders = tenders.map((t) => this.mapTenderExtraFields(t)); + const filteredTenders = + filters.status === 'EXPIRED' + ? mappedTenders.filter((t: any) => t.status === 'EXPIRED') + : mappedTenders; + + return { + tenders: filteredTenders, + total: filters.status === 'EXPIRED' ? filteredTenders.length : total, page, pageSize, }; @@ -679,7 +701,17 @@ class TendersService { return deal; } - async uploadTenderAttachment( + private decodeUploadedFileName(fileName: string) { + if (!fileName) return 'file'; + + try { + return Buffer.from(fileName, 'latin1').toString('utf8'); + } catch { + return fileName; + } + } + + async uploadTenderAttachment( tenderId: string, file: { path: string; originalname: string; mimetype: string; size: number }, userId: string, @@ -687,14 +719,17 @@ class TendersService { ) { 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 originalName = this.decodeUploadedFileName(file.originalname); + const attachment = await prisma.attachment.create({ data: { entityType: 'TENDER', entityId: tenderId, tenderId, fileName, - originalName: file.originalname, + originalName, mimeType: file.mimetype, size: file.size, path: file.path, @@ -702,6 +737,7 @@ class TendersService { uploadedBy: userId, }, }); + await AuditLogger.log({ entityType: 'TENDER', entityId: tenderId, @@ -709,6 +745,7 @@ class TendersService { userId, changes: { attachmentUploaded: attachment.id }, }); + return attachment; } @@ -722,8 +759,12 @@ class TendersService { where: { id: directiveId }, select: { id: true, tenderId: true }, }); + if (!directive) throw new AppError(404, 'Directive not found'); + const fileName = path.basename(file.path); + const originalName = this.decodeUploadedFileName(file.originalname); + const attachment = await prisma.attachment.create({ data: { entityType: 'TENDER_DIRECTIVE', @@ -731,7 +772,7 @@ class TendersService { tenderDirectiveId: directiveId, tenderId: directive.tenderId, fileName, - originalName: file.originalname, + originalName, mimeType: file.mimetype, size: file.size, path: file.path, @@ -739,6 +780,7 @@ class TendersService { uploadedBy: userId, }, }); + await AuditLogger.log({ entityType: 'TENDER_DIRECTIVE', entityId: directiveId, @@ -746,9 +788,9 @@ class TendersService { userId, changes: { attachmentUploaded: attachment.id }, }); + return attachment; } - async getAttachmentFile(attachmentId: string): Promise { const attachment = await prisma.attachment.findUnique({ where: { id: attachmentId }, @@ -766,12 +808,10 @@ class TendersService { 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 }, }) diff --git a/frontend/src/app/tenders/[id]/page.tsx b/frontend/src/app/tenders/[id]/page.tsx index d3f53c2..517350c 100644 --- a/frontend/src/app/tenders/[id]/page.tsx +++ b/frontend/src/app/tenders/[id]/page.tsx @@ -35,6 +35,24 @@ const DIRECTIVE_TYPE_LABELS: Record = { PREPARE_TO_BID: 'Prepare to bid', } +const getDisplayFileName = (attachment: any) => { + const name = String(attachment.originalName || attachment.fileName || 'file') + + if (!/[ÃÄÅØÙ]/.test(name)) { + return name + } + + try { + const bytes = new Uint8Array( + Array.from(name, (char: string) => char.charCodeAt(0) & 0xff) + ) + + return new TextDecoder('utf-8').decode(bytes) + } catch { + return name + } +} + function TenderDetailContent() { const searchParams = useSearchParams() const params = useParams() @@ -45,8 +63,8 @@ function TenderDetailContent() { const [tender, setTender] = useState(null) const [history, setHistory] = useState([]) const [loading, setLoading] = useState(true) - type TenderTab = 'details' | 'directives' | 'attachments' | 'logs' | 'info' |'history' - const [activeTab, setActiveTab] = useState('details') + type TenderTab = 'info' | 'directives' | 'attachments' | 'history' + const [activeTab, setActiveTab] = useState('info') const openTab = (tab: TenderTab) => { setActiveTab(tab) router.replace(`/tenders/${params.id}?tab=${tab}`) @@ -94,9 +112,7 @@ function TenderDetailContent() { } useEffect(() => { const tabParam = searchParams.get('tab') as TenderTab | null - - const allowedTabs: TenderTab[] = ['details', 'directives', 'attachments', 'logs'] - + const allowedTabs: TenderTab[] = ['info', 'directives', 'attachments', 'history'] if (tabParam && allowedTabs.includes(tabParam)) { setActiveTab(tabParam) } @@ -284,7 +300,7 @@ function TenderDetailContent() { {tabs.map((tab) => (