edit tender module
This commit is contained in:
@@ -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 },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user