edit tender module

This commit is contained in:
Aya
2026-04-26 12:02:45 +03:00
parent 0a9e1bbd4d
commit 11d14c01d2
3 changed files with 81 additions and 22 deletions

View File

@@ -209,11 +209,25 @@ class TendersService {
return cleanedNotes ? `${cleanedNotes}\n${metaBlock}` : metaBlock; return cleanedNotes ? `${cleanedNotes}\n${metaBlock}` : metaBlock;
} }
private mapTenderExtraFields<T extends { notes?: string | null; bondValue?: any }>(tender: T) { private getComputedTenderStatus<T extends { status?: string | null; closingDate?: Date | string | null }>(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<T extends { notes?: string | null; bondValue?: any; status?: string | null; closingDate?: Date | string | null }>(tender: T) {
const { cleanNotes, meta } = this.extractTenderExtraMeta(tender.notes); const { cleanNotes, meta } = this.extractTenderExtraMeta(tender.notes);
return { return {
...tender, ...tender,
status: this.getComputedTenderStatus(tender),
originalStatus: tender.status,
notes: cleanNotes || null, notes: cleanNotes || null,
initialBondValue: meta.initialBondValue ?? Number(tender.bondValue ?? 0), initialBondValue: meta.initialBondValue ?? Number(tender.bondValue ?? 0),
finalBondValue: meta.finalBondValue ?? null, finalBondValue: meta.finalBondValue ?? null,
@@ -346,7 +360,9 @@ class TendersService {
{ issuingBodyName: { contains: filters.search, mode: 'insensitive' } }, { 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.source) where.source = filters.source;
if (filters.announcementType) where.announcementType = filters.announcementType; if (filters.announcementType) where.announcementType = filters.announcementType;
@@ -362,9 +378,15 @@ class TendersService {
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
const mappedTenders = tenders.map((t) => this.mapTenderExtraFields(t));
const filteredTenders =
filters.status === 'EXPIRED'
? mappedTenders.filter((t: any) => t.status === 'EXPIRED')
: mappedTenders;
return { return {
tenders: tenders.map((t) => this.mapTenderExtraFields(t)), tenders: filteredTenders,
total, total: filters.status === 'EXPIRED' ? filteredTenders.length : total,
page, page,
pageSize, pageSize,
}; };
@@ -679,6 +701,16 @@ class TendersService {
return deal; return deal;
} }
private decodeUploadedFileName(fileName: string) {
if (!fileName) return 'file';
try {
return Buffer.from(fileName, 'latin1').toString('utf8');
} catch {
return fileName;
}
}
async uploadTenderAttachment( async uploadTenderAttachment(
tenderId: string, tenderId: string,
file: { path: string; originalname: string; mimetype: string; size: number }, file: { path: string; originalname: string; mimetype: string; size: number },
@@ -687,14 +719,17 @@ class TendersService {
) { ) {
const tender = await prisma.tender.findUnique({ where: { id: tenderId } }); const tender = await prisma.tender.findUnique({ where: { id: tenderId } });
if (!tender) throw new AppError(404, 'Tender not found'); if (!tender) throw new AppError(404, 'Tender not found');
const fileName = path.basename(file.path); const fileName = path.basename(file.path);
const originalName = this.decodeUploadedFileName(file.originalname);
const attachment = await prisma.attachment.create({ const attachment = await prisma.attachment.create({
data: { data: {
entityType: 'TENDER', entityType: 'TENDER',
entityId: tenderId, entityId: tenderId,
tenderId, tenderId,
fileName, fileName,
originalName: file.originalname, originalName,
mimeType: file.mimetype, mimeType: file.mimetype,
size: file.size, size: file.size,
path: file.path, path: file.path,
@@ -702,6 +737,7 @@ class TendersService {
uploadedBy: userId, uploadedBy: userId,
}, },
}); });
await AuditLogger.log({ await AuditLogger.log({
entityType: 'TENDER', entityType: 'TENDER',
entityId: tenderId, entityId: tenderId,
@@ -709,6 +745,7 @@ class TendersService {
userId, userId,
changes: { attachmentUploaded: attachment.id }, changes: { attachmentUploaded: attachment.id },
}); });
return attachment; return attachment;
} }
@@ -722,8 +759,12 @@ class TendersService {
where: { id: directiveId }, where: { id: directiveId },
select: { id: true, tenderId: true }, select: { id: true, tenderId: true },
}); });
if (!directive) throw new AppError(404, 'Directive not found'); if (!directive) throw new AppError(404, 'Directive not found');
const fileName = path.basename(file.path); const fileName = path.basename(file.path);
const originalName = this.decodeUploadedFileName(file.originalname);
const attachment = await prisma.attachment.create({ const attachment = await prisma.attachment.create({
data: { data: {
entityType: 'TENDER_DIRECTIVE', entityType: 'TENDER_DIRECTIVE',
@@ -731,7 +772,7 @@ class TendersService {
tenderDirectiveId: directiveId, tenderDirectiveId: directiveId,
tenderId: directive.tenderId, tenderId: directive.tenderId,
fileName, fileName,
originalName: file.originalname, originalName,
mimeType: file.mimetype, mimeType: file.mimetype,
size: file.size, size: file.size,
path: file.path, path: file.path,
@@ -739,6 +780,7 @@ class TendersService {
uploadedBy: userId, uploadedBy: userId,
}, },
}); });
await AuditLogger.log({ await AuditLogger.log({
entityType: 'TENDER_DIRECTIVE', entityType: 'TENDER_DIRECTIVE',
entityId: directiveId, entityId: directiveId,
@@ -746,9 +788,9 @@ class TendersService {
userId, userId,
changes: { attachmentUploaded: attachment.id }, changes: { attachmentUploaded: attachment.id },
}); });
return attachment; return attachment;
} }
async getAttachmentFile(attachmentId: string): Promise<string> { async getAttachmentFile(attachmentId: string): Promise<string> {
const attachment = await prisma.attachment.findUnique({ const attachment = await prisma.attachment.findUnique({
where: { id: attachmentId }, where: { id: attachmentId },
@@ -766,12 +808,10 @@ class TendersService {
if (!attachment) throw new AppError(404, 'File not found') if (!attachment) throw new AppError(404, 'File not found')
// حذف من الديسك
if (attachment.path && fs.existsSync(attachment.path)) { if (attachment.path && fs.existsSync(attachment.path)) {
fs.unlinkSync(attachment.path) fs.unlinkSync(attachment.path)
} }
// حذف من DB
await prisma.attachment.delete({ await prisma.attachment.delete({
where: { id: attachmentId }, where: { id: attachmentId },
}) })

View File

@@ -35,6 +35,24 @@ const DIRECTIVE_TYPE_LABELS: Record<string, string> = {
PREPARE_TO_BID: 'Prepare to bid', 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() { function TenderDetailContent() {
const searchParams = useSearchParams() const searchParams = useSearchParams()
const params = useParams() const params = useParams()
@@ -45,8 +63,8 @@ function TenderDetailContent() {
const [tender, setTender] = useState<Tender | null>(null) const [tender, setTender] = useState<Tender | null>(null)
const [history, setHistory] = useState<any[]>([]) const [history, setHistory] = useState<any[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
type TenderTab = 'details' | 'directives' | 'attachments' | 'logs' | 'info' |'history' type TenderTab = 'info' | 'directives' | 'attachments' | 'history'
const [activeTab, setActiveTab] = useState<TenderTab>('details') const [activeTab, setActiveTab] = useState<TenderTab>('info')
const openTab = (tab: TenderTab) => { const openTab = (tab: TenderTab) => {
setActiveTab(tab) setActiveTab(tab)
router.replace(`/tenders/${params.id}?tab=${tab}`) router.replace(`/tenders/${params.id}?tab=${tab}`)
@@ -94,9 +112,7 @@ function TenderDetailContent() {
} }
useEffect(() => { useEffect(() => {
const tabParam = searchParams.get('tab') as TenderTab | null const tabParam = searchParams.get('tab') as TenderTab | null
const allowedTabs: TenderTab[] = ['info', 'directives', 'attachments', 'history']
const allowedTabs: TenderTab[] = ['details', 'directives', 'attachments', 'logs']
if (tabParam && allowedTabs.includes(tabParam)) { if (tabParam && allowedTabs.includes(tabParam)) {
setActiveTab(tabParam) setActiveTab(tabParam)
} }
@@ -284,7 +300,7 @@ function TenderDetailContent() {
{tabs.map((tab) => ( {tabs.map((tab) => (
<button <button
key={tab.id} key={tab.id}
onClick={() => setActiveTab(tab.id as any)} onClick={() => openTab(tab.id as TenderTab)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium ${ className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium ${
activeTab === tab.id activeTab === tab.id
? 'bg-indigo-100 text-indigo-800' ? 'bg-indigo-100 text-indigo-800'
@@ -510,7 +526,7 @@ function TenderDetailContent() {
className="text-sm text-indigo-600 hover:underline flex items-center gap-1" className="text-sm text-indigo-600 hover:underline flex items-center gap-1"
> >
<ExternalLink className="h-4 w-4" /> <ExternalLink className="h-4 w-4" />
{a.originalName || a.fileName} {getDisplayFileName(a)}
</a> </a>
<button <button

View File

@@ -662,6 +662,7 @@ function TendersContent() {
> >
<option value="all">{t('common.all') || 'All status'}</option> <option value="all">{t('common.all') || 'All status'}</option>
<option value="ACTIVE">Active</option> <option value="ACTIVE">Active</option>
<option value="EXPIRED">Expired</option>
<option value="CONVERTED_TO_DEAL">Converted</option> <option value="CONVERTED_TO_DEAL">Converted</option>
<option value="CANCELLED">Cancelled</option> <option value="CANCELLED">Cancelled</option>
</select> </select>
@@ -721,12 +722,14 @@ function TendersContent() {
className={`inline-flex px-2.5 py-1 text-xs font-medium rounded-full ${ className={`inline-flex px-2.5 py-1 text-xs font-medium rounded-full ${
tender.status === 'ACTIVE' tender.status === 'ACTIVE'
? 'bg-green-100 text-green-800' ? 'bg-green-100 text-green-800'
: tender.status === 'EXPIRED'
? 'bg-red-100 text-red-800'
: tender.status === 'CONVERTED_TO_DEAL' : tender.status === 'CONVERTED_TO_DEAL'
? 'bg-blue-100 text-blue-800' ? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-800' : 'bg-gray-100 text-gray-800'
}`} }`}
> >
{tender.status} {tender.status === 'EXPIRED' ? 'EXPIRED' : tender.status}
</span> </span>
</td> </td>
<td className="px-6 py-4 text-right align-middle"> <td className="px-6 py-4 text-right align-middle">