updates for contacts & tenders Modules
This commit is contained in:
@@ -42,7 +42,7 @@ router.post(
|
|||||||
'/',
|
'/',
|
||||||
authorize('contacts', 'contacts', 'create'),
|
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',]),
|
'BANK','UNIVERSITY','SCHOOL','UN','NGO','INSTITUTION',]),
|
||||||
body('name').notEmpty().trim(),
|
body('name').notEmpty().trim(),
|
||||||
body('email').optional().isEmail(),
|
body('email').optional().isEmail(),
|
||||||
|
|||||||
@@ -679,7 +679,7 @@ class ContactsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate type
|
// 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({
|
results.errors.push({
|
||||||
row: rowNumber,
|
row: rowNumber,
|
||||||
field: 'type',
|
field: 'type',
|
||||||
|
|||||||
@@ -227,6 +227,39 @@ export class TendersController {
|
|||||||
next(error);
|
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();
|
export const tendersController = new TendersController();
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ const upload = multer({
|
|||||||
limits: { fileSize: config.upload.maxFileSize },
|
limits: { fileSize: config.upload.maxFileSize },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// View attachment
|
||||||
|
router.get(
|
||||||
|
'/attachments/:attachmentId/view',
|
||||||
|
param('attachmentId').isUUID(),
|
||||||
|
validate,
|
||||||
|
tendersController.viewAttachment
|
||||||
|
)
|
||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
|
|
||||||
// Enum/lookup routes (no resource id) - place before /:id routes
|
// Enum/lookup routes (no resource id) - place before /:id routes
|
||||||
@@ -173,3 +182,14 @@ router.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Delete attachment
|
||||||
|
router.delete(
|
||||||
|
'/attachments/:attachmentId',
|
||||||
|
authorize('tenders', 'tenders', 'update'),
|
||||||
|
param('attachmentId').isUUID(),
|
||||||
|
validate,
|
||||||
|
tendersController.deleteAttachment
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { AppError } from '../../shared/middleware/errorHandler';
|
|||||||
import { AuditLogger } from '../../shared/utils/auditLogger';
|
import { AuditLogger } from '../../shared/utils/auditLogger';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import fs from 'fs'
|
||||||
|
|
||||||
|
|
||||||
const TENDER_SOURCE_VALUES = [
|
const TENDER_SOURCE_VALUES = [
|
||||||
'GOVERNMENT_SITE',
|
'GOVERNMENT_SITE',
|
||||||
@@ -33,18 +35,31 @@ export interface CreateTenderData {
|
|||||||
issuingBodyName: string;
|
issuingBodyName: string;
|
||||||
title: string;
|
title: string;
|
||||||
tenderNumber: string;
|
tenderNumber: string;
|
||||||
|
|
||||||
termsValue: number;
|
termsValue: number;
|
||||||
bondValue: number;
|
bondValue: number;
|
||||||
|
|
||||||
|
// new extra fields stored inside notes metadata
|
||||||
|
initialBondValue?: number;
|
||||||
|
finalBondValue?: number;
|
||||||
|
finalBondRefundPeriod?: string;
|
||||||
|
siteVisitRequired?: boolean;
|
||||||
|
siteVisitLocation?: string;
|
||||||
|
termsPickupProvince?: string;
|
||||||
|
|
||||||
announcementDate: string;
|
announcementDate: string;
|
||||||
closingDate: string;
|
closingDate: string;
|
||||||
announcementLink?: string;
|
announcementLink?: string;
|
||||||
|
|
||||||
source: string;
|
source: string;
|
||||||
sourceOther?: string;
|
sourceOther?: string;
|
||||||
announcementType: string;
|
announcementType: string;
|
||||||
|
|
||||||
notes?: string;
|
notes?: string;
|
||||||
contactId?: string;
|
contactId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface CreateDirectiveData {
|
export interface CreateDirectiveData {
|
||||||
type: string;
|
type: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
@@ -71,12 +86,92 @@ class TendersService {
|
|||||||
return `TND-${year}-${seq}`;
|
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<T extends { notes?: string | null; bondValue?: any }>(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<any[]> {
|
async findPossibleDuplicates(data: CreateTenderData): Promise<any[]> {
|
||||||
const announcementDate = data.announcementDate ? new Date(data.announcementDate) : null;
|
const announcementDate = data.announcementDate ? new Date(data.announcementDate) : null;
|
||||||
const closingDate = data.closingDate ? new Date(data.closingDate) : null;
|
const closingDate = data.closingDate ? new Date(data.closingDate) : null;
|
||||||
const termsValue = Number(data.termsValue);
|
const termsValue = Number(data.termsValue);
|
||||||
const bondValue = Number(data.bondValue);
|
const bondValue = Number(data.initialBondValue ?? data.bondValue ?? 0);
|
||||||
|
|
||||||
const where: Prisma.TenderWhereInput = {
|
const where: Prisma.TenderWhereInput = {
|
||||||
status: { not: 'CANCELLED' },
|
status: { not: 'CANCELLED' },
|
||||||
};
|
};
|
||||||
@@ -135,20 +230,33 @@ class TendersService {
|
|||||||
const announcementDate = new Date(data.announcementDate);
|
const announcementDate = new Date(data.announcementDate);
|
||||||
const closingDate = new Date(data.closingDate);
|
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({
|
const tender = await prisma.tender.create({
|
||||||
data: {
|
data: {
|
||||||
tenderNumber,
|
tenderNumber,
|
||||||
issuingBodyName: data.issuingBodyName.trim(),
|
issuingBodyName: data.issuingBodyName.trim(),
|
||||||
title: data.title.trim(),
|
title: data.title.trim(),
|
||||||
termsValue: data.termsValue,
|
termsValue: data.termsValue,
|
||||||
bondValue: data.bondValue,
|
bondValue: Number(data.initialBondValue ?? data.bondValue ?? 0),
|
||||||
announcementDate,
|
announcementDate,
|
||||||
closingDate,
|
closingDate,
|
||||||
announcementLink: data.announcementLink?.trim() || null,
|
announcementLink: data.announcementLink?.trim() || null,
|
||||||
source: data.source,
|
source: data.source,
|
||||||
sourceOther: data.sourceOther?.trim() || null,
|
sourceOther: data.sourceOther?.trim() || null,
|
||||||
announcementType: data.announcementType,
|
announcementType: data.announcementType,
|
||||||
notes: data.notes?.trim() || null,
|
notes: finalNotes,
|
||||||
contactId: data.contactId || null,
|
contactId: data.contactId || null,
|
||||||
createdById: userId,
|
createdById: userId,
|
||||||
},
|
},
|
||||||
@@ -165,8 +273,10 @@ class TendersService {
|
|||||||
userId,
|
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) {
|
async findAll(filters: any, page: number, pageSize: number) {
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
@@ -195,7 +305,12 @@ class TendersService {
|
|||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
return { tenders, total, page, pageSize };
|
return {
|
||||||
|
tenders: tenders.map((t) => this.mapTenderExtraFields(t)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string) {
|
async findById(id: string) {
|
||||||
@@ -217,7 +332,7 @@ class TendersService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!tender) throw new AppError(404, 'Tender not found');
|
if (!tender) throw new AppError(404, 'Tender not found');
|
||||||
return tender;
|
return this.mapTenderExtraFields(tender);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, data: Partial<CreateTenderData>, userId: string) {
|
async update(id: string, data: Partial<CreateTenderData>, userId: string) {
|
||||||
@@ -228,17 +343,76 @@ class TendersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateData: Prisma.TenderUpdateInput = {};
|
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.title !== undefined) updateData.title = data.title.trim();
|
||||||
if (data.issuingBodyName !== undefined) updateData.issuingBodyName = data.issuingBodyName.trim();
|
if (data.issuingBodyName !== undefined) updateData.issuingBodyName = data.issuingBodyName.trim();
|
||||||
if (data.termsValue !== undefined) updateData.termsValue = data.termsValue;
|
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.announcementDate !== undefined) updateData.announcementDate = new Date(data.announcementDate);
|
||||||
if (data.closingDate !== undefined) updateData.closingDate = new Date(data.closingDate);
|
if (data.closingDate !== undefined) updateData.closingDate = new Date(data.closingDate);
|
||||||
if (data.announcementLink !== undefined) updateData.announcementLink = data.announcementLink?.trim() || null;
|
if (data.announcementLink !== undefined) updateData.announcementLink = data.announcementLink?.trim() || null;
|
||||||
if (data.source !== undefined) updateData.source = data.source;
|
if (data.source !== undefined) updateData.source = data.source;
|
||||||
if (data.sourceOther !== undefined) updateData.sourceOther = data.sourceOther?.trim() || null;
|
if (data.sourceOther !== undefined) updateData.sourceOther = data.sourceOther?.trim() || null;
|
||||||
if (data.announcementType !== undefined) updateData.announcementType = data.announcementType;
|
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) {
|
if (data.contactId !== undefined) {
|
||||||
updateData.contact = data.contactId
|
updateData.contact = data.contactId
|
||||||
? { connect: { id: data.contactId } }
|
? { connect: { id: data.contactId } }
|
||||||
@@ -261,7 +435,7 @@ class TendersService {
|
|||||||
userId,
|
userId,
|
||||||
changes: { before: existing, after: data },
|
changes: { before: existing, after: data },
|
||||||
});
|
});
|
||||||
return tender;
|
return this.mapTenderExtraFields(tender);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createDirective(tenderId: string, data: CreateDirectiveData, userId: string) {
|
async createDirective(tenderId: string, data: CreateDirectiveData, userId: string) {
|
||||||
@@ -518,6 +692,34 @@ class TendersService {
|
|||||||
return attachment;
|
return attachment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAttachmentFile(attachmentId: string): Promise<string> {
|
||||||
|
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<void> {
|
||||||
|
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<string> {
|
private async generateDealNumber(): Promise<string> {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
const prefix = `DEAL-${year}-`;
|
const prefix = `DEAL-${year}-`;
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ function ContactDetailContent() {
|
|||||||
HOLDING: 'bg-purple-100 text-purple-700',
|
HOLDING: 'bg-purple-100 text-purple-700',
|
||||||
GOVERNMENT: 'bg-orange-100 text-orange-700',
|
GOVERNMENT: 'bg-orange-100 text-orange-700',
|
||||||
ORGANIZATION: 'bg-cyan-100 text-cyan-700',
|
ORGANIZATION: 'bg-cyan-100 text-cyan-700',
|
||||||
|
EMBASSIES: 'bg-red-100 text-red-700',
|
||||||
BANK: 'bg-emerald-100 text-emerald-700',
|
BANK: 'bg-emerald-100 text-emerald-700',
|
||||||
UNIVERSITY: 'bg-indigo-100 text-indigo-700',
|
UNIVERSITY: 'bg-indigo-100 text-indigo-700',
|
||||||
SCHOOL: 'bg-yellow-100 text-yellow-700',
|
SCHOOL: 'bg-yellow-100 text-yellow-700',
|
||||||
@@ -119,6 +120,7 @@ function ContactDetailContent() {
|
|||||||
ORGANIZATION: 'منظمات - Organizations',
|
ORGANIZATION: 'منظمات - Organizations',
|
||||||
BANK: 'بنوك - Banks',
|
BANK: 'بنوك - Banks',
|
||||||
UNIVERSITY: 'جامعات - Universities',
|
UNIVERSITY: 'جامعات - Universities',
|
||||||
|
EMBASSIES: 'سفارات - Embassies',
|
||||||
SCHOOL: 'مدارس - Schools',
|
SCHOOL: 'مدارس - Schools',
|
||||||
UN: 'UN - United Nations',
|
UN: 'UN - United Nations',
|
||||||
NGO: 'NGO - Non-Governmental Organization',
|
NGO: 'NGO - Non-Governmental Organization',
|
||||||
|
|||||||
@@ -387,6 +387,7 @@ function ContactsContent() {
|
|||||||
<option value="HOLDING">Holdings</option>
|
<option value="HOLDING">Holdings</option>
|
||||||
<option value="GOVERNMENT">Government</option>
|
<option value="GOVERNMENT">Government</option>
|
||||||
<option value="ORGANIZATION">Organizations</option>
|
<option value="ORGANIZATION">Organizations</option>
|
||||||
|
<option value="EMBASSIES">Embassies</option>
|
||||||
<option value="BANK">Banks</option>
|
<option value="BANK">Banks</option>
|
||||||
<option value="UNIVERSITY">Universities</option>
|
<option value="UNIVERSITY">Universities</option>
|
||||||
<option value="SCHOOL">Schools</option>
|
<option value="SCHOOL">Schools</option>
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ function HRContent() {
|
|||||||
mobile: '',
|
mobile: '',
|
||||||
dateOfBirth: '',
|
dateOfBirth: '',
|
||||||
gender: '',
|
gender: '',
|
||||||
nationality: 'Saudi Arabia',
|
nationality: 'Syria',
|
||||||
nationalId: '',
|
nationalId: '',
|
||||||
employmentType: 'FULL_TIME',
|
employmentType: 'FULL_TIME',
|
||||||
contractType: 'UNLIMITED',
|
contractType: 'UNLIMITED',
|
||||||
|
|||||||
@@ -10,14 +10,13 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
Building2,
|
Building2,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
User,
|
|
||||||
History,
|
History,
|
||||||
Plus,
|
Plus,
|
||||||
Loader2,
|
Loader2,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Upload,
|
Upload,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
AlertCircle,
|
MapPin,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||||
import LoadingSpinner from '@/components/LoadingSpinner'
|
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||||
@@ -51,8 +50,16 @@ function TenderDetailContent() {
|
|||||||
const [employees, setEmployees] = useState<any[]>([])
|
const [employees, setEmployees] = useState<any[]>([])
|
||||||
const [contacts, setContacts] = useState<any[]>([])
|
const [contacts, setContacts] = useState<any[]>([])
|
||||||
const [pipelines, setPipelines] = useState<any[]>([])
|
const [pipelines, setPipelines] = useState<any[]>([])
|
||||||
const [directiveForm, setDirectiveForm] = useState<CreateDirectiveData>({ type: 'BUY_TERMS', assignedToEmployeeId: '', notes: '' })
|
const [directiveForm, setDirectiveForm] = useState<CreateDirectiveData>({
|
||||||
const [convertForm, setConvertForm] = useState({ contactId: '', pipelineId: '', ownerId: '' })
|
type: 'BUY_TERMS',
|
||||||
|
assignedToEmployeeId: '',
|
||||||
|
notes: '',
|
||||||
|
})
|
||||||
|
const [convertForm, setConvertForm] = useState({
|
||||||
|
contactId: '',
|
||||||
|
pipelineId: '',
|
||||||
|
ownerId: '',
|
||||||
|
})
|
||||||
const [completeNotes, setCompleteNotes] = useState('')
|
const [completeNotes, setCompleteNotes] = useState('')
|
||||||
const [directiveTypeValues, setDirectiveTypeValues] = useState<string[]>([])
|
const [directiveTypeValues, setDirectiveTypeValues] = useState<string[]>([])
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
@@ -93,10 +100,17 @@ function TenderDetailContent() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showDirectiveModal || showConvertModal) {
|
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) {
|
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(() => {})
|
pipelinesAPI.getAll().then(setPipelines).catch(() => {})
|
||||||
}
|
}
|
||||||
}, [showDirectiveModal, showConvertModal])
|
}, [showDirectiveModal, showConvertModal])
|
||||||
@@ -107,6 +121,7 @@ function TenderDetailContent() {
|
|||||||
toast.error(t('tenders.assignee') + ' ' + t('common.required'))
|
toast.error(t('tenders.assignee') + ' ' + t('common.required'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await tendersAPI.createDirective(tenderId, directiveForm)
|
await tendersAPI.createDirective(tenderId, directiveForm)
|
||||||
@@ -124,9 +139,13 @@ function TenderDetailContent() {
|
|||||||
const handleCompleteDirective = async (e: React.FormEvent) => {
|
const handleCompleteDirective = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!showCompleteModal) return
|
if (!showCompleteModal) return
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await tendersAPI.updateDirective(showCompleteModal.id, { status: 'COMPLETED', completionNotes: completeNotes })
|
await tendersAPI.updateDirective(showCompleteModal.id, {
|
||||||
|
status: 'COMPLETED',
|
||||||
|
completionNotes: completeNotes,
|
||||||
|
})
|
||||||
toast.success('Task completed')
|
toast.success('Task completed')
|
||||||
setShowCompleteModal(null)
|
setShowCompleteModal(null)
|
||||||
setCompleteNotes('')
|
setCompleteNotes('')
|
||||||
@@ -144,6 +163,7 @@ function TenderDetailContent() {
|
|||||||
toast.error('Contact and Pipeline are required')
|
toast.error('Contact and Pipeline are required')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const deal = await tendersAPI.convertToDeal(tenderId, convertForm)
|
const deal = await tendersAPI.convertToDeal(tenderId, convertForm)
|
||||||
@@ -160,6 +180,7 @@ function TenderDetailContent() {
|
|||||||
const handleTenderFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleTenderFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await tendersAPI.uploadTenderAttachment(tenderId, file)
|
await tendersAPI.uploadTenderAttachment(tenderId, file)
|
||||||
@@ -183,7 +204,9 @@ function TenderDetailContent() {
|
|||||||
const directiveId = directiveIdForUpload
|
const directiveId = directiveIdForUpload
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
setDirectiveIdForUpload(null)
|
setDirectiveIdForUpload(null)
|
||||||
|
|
||||||
if (!file || !directiveId) return
|
if (!file || !directiveId) return
|
||||||
|
|
||||||
setUploadingDirectiveId(directiveId)
|
setUploadingDirectiveId(directiveId)
|
||||||
try {
|
try {
|
||||||
await tendersAPI.uploadDirectiveAttachment(directiveId, file)
|
await tendersAPI.uploadDirectiveAttachment(directiveId, file)
|
||||||
@@ -220,10 +243,13 @@ function TenderDetailContent() {
|
|||||||
<ArrowLeft className="h-5 w-5" />
|
<ArrowLeft className="h-5 w-5" />
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">{tender.tenderNumber} – {tender.title}</h1>
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
{tender.tenderNumber} – {tender.title}
|
||||||
|
</h1>
|
||||||
<p className="text-sm text-gray-600">{tender.issuingBodyName}</p>
|
<p className="text-sm text-gray-600">{tender.issuingBodyName}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tender.status === 'ACTIVE' && (
|
{tender.status === 'ACTIVE' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowConvertModal(true)}
|
onClick={() => setShowConvertModal(true)}
|
||||||
@@ -242,7 +268,9 @@ function TenderDetailContent() {
|
|||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id as any)}
|
onClick={() => setActiveTab(tab.id as any)}
|
||||||
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 ? 'bg-indigo-100 text-indigo-800' : 'text-gray-600 hover:bg-gray-100'
|
activeTab === tab.id
|
||||||
|
? 'bg-indigo-100 text-indigo-800'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<tab.icon className="h-4 w-4" />
|
<tab.icon className="h-4 w-4" />
|
||||||
@@ -262,6 +290,7 @@ function TenderDetailContent() {
|
|||||||
<p>{tender.announcementDate?.split('T')[0]}</p>
|
<p>{tender.announcementDate?.split('T')[0]}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<Calendar className="h-5 w-5 text-gray-400 mt-0.5" />
|
<Calendar className="h-5 w-5 text-gray-400 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
@@ -269,6 +298,7 @@ function TenderDetailContent() {
|
|||||||
<p>{tender.closingDate?.split('T')[0]}</p>
|
<p>{tender.closingDate?.split('T')[0]}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<DollarSign className="h-5 w-5 text-gray-400 mt-0.5" />
|
<DollarSign className="h-5 w-5 text-gray-400 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
@@ -276,21 +306,71 @@ function TenderDetailContent() {
|
|||||||
<p>{Number(tender.termsValue)} SAR</p>
|
<p>{Number(tender.termsValue)} SAR</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<DollarSign className="h-5 w-5 text-gray-400 mt-0.5" />
|
<DollarSign className="h-5 w-5 text-gray-400 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500">{t('tenders.bondValue')}</p>
|
<p className="text-xs text-gray-500">التأمينات الأولية</p>
|
||||||
<p>{Number(tender.bondValue)} SAR</p>
|
<p>{Number(tender.initialBondValue || tender.bondValue || 0)} SAR</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<DollarSign className="h-5 w-5 text-gray-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">التأمينات النهائية</p>
|
||||||
|
<p>{Number(tender.finalBondValue || 0)} SAR</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Calendar className="h-5 w-5 text-gray-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">زمن الاسترجاع</p>
|
||||||
|
<p>{tender.finalBondRefundPeriod || '-'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Calendar className="h-5 w-5 text-gray-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">زيارة الموقع</p>
|
||||||
|
<p>{tender.siteVisitRequired ? 'إجبارية' : 'غير إجبارية'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tender.siteVisitRequired && (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<MapPin className="h-5 w-5 text-gray-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">مكان الزيارة</p>
|
||||||
|
<p>{tender.siteVisitLocation || '-'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<MapPin className="h-5 w-5 text-gray-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">مكان استلام دفتر الشروط</p>
|
||||||
|
<p>{tender.termsPickupProvince || '-'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tender.announcementLink && (
|
{tender.announcementLink && (
|
||||||
<p>
|
<p>
|
||||||
<a href={tender.announcementLink} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:underline">
|
<a
|
||||||
|
href={tender.announcementLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-indigo-600 hover:underline"
|
||||||
|
>
|
||||||
{t('tenders.announcementLink')}
|
{t('tenders.announcementLink')}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tender.notes && (
|
{tender.notes && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500">{t('common.notes')}</p>
|
<p className="text-xs text-gray-500">{t('common.notes')}</p>
|
||||||
@@ -312,19 +392,29 @@ function TenderDetailContent() {
|
|||||||
{t('tenders.addDirective')}
|
{t('tenders.addDirective')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!tender.directives?.length ? (
|
{!tender.directives?.length ? (
|
||||||
<p className="text-gray-500">{t('common.noData')}</p>
|
<p className="text-gray-500">{t('common.noData')}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{tender.directives.map((d) => (
|
{tender.directives.map((d) => (
|
||||||
<li key={d.id} className="border rounded-lg p-4 flex flex-wrap items-center justify-between gap-2">
|
<li
|
||||||
|
key={d.id}
|
||||||
|
className="border rounded-lg p-4 flex flex-wrap items-center justify-between gap-2"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{DIRECTIVE_TYPE_LABELS[d.type] || d.type}</p>
|
<p className="font-medium">{DIRECTIVE_TYPE_LABELS[d.type] || d.type}</p>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
{d.assignedToEmployee ? `${d.assignedToEmployee.firstName} ${d.assignedToEmployee.lastName}` : ''} · {d.status}
|
{d.assignedToEmployee
|
||||||
|
? `${d.assignedToEmployee.firstName} ${d.assignedToEmployee.lastName}`
|
||||||
|
: ''}{' '}
|
||||||
|
· {d.status}
|
||||||
</p>
|
</p>
|
||||||
{d.completionNotes && <p className="text-sm mt-1">{d.completionNotes}</p>}
|
{d.completionNotes && (
|
||||||
|
<p className="text-sm mt-1">{d.completionNotes}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{d.status !== 'COMPLETED' && d.assignedToEmployee?.user?.id && (
|
{d.status !== 'COMPLETED' && d.assignedToEmployee?.user?.id && (
|
||||||
<button
|
<button
|
||||||
@@ -334,19 +424,25 @@ function TenderDetailContent() {
|
|||||||
{t('tenders.completeTask')}
|
{t('tenders.completeTask')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={directiveFileInputRef}
|
ref={directiveFileInputRef}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleDirectiveFileUpload}
|
onChange={handleDirectiveFileUpload}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDirectiveFileSelect(d.id)}
|
onClick={() => handleDirectiveFileSelect(d.id)}
|
||||||
disabled={uploadingDirectiveId === d.id}
|
disabled={uploadingDirectiveId === d.id}
|
||||||
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"
|
||||||
>
|
>
|
||||||
{uploadingDirectiveId === d.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
{uploadingDirectiveId === d.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
)}
|
||||||
{t('tenders.uploadFile')}
|
{t('tenders.uploadFile')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -371,17 +467,49 @@ function TenderDetailContent() {
|
|||||||
disabled={submitting}
|
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"
|
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 ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
{submitting ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
)}
|
||||||
{t('tenders.uploadFile')}
|
{t('tenders.uploadFile')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!tender.attachments?.length ? (
|
{!tender.attachments?.length ? (
|
||||||
<p className="text-gray-500">{t('common.noData')}</p>
|
<p className="text-gray-500">{t('common.noData')}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{tender.attachments.map((a: any) => (
|
{tender.attachments.map((a: any) => (
|
||||||
<li key={a.id} className="text-sm text-gray-700">
|
<li
|
||||||
|
key={a.id}
|
||||||
|
className="flex items-center justify-between border rounded px-3 py-2"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`${process.env.NEXT_PUBLIC_API_URL}/tenders/attachments/${a.id}/view`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-indigo-600 hover:underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
{a.originalName || a.fileName}
|
{a.originalName || a.fileName}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm('حذف الملف؟')) return
|
||||||
|
try {
|
||||||
|
await tendersAPI.deleteAttachment(a.id)
|
||||||
|
toast.success('تم الحذف')
|
||||||
|
fetchTender()
|
||||||
|
} catch {
|
||||||
|
toast.error('فشل الحذف')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-red-600 text-sm hover:underline"
|
||||||
|
>
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -397,7 +525,8 @@ function TenderDetailContent() {
|
|||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{history.map((h: any) => (
|
{history.map((h: any) => (
|
||||||
<li key={h.id} className="text-sm border-b border-gray-100 pb-2">
|
<li key={h.id} className="text-sm border-b border-gray-100 pb-2">
|
||||||
<span className="font-medium">{h.action}</span> · {h.user?.username} · {h.createdAt?.split('T')[0]}
|
<span className="font-medium">{h.action}</span> · {h.user?.username} ·{' '}
|
||||||
|
{h.createdAt?.split('T')[0]}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -408,36 +537,54 @@ function TenderDetailContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal isOpen={showDirectiveModal} onClose={() => setShowDirectiveModal(false)} title={t('tenders.addDirective')}>
|
<Modal
|
||||||
|
isOpen={showDirectiveModal}
|
||||||
|
onClose={() => setShowDirectiveModal(false)}
|
||||||
|
title={t('tenders.addDirective')}
|
||||||
|
>
|
||||||
<form onSubmit={handleAddDirective} className="space-y-4">
|
<form onSubmit={handleAddDirective} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.directiveType')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('tenders.directiveType')}
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={directiveForm.type}
|
value={directiveForm.type}
|
||||||
onChange={(e) => setDirectiveForm({ ...directiveForm, type: e.target.value })}
|
onChange={(e) => setDirectiveForm({ ...directiveForm, type: e.target.value })}
|
||||||
className="w-full px-3 py-2 border rounded-lg"
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
>
|
>
|
||||||
{directiveTypeValues.map((v) => (
|
{directiveTypeValues.map((v) => (
|
||||||
<option key={v} value={v}>{DIRECTIVE_TYPE_LABELS[v] || v}</option>
|
<option key={v} value={v}>
|
||||||
|
{DIRECTIVE_TYPE_LABELS[v] || v}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.assignee')} *</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('tenders.assignee')} *
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={directiveForm.assignedToEmployeeId}
|
value={directiveForm.assignedToEmployeeId}
|
||||||
onChange={(e) => setDirectiveForm({ ...directiveForm, assignedToEmployeeId: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setDirectiveForm({ ...directiveForm, assignedToEmployeeId: e.target.value })
|
||||||
|
}
|
||||||
className="w-full px-3 py-2 border rounded-lg"
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">Select employee</option>
|
<option value="">Select employee</option>
|
||||||
{employees.map((emp) => (
|
{employees.map((emp) => (
|
||||||
<option key={emp.id} value={emp.id}>{emp.firstName} {emp.lastName}</option>
|
<option key={emp.id} value={emp.id}>
|
||||||
|
{emp.firstName} {emp.lastName}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('common.notes')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('common.notes')}
|
||||||
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={directiveForm.notes || ''}
|
value={directiveForm.notes || ''}
|
||||||
onChange={(e) => setDirectiveForm({ ...directiveForm, notes: e.target.value })}
|
onChange={(e) => setDirectiveForm({ ...directiveForm, notes: e.target.value })}
|
||||||
@@ -445,9 +592,20 @@ function TenderDetailContent() {
|
|||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button type="button" onClick={() => setShowDirectiveModal(false)} className="px-4 py-2 border rounded-lg">{t('common.cancel')}</button>
|
<button
|
||||||
<button type="submit" disabled={submitting} className="px-4 py-2 bg-indigo-600 text-white rounded-lg disabled:opacity-50 flex items-center gap-2">
|
type="button"
|
||||||
|
onClick={() => setShowDirectiveModal(false)}
|
||||||
|
className="px-4 py-2 border rounded-lg"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-4 py-2 bg-indigo-600 text-white rounded-lg disabled:opacity-50 flex items-center gap-2"
|
||||||
|
>
|
||||||
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</button>
|
</button>
|
||||||
@@ -455,10 +613,16 @@ function TenderDetailContent() {
|
|||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal isOpen={!!showCompleteModal} onClose={() => setShowCompleteModal(null)} title={t('tenders.completeTask')}>
|
<Modal
|
||||||
|
isOpen={!!showCompleteModal}
|
||||||
|
onClose={() => setShowCompleteModal(null)}
|
||||||
|
title={t('tenders.completeTask')}
|
||||||
|
>
|
||||||
<form onSubmit={handleCompleteDirective} className="space-y-4">
|
<form onSubmit={handleCompleteDirective} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.completionNotes')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('tenders.completionNotes')}
|
||||||
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={completeNotes}
|
value={completeNotes}
|
||||||
onChange={(e) => setCompleteNotes(e.target.value)}
|
onChange={(e) => setCompleteNotes(e.target.value)}
|
||||||
@@ -466,9 +630,20 @@ function TenderDetailContent() {
|
|||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button type="button" onClick={() => setShowCompleteModal(null)} className="px-4 py-2 border rounded-lg">{t('common.cancel')}</button>
|
<button
|
||||||
<button type="submit" disabled={submitting} className="px-4 py-2 bg-green-600 text-white rounded-lg disabled:opacity-50 flex items-center gap-2">
|
type="button"
|
||||||
|
onClick={() => setShowCompleteModal(null)}
|
||||||
|
className="px-4 py-2 border rounded-lg"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white rounded-lg disabled:opacity-50 flex items-center gap-2"
|
||||||
|
>
|
||||||
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</button>
|
</button>
|
||||||
@@ -476,7 +651,11 @@ function TenderDetailContent() {
|
|||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal isOpen={showConvertModal} onClose={() => setShowConvertModal(false)} title={t('tenders.convertToDeal')}>
|
<Modal
|
||||||
|
isOpen={showConvertModal}
|
||||||
|
onClose={() => setShowConvertModal(false)}
|
||||||
|
title={t('tenders.convertToDeal')}
|
||||||
|
>
|
||||||
<form onSubmit={handleConvertToDeal} className="space-y-4">
|
<form onSubmit={handleConvertToDeal} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Contact *</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Contact *</label>
|
||||||
@@ -488,10 +667,13 @@ function TenderDetailContent() {
|
|||||||
>
|
>
|
||||||
<option value="">Select contact</option>
|
<option value="">Select contact</option>
|
||||||
{contacts.map((c) => (
|
{contacts.map((c) => (
|
||||||
<option key={c.id} value={c.id}>{c.name}</option>
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Pipeline *</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Pipeline *</label>
|
||||||
<select
|
<select
|
||||||
@@ -502,13 +684,26 @@ function TenderDetailContent() {
|
|||||||
>
|
>
|
||||||
<option value="">Select pipeline</option>
|
<option value="">Select pipeline</option>
|
||||||
{pipelines.map((p) => (
|
{pipelines.map((p) => (
|
||||||
<option key={p.id} value={p.id}>{p.name}</option>
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button type="button" onClick={() => setShowConvertModal(false)} className="px-4 py-2 border rounded-lg">{t('common.cancel')}</button>
|
<button
|
||||||
<button type="submit" disabled={submitting} className="px-4 py-2 bg-green-600 text-white rounded-lg disabled:opacity-50 flex items-center gap-2">
|
type="button"
|
||||||
|
onClick={() => setShowConvertModal(false)}
|
||||||
|
className="px-4 py-2 border rounded-lg"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white rounded-lg disabled:opacity-50 flex items-center gap-2"
|
||||||
|
>
|
||||||
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
{t('tenders.convertToDeal')}
|
{t('tenders.convertToDeal')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
Calendar,
|
|
||||||
Building2,
|
|
||||||
DollarSign,
|
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -32,6 +29,23 @@ const SOURCE_LABELS: Record<string, string> = {
|
|||||||
MANUAL: 'Manual entry',
|
MANUAL: 'Manual entry',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SYRIA_PROVINCES = [
|
||||||
|
'دمشق',
|
||||||
|
'ريف دمشق',
|
||||||
|
'حلب',
|
||||||
|
'حمص',
|
||||||
|
'حماة',
|
||||||
|
'اللاذقية',
|
||||||
|
'طرطوس',
|
||||||
|
'إدلب',
|
||||||
|
'درعا',
|
||||||
|
'السويداء',
|
||||||
|
'القنيطرة',
|
||||||
|
'دير الزور',
|
||||||
|
'الرقة',
|
||||||
|
'الحسكة',
|
||||||
|
]
|
||||||
|
|
||||||
const ANNOUNCEMENT_LABELS: Record<string, string> = {
|
const ANNOUNCEMENT_LABELS: Record<string, string> = {
|
||||||
FIRST: 'First announcement',
|
FIRST: 'First announcement',
|
||||||
RE_ANNOUNCEMENT_2: 'Re-announcement 2nd',
|
RE_ANNOUNCEMENT_2: 'Re-announcement 2nd',
|
||||||
@@ -39,6 +53,27 @@ const ANNOUNCEMENT_LABELS: Record<string, string> = {
|
|||||||
RE_ANNOUNCEMENT_4: 'Re-announcement 4th',
|
RE_ANNOUNCEMENT_4: 'Re-announcement 4th',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getInitialFormData = (): CreateTenderData => ({
|
||||||
|
tenderNumber: '',
|
||||||
|
issuingBodyName: '',
|
||||||
|
title: '',
|
||||||
|
termsValue: 0,
|
||||||
|
bondValue: 0,
|
||||||
|
|
||||||
|
initialBondValue: 0,
|
||||||
|
finalBondValue: 0,
|
||||||
|
finalBondRefundPeriod: '',
|
||||||
|
siteVisitRequired: false,
|
||||||
|
siteVisitLocation: '',
|
||||||
|
termsPickupProvince: '',
|
||||||
|
|
||||||
|
announcementDate: '',
|
||||||
|
closingDate: '',
|
||||||
|
source: 'MANUAL',
|
||||||
|
announcementType: 'FIRST',
|
||||||
|
notes: '',
|
||||||
|
})
|
||||||
|
|
||||||
function TendersContent() {
|
function TendersContent() {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const [tenders, setTenders] = useState<Tender[]>([])
|
const [tenders, setTenders] = useState<Tender[]>([])
|
||||||
@@ -50,17 +85,8 @@ function TendersContent() {
|
|||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const [selectedStatus, setSelectedStatus] = useState('all')
|
const [selectedStatus, setSelectedStatus] = useState('all')
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
const [formData, setFormData] = useState<CreateTenderData>({
|
|
||||||
tenderNumber: '',
|
const [formData, setFormData] = useState<CreateTenderData>(getInitialFormData())
|
||||||
issuingBodyName: '',
|
|
||||||
title: '',
|
|
||||||
termsValue: 0,
|
|
||||||
bondValue: 0,
|
|
||||||
announcementDate: '',
|
|
||||||
closingDate: '',
|
|
||||||
source: 'MANUAL',
|
|
||||||
announcementType: 'FIRST',
|
|
||||||
})
|
|
||||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
|
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [possibleDuplicates, setPossibleDuplicates] = useState<Tender[]>([])
|
const [possibleDuplicates, setPossibleDuplicates] = useState<Tender[]>([])
|
||||||
@@ -68,12 +94,20 @@ function TendersContent() {
|
|||||||
const [sourceValues, setSourceValues] = useState<string[]>([])
|
const [sourceValues, setSourceValues] = useState<string[]>([])
|
||||||
const [announcementTypeValues, setAnnouncementTypeValues] = useState<string[]>([])
|
const [announcementTypeValues, setAnnouncementTypeValues] = useState<string[]>([])
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData(getInitialFormData())
|
||||||
|
setFormErrors({})
|
||||||
|
setPossibleDuplicates([])
|
||||||
|
setShowDuplicateWarning(false)
|
||||||
|
}
|
||||||
|
|
||||||
const fetchTenders = useCallback(async () => {
|
const fetchTenders = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const filters: TenderFilters = { page: currentPage, pageSize }
|
const filters: TenderFilters = { page: currentPage, pageSize }
|
||||||
if (searchTerm) filters.search = searchTerm
|
if (searchTerm) filters.search = searchTerm
|
||||||
if (selectedStatus !== 'all') filters.status = selectedStatus
|
if (selectedStatus !== 'all') filters.status = selectedStatus
|
||||||
|
|
||||||
const data = await tendersAPI.getAll(filters)
|
const data = await tendersAPI.getAll(filters)
|
||||||
setTenders(data.tenders)
|
setTenders(data.tenders)
|
||||||
setTotal(data.total)
|
setTotal(data.total)
|
||||||
@@ -96,24 +130,39 @@ function TendersContent() {
|
|||||||
|
|
||||||
const handleCreate = async (e: React.FormEvent) => {
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
const errors: Record<string, string> = {}
|
const errors: Record<string, string> = {}
|
||||||
|
|
||||||
if (!formData.tenderNumber?.trim()) errors.tenderNumber = t('common.required')
|
if (!formData.tenderNumber?.trim()) errors.tenderNumber = t('common.required')
|
||||||
if (!formData.issuingBodyName?.trim()) errors.issuingBodyName = t('common.required')
|
if (!formData.issuingBodyName?.trim()) errors.issuingBodyName = t('common.required')
|
||||||
if (!formData.title?.trim()) errors.title = t('common.required')
|
if (!formData.title?.trim()) errors.title = t('common.required')
|
||||||
if (!formData.announcementDate) errors.announcementDate = t('common.required')
|
if (!formData.announcementDate) errors.announcementDate = t('common.required')
|
||||||
if (!formData.closingDate) errors.closingDate = t('common.required')
|
if (!formData.closingDate) errors.closingDate = t('common.required')
|
||||||
//if (Number(formData.termsValue) < 0) errors.termsValue = t('common.required')
|
|
||||||
if (Number(formData.bondValue) < 0) errors.bondValue = t('common.required')
|
if (Number(formData.initialBondValue || 0) < 0) {
|
||||||
|
errors.initialBondValue = t('common.required')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.siteVisitRequired && !formData.siteVisitLocation?.trim()) {
|
||||||
|
errors.siteVisitLocation = t('common.required')
|
||||||
|
}
|
||||||
|
|
||||||
setFormErrors(errors)
|
setFormErrors(errors)
|
||||||
if (Object.keys(errors).length > 0) return
|
if (Object.keys(errors).length > 0) return
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const result = await tendersAPI.create(formData)
|
const result = await tendersAPI.create({
|
||||||
|
...formData,
|
||||||
|
bondValue: Number(formData.initialBondValue ?? formData.bondValue ?? 0),
|
||||||
|
})
|
||||||
|
|
||||||
if (result.possibleDuplicates && result.possibleDuplicates.length > 0) {
|
if (result.possibleDuplicates && result.possibleDuplicates.length > 0) {
|
||||||
setPossibleDuplicates(result.possibleDuplicates)
|
setPossibleDuplicates(result.possibleDuplicates)
|
||||||
setShowDuplicateWarning(true)
|
setShowDuplicateWarning(true)
|
||||||
toast(t('tenders.duplicateWarning') || 'Possible duplicates found. Please review.', { icon: '⚠️' })
|
toast(t('tenders.duplicateWarning') || 'Possible duplicates found. Please review.', {
|
||||||
|
icon: '⚠️',
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
toast.success(t('tenders.createSuccess') || 'Tender created successfully')
|
toast.success(t('tenders.createSuccess') || 'Tender created successfully')
|
||||||
setShowCreateModal(false)
|
setShowCreateModal(false)
|
||||||
@@ -127,23 +176,6 @@ function TendersContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setFormData({
|
|
||||||
tenderNumber: '',
|
|
||||||
issuingBodyName: '',
|
|
||||||
title: '',
|
|
||||||
termsValue: 0,
|
|
||||||
bondValue: 0,
|
|
||||||
announcementDate: '',
|
|
||||||
closingDate: '',
|
|
||||||
source: 'MANUAL',
|
|
||||||
announcementType: 'FIRST',
|
|
||||||
})
|
|
||||||
setFormErrors({})
|
|
||||||
setPossibleDuplicates([])
|
|
||||||
setShowDuplicateWarning(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
@@ -155,16 +187,25 @@ function TendersContent() {
|
|||||||
>
|
>
|
||||||
<ArrowLeft className="h-5 w-5" />
|
<ArrowLeft className="h-5 w-5" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileText className="h-8 w-8 text-indigo-600" />
|
<FileText className="h-8 w-8 text-indigo-600" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">{t('nav.tenders') || 'Tenders'}</h1>
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
<p className="text-sm text-gray-600">{t('tenders.subtitle') || 'Tender Management'}</p>
|
{t('nav.tenders') || 'Tenders'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{t('tenders.subtitle') || 'Tender Management'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowCreateModal(true); resetForm(); }}
|
onClick={() => {
|
||||||
|
resetForm()
|
||||||
|
setShowCreateModal(true)
|
||||||
|
}}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
|
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
|
||||||
>
|
>
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="h-5 w-5" />
|
||||||
@@ -184,6 +225,7 @@ function TendersContent() {
|
|||||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
value={selectedStatus}
|
value={selectedStatus}
|
||||||
onChange={(e) => setSelectedStatus(e.target.value)}
|
onChange={(e) => setSelectedStatus(e.target.value)}
|
||||||
@@ -209,26 +251,52 @@ function TendersContent() {
|
|||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('tenders.tenderNumber') || 'Number'}</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('tenders.title') || 'Title'}</th>
|
{t('tenders.tenderNumber') || 'Number'}
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('tenders.issuingBody') || 'Issuing body'}</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('tenders.closingDate') || 'Closing date'}</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('common.status')}</th>
|
{t('tenders.title') || 'Title'}
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">{t('common.actions')}</th>
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
{t('tenders.issuingBody') || 'Issuing body'}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
{t('tenders.closingDate') || 'Closing date'}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
{t('common.status')}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||||
|
{t('common.actions')}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{tenders.map((tender) => (
|
{tenders.map((tender) => (
|
||||||
<tr key={tender.id} className="hover:bg-gray-50">
|
<tr key={tender.id} className="hover:bg-gray-50">
|
||||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">{tender.tenderNumber}</td>
|
<td className="px-4 py-3 text-sm font-medium text-gray-900">
|
||||||
<td className="px-4 py-3 text-sm text-gray-900">{tender.title}</td>
|
{tender.tenderNumber}
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">{tender.issuingBodyName}</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">{tender.closingDate?.split('T')[0]}</td>
|
<td className="px-4 py-3 text-sm text-gray-900">
|
||||||
|
{tender.title}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600">
|
||||||
|
{tender.issuingBodyName}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600">
|
||||||
|
{tender.closingDate?.split('T')[0]}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
<span
|
||||||
tender.status === 'ACTIVE' ? 'bg-green-100 text-green-800' :
|
className={`px-2 py-1 text-xs rounded-full ${
|
||||||
tender.status === 'CONVERTED_TO_DEAL' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'
|
tender.status === 'ACTIVE'
|
||||||
}`}>
|
? 'bg-green-100 text-green-800'
|
||||||
|
: tender.status === 'CONVERTED_TO_DEAL'
|
||||||
|
? 'bg-blue-100 text-blue-800'
|
||||||
|
: 'bg-gray-100 text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{tender.status}
|
{tender.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -251,7 +319,8 @@ function TendersContent() {
|
|||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="px-4 py-3 border-t border-gray-200 flex items-center justify-between">
|
<div className="px-4 py-3 border-t border-gray-200 flex items-center justify-between">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
{t('common.showing') || 'Showing'} {(currentPage - 1) * pageSize + 1}–{Math.min(currentPage * pageSize, total)} {t('common.of') || 'of'} {total}
|
{t('common.showing') || 'Showing'} {(currentPage - 1) * pageSize + 1}–
|
||||||
|
{Math.min(currentPage * pageSize, total)} {t('common.of') || 'of'} {total}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -276,113 +345,259 @@ function TendersContent() {
|
|||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showCreateModal}
|
isOpen={showCreateModal}
|
||||||
onClose={() => { setShowCreateModal(false); setShowDuplicateWarning(false); resetForm(); }}
|
onClose={() => {
|
||||||
|
setShowCreateModal(false)
|
||||||
|
resetForm()
|
||||||
|
}}
|
||||||
title={t('tenders.addTender') || 'Add Tender'}
|
title={t('tenders.addTender') || 'Add Tender'}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleCreate} className="space-y-4">
|
<form onSubmit={handleCreate} className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.tenderNumber')} *</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('tenders.tenderNumber')} *
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.tenderNumber}
|
value={formData.tenderNumber}
|
||||||
onChange={(e) => setFormData({ ...formData, tenderNumber: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, tenderNumber: e.target.value })}
|
||||||
className="w-full px-3 py-2 border rounded-lg"
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
/>
|
/>
|
||||||
{formErrors.tenderNumber && <p className="text-red-500 text-xs mt-1">{formErrors.tenderNumber}</p>}
|
{formErrors.tenderNumber && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{formErrors.tenderNumber}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.issuingBody')} *</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('tenders.issuingBody')} *
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.issuingBodyName}
|
value={formData.issuingBodyName}
|
||||||
onChange={(e) => setFormData({ ...formData, issuingBodyName: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, issuingBodyName: e.target.value })}
|
||||||
className="w-full px-3 py-2 border rounded-lg"
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
/>
|
/>
|
||||||
{formErrors.issuingBodyName && <p className="text-red-500 text-xs mt-1">{formErrors.issuingBodyName}</p>}
|
{formErrors.issuingBodyName && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{formErrors.issuingBodyName}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.titleLabel')} *</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('tenders.titleLabel')} *
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.title}
|
value={formData.title}
|
||||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||||
className="w-full px-3 py-2 border rounded-lg"
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
/>
|
/>
|
||||||
{formErrors.title && <p className="text-red-500 text-xs mt-1">{formErrors.title}</p>}
|
{formErrors.title && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{formErrors.title}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.termsValue')} *</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
<input
|
قيمة دفتر الشروط *
|
||||||
type="number"
|
</label>
|
||||||
value={formData.termsValue || ''}
|
|
||||||
onChange={(e) => setFormData({ ...formData, termsValue: Number(e.target.value) || 0 })}
|
|
||||||
className="w-full px-3 py-2 border rounded-lg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.bondValue')} *</label>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
value={formData.bondValue || ''}
|
value={formData.termsValue || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, bondValue: Number(e.target.value) || 0 })}
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, termsValue: Number(e.target.value) || 0 })
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
قيمة التأمينات الأولية *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={formData.initialBondValue || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
initialBondValue: Number(e.target.value) || 0,
|
||||||
|
bondValue: Number(e.target.value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
|
/>
|
||||||
|
{formErrors.initialBondValue && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{formErrors.initialBondValue}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
قيمة التأمينات النهائية
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={formData.finalBondValue || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, finalBondValue: Number(e.target.value) || 0 })
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
زمن الاسترجاع
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.finalBondRefundPeriod || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, finalBondRefundPeriod: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="مثال: بعد 90 يوم من التسليم النهائي"
|
||||||
className="w-full px-3 py-2 border rounded-lg"
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.announcementDate')} *</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('tenders.announcementDate')} *
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={formData.announcementDate}
|
value={formData.announcementDate}
|
||||||
onChange={(e) => setFormData({ ...formData, announcementDate: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, announcementDate: e.target.value })}
|
||||||
className="w-full px-3 py-2 border rounded-lg"
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
/>
|
/>
|
||||||
{formErrors.announcementDate && <p className="text-red-500 text-xs mt-1">{formErrors.announcementDate}</p>}
|
{formErrors.announcementDate && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{formErrors.announcementDate}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.closingDate')} *</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('tenders.closingDate')} *
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={formData.closingDate}
|
value={formData.closingDate}
|
||||||
onChange={(e) => setFormData({ ...formData, closingDate: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, closingDate: e.target.value })}
|
||||||
className="w-full px-3 py-2 border rounded-lg"
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
/>
|
/>
|
||||||
{formErrors.closingDate && <p className="text-red-500 text-xs mt-1">{formErrors.closingDate}</p>}
|
{formErrors.closingDate && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{formErrors.closingDate}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.source')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('tenders.source')}
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={formData.source}
|
value={formData.source}
|
||||||
onChange={(e) => setFormData({ ...formData, source: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, source: e.target.value })}
|
||||||
className="w-full px-3 py-2 border rounded-lg"
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
>
|
>
|
||||||
{sourceValues.map((s) => (
|
{sourceValues.map((s) => (
|
||||||
<option key={s} value={s}>{SOURCE_LABELS[s] || s}</option>
|
<option key={s} value={s}>
|
||||||
|
{SOURCE_LABELS[s] || s}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.announcementType')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('tenders.announcementType')}
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={formData.announcementType}
|
value={formData.announcementType}
|
||||||
onChange={(e) => setFormData({ ...formData, announcementType: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, announcementType: e.target.value })}
|
||||||
className="w-full px-3 py-2 border rounded-lg"
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
>
|
>
|
||||||
{announcementTypeValues.map((a) => (
|
{announcementTypeValues.map((a) => (
|
||||||
<option key={a} value={a}>{ANNOUNCEMENT_LABELS[a] || a}</option>
|
<option key={a} value={a}>
|
||||||
|
{ANNOUNCEMENT_LABELS[a] || a}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.announcementLink')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
زيارة الموقع
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.siteVisitRequired ? 'YES' : 'NO'}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
siteVisitRequired: e.target.value === 'YES',
|
||||||
|
siteVisitLocation: e.target.value === 'YES' ? formData.siteVisitLocation || '' : '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
|
>
|
||||||
|
<option value="NO">غير إجبارية</option>
|
||||||
|
<option value="YES">إجبارية</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
مكان استلام دفتر الشروط - المحافظة
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.termsPickupProvince || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, termsPickupProvince: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
|
>
|
||||||
|
<option value="">اختر المحافظة</option>
|
||||||
|
{SYRIA_PROVINCES.map((province) => (
|
||||||
|
<option key={province} value={province}>
|
||||||
|
{province}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.siteVisitRequired && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
مكان الزيارة *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.siteVisitLocation || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, siteVisitLocation: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
|
placeholder="اكتب مكان أو عنوان زيارة الموقع"
|
||||||
|
/>
|
||||||
|
{formErrors.siteVisitLocation && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{formErrors.siteVisitLocation}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('tenders.announcementLink')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
value={formData.announcementLink || ''}
|
value={formData.announcementLink || ''}
|
||||||
@@ -390,8 +605,11 @@ function TendersContent() {
|
|||||||
className="w-full px-3 py-2 border rounded-lg"
|
className="w-full px-3 py-2 border rounded-lg"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('common.notes')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t('common.notes')}
|
||||||
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={formData.notes || ''}
|
value={formData.notes || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||||
@@ -399,29 +617,39 @@ function TendersContent() {
|
|||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showDuplicateWarning && possibleDuplicates.length > 0 && (
|
{showDuplicateWarning && possibleDuplicates.length > 0 && (
|
||||||
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-start gap-2">
|
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-start gap-2">
|
||||||
<AlertCircle className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
<AlertCircle className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-amber-800">{t('tenders.duplicateWarning') || 'Possible duplicates found'}</p>
|
<p className="text-sm font-medium text-amber-800">
|
||||||
|
{t('tenders.duplicateWarning') || 'Possible duplicates found'}
|
||||||
|
</p>
|
||||||
<ul className="text-sm text-amber-700 mt-1 list-disc list-inside">
|
<ul className="text-sm text-amber-700 mt-1 list-disc list-inside">
|
||||||
{possibleDuplicates.slice(0, 3).map((d) => (
|
{possibleDuplicates.slice(0, 3).map((d) => (
|
||||||
<li key={d.id}>
|
<li key={d.id}>
|
||||||
<Link href={`/tenders/${d.id}`} className="underline">{d.tenderNumber} - {d.title}</Link>
|
<Link href={`/tenders/${d.id}`} className="underline">
|
||||||
|
{d.tenderNumber} - {d.title}
|
||||||
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-4">
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setShowCreateModal(false); resetForm(); }}
|
onClick={() => {
|
||||||
|
setShowCreateModal(false)
|
||||||
|
resetForm()
|
||||||
|
}}
|
||||||
className="px-4 py-2 border rounded-lg"
|
className="px-4 py-2 border rounded-lg"
|
||||||
>
|
>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
commercialRegister: contact?.commercialRegister,
|
commercialRegister: contact?.commercialRegister,
|
||||||
address: contact?.address,
|
address: contact?.address,
|
||||||
city: contact?.city,
|
city: contact?.city,
|
||||||
country: contact?.country || 'Saudi Arabia',
|
country: contact?.country || 'Syria',
|
||||||
postalCode: contact?.postalCode,
|
postalCode: contact?.postalCode,
|
||||||
source: contact?.source || 'WEBSITE',
|
source: contact?.source || 'WEBSITE',
|
||||||
categories: contact?.categories?.map((c: any) => c.id || c) || [],
|
categories: contact?.categories?.map((c: any) => c.id || c) || [],
|
||||||
@@ -176,6 +176,7 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
|
|||||||
<option value="HOLDING">Holding - مجموعة</option>
|
<option value="HOLDING">Holding - مجموعة</option>
|
||||||
<option value="GOVERNMENT">Government - حكومي</option>
|
<option value="GOVERNMENT">Government - حكومي</option>
|
||||||
<option value="ORGANIZATION">Organizations - منظمات</option>
|
<option value="ORGANIZATION">Organizations - منظمات</option>
|
||||||
|
<option value="EMBASSIES">Embassies - سفارات</option>
|
||||||
<option value="BANK">Banks - بنوك</option>
|
<option value="BANK">Banks - بنوك</option>
|
||||||
<option value="UNIVERSITY">Universities - جامعات</option>
|
<option value="UNIVERSITY">Universities - جامعات</option>
|
||||||
<option value="SCHOOL">Schools - مدارس</option>
|
<option value="SCHOOL">Schools - مدارس</option>
|
||||||
|
|||||||
@@ -5,8 +5,18 @@ export interface Tender {
|
|||||||
tenderNumber: string
|
tenderNumber: string
|
||||||
issuingBodyName: string
|
issuingBodyName: string
|
||||||
title: string
|
title: string
|
||||||
|
|
||||||
termsValue: number
|
termsValue: number
|
||||||
bondValue: number
|
bondValue: number
|
||||||
|
|
||||||
|
// extra fields stored inside notes metadata for now
|
||||||
|
initialBondValue?: number | null
|
||||||
|
finalBondValue?: number | null
|
||||||
|
finalBondRefundPeriod?: string | null
|
||||||
|
siteVisitRequired?: boolean
|
||||||
|
siteVisitLocation?: string | null
|
||||||
|
termsPickupProvince?: string | null
|
||||||
|
|
||||||
announcementDate: string
|
announcementDate: string
|
||||||
closingDate: string
|
closingDate: string
|
||||||
announcementLink?: string
|
announcementLink?: string
|
||||||
@@ -26,6 +36,7 @@ export interface Tender {
|
|||||||
_count?: { directives: number }
|
_count?: { directives: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface TenderDirective {
|
export interface TenderDirective {
|
||||||
id: string
|
id: string
|
||||||
tenderId: string
|
tenderId: string
|
||||||
@@ -48,8 +59,18 @@ export interface CreateTenderData {
|
|||||||
tenderNumber: string
|
tenderNumber: string
|
||||||
issuingBodyName: string
|
issuingBodyName: string
|
||||||
title: string
|
title: string
|
||||||
|
|
||||||
termsValue: number
|
termsValue: number
|
||||||
bondValue: number
|
bondValue: number
|
||||||
|
|
||||||
|
// extra UI/backend fields without DB migration
|
||||||
|
initialBondValue?: number
|
||||||
|
finalBondValue?: number
|
||||||
|
finalBondRefundPeriod?: string
|
||||||
|
siteVisitRequired?: boolean
|
||||||
|
siteVisitLocation?: string
|
||||||
|
termsPickupProvince?: string
|
||||||
|
|
||||||
announcementDate: string
|
announcementDate: string
|
||||||
closingDate: string
|
closingDate: string
|
||||||
announcementLink?: string
|
announcementLink?: string
|
||||||
@@ -166,6 +187,10 @@ export const tendersAPI = {
|
|||||||
return response.data.data
|
return response.data.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
deleteAttachment: async (attachmentId: string): Promise<void> => {
|
||||||
|
await api.delete(`/tenders/attachments/${attachmentId}`)
|
||||||
|
},
|
||||||
|
|
||||||
getSourceValues: async (): Promise<string[]> => {
|
getSourceValues: async (): Promise<string[]> => {
|
||||||
const response = await api.get('/tenders/source-values')
|
const response = await api.get('/tenders/source-values')
|
||||||
return response.data.data
|
return response.data.data
|
||||||
|
|||||||
Reference in New Issue
Block a user