updates for contacts & tenders Modules

This commit is contained in:
yotakii
2026-04-01 15:50:21 +03:00
parent 278d8f6982
commit f101989047
12 changed files with 850 additions and 143 deletions

View File

@@ -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(),

View File

@@ -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',

View File

@@ -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();

View File

@@ -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
)

View File

@@ -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) {
@@ -216,8 +331,8 @@ class TendersService {
attachments: true, attachments: true,
}, },
}); });
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,8 +435,8 @@ 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) {
const tender = await prisma.tender.findUnique({ const tender = await prisma.tender.findUnique({
@@ -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}-`;

View File

@@ -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',

View File

@@ -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>

View File

@@ -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',

View File

@@ -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
{a.originalName || a.fileName} 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>
<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>

View File

@@ -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>
<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> <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">
{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}

View File

@@ -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>

View File

@@ -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