update tender form

This commit is contained in:
Aya
2026-07-23 10:36:08 +03:00
parent 96386887fb
commit 12578223fd
5 changed files with 85 additions and 54 deletions

View File

@@ -0,0 +1,9 @@
-- Convert leave start/end columns from DATE to TIMESTAMP(3)
-- so that HOURLY leaves can store the actual start/end time of day.
-- Without this, Postgres truncates the time and every hourly leave
-- renders at the company timezone offset (e.g. 03:00) instead of the
-- chosen time.
ALTER TABLE "leaves"
ALTER COLUMN "startDate" TYPE TIMESTAMP(3) USING "startDate"::timestamp(3),
ALTER COLUMN "endDate" TYPE TIMESTAMP(3) USING "endDate"::timestamp(3);

View File

@@ -81,11 +81,11 @@ router.post(
[ [
body('issuingBodyName').optional().trim(), body('issuingBodyName').optional().trim(),
body('title').optional().trim(), body('title').optional().trim(),
body('tenderNumber').optional().trim(), body('tenderNumber').optional({ checkFalsy: true }).trim(),
body('termsValue').optional().isNumeric(), body('termsValue').optional({ checkFalsy: false }).isNumeric(),
body('bondValue').optional().isNumeric(), body('bondValue').optional({ checkFalsy: false }).isNumeric(),
body('announcementDate').optional().isISO8601(), body('announcementDate').optional({ checkFalsy: true }).isISO8601(),
body('closingDate').optional().isISO8601(), body('closingDate').optional({ checkFalsy: true }).isISO8601(),
], ],
validate, validate,
tendersController.checkDuplicates tendersController.checkDuplicates
@@ -101,14 +101,14 @@ router.post(
'/', '/',
authorize('tenders', 'tenders', 'create'), authorize('tenders', 'tenders', 'create'),
[ [
body('tenderNumber').notEmpty().trim(), body('tenderNumber').optional({ checkFalsy: true }).trim(),
body('issueNumber').optional().trim(), body('issueNumber').optional().trim(),
body('issuingBodyName').notEmpty().trim(), body('issuingBodyName').notEmpty().trim(),
body('title').notEmpty().trim(), body('title').notEmpty().trim(),
body('termsValue').isNumeric(), body('termsValue').optional({ checkFalsy: false }).isNumeric(),
body('bondValue').isNumeric(), body('bondValue').optional({ checkFalsy: false }).isNumeric(),
body('announcementDate').isISO8601(), body('announcementDate').optional({ checkFalsy: true }).isISO8601(),
body('closingDate').isISO8601(), body('closingDate').optional({ checkFalsy: true }).isISO8601(),
body('source').notEmpty(), body('source').notEmpty(),
body('announcementType').notEmpty(), body('announcementType').notEmpty(),
], ],

View File

@@ -35,7 +35,7 @@ const DIRECTIVE_TYPE_VALUES = [
export interface CreateTenderData { export interface CreateTenderData {
issuingBodyName: string; issuingBodyName: string;
title: string; title: string;
tenderNumber: string; tenderNumber?: string;
issueNumber?: string; issueNumber?: string;
termsValue: number; termsValue: number;
@@ -49,8 +49,8 @@ export interface CreateTenderData {
siteVisitLocation?: string; siteVisitLocation?: string;
termsPickupProvince?: string; termsPickupProvince?: string;
announcementDate: string; announcementDate?: string;
closingDate: string; closingDate?: string;
announcementLink?: string; announcementLink?: string;
source: string; source: string;
@@ -327,16 +327,33 @@ private getEffectiveTenderStatus(tender: {
async create(data: CreateTenderData, userId: string): Promise<TenderWithDuplicates> { async create(data: CreateTenderData, userId: string): Promise<TenderWithDuplicates> {
const possibleDuplicates = await this.findPossibleDuplicates(data); const possibleDuplicates = await this.findPossibleDuplicates(data);
const existing = await prisma.tender.findUnique({ // رقم المناقصة اختياري في الواجهة، لكن العمود في قاعدة البيانات مطلوب وفريد.
where: { tenderNumber: data.tenderNumber.trim() }, // إذا تُرك فاضياً نولّد رقماً تلقائياً حتى لا يفشل الحفظ ولا نحتاج migration.
}); let tenderNumber = data.tenderNumber?.trim() || '';
if (existing) { if (tenderNumber) {
throw new AppError(400, 'Tender number already exists - رقم المناقصة موجود مسبقاً'); const existing = await prisma.tender.findUnique({
where: { tenderNumber },
});
if (existing) {
throw new AppError(400, 'Tender number already exists - رقم المناقصة موجود مسبقاً');
}
} else {
// توليد رقم تلقائي فريد مع تفادي التعارض النادر
tenderNumber = await this.generateTenderNumber();
let guard = 0;
while (await prisma.tender.findUnique({ where: { tenderNumber } })) {
tenderNumber = `${await this.generateTenderNumber()}-${++guard}`;
if (guard > 5) {
tenderNumber = `TND-${Date.now()}`;
break;
}
}
} }
const tenderNumber = data.tenderNumber.trim(); // تاريخ الإعلان/الإغلاق اختياريان في الواجهة، والعمودان مطلوبان في قاعدة البيانات.
const announcementDate = new Date(data.announcementDate); // إذا تُركا فاضيين نستخدم تاريخ اليوم افتراضياً.
const closingDate = new Date(data.closingDate); const announcementDate = data.announcementDate ? new Date(data.announcementDate) : new Date();
const closingDate = data.closingDate ? new Date(data.closingDate) : new Date();
if (data.siteVisitRequired && !data.siteVisitLocation?.trim()) { if (data.siteVisitRequired && !data.siteVisitLocation?.trim()) {
throw new AppError(400, 'مكان زيارة الموقع مطلوب عند اختيار زيارة موقع إجبارية'); throw new AppError(400, 'مكان زيارة الموقع مطلوب عند اختيار زيارة موقع إجبارية');
@@ -357,7 +374,7 @@ private getEffectiveTenderStatus(tender: {
issueNumber: data.issueNumber?.trim() || null, issueNumber: data.issueNumber?.trim() || null,
issuingBodyName: data.issuingBodyName.trim(), issuingBodyName: data.issuingBodyName.trim(),
title: data.title.trim(), title: data.title.trim(),
termsValue: data.termsValue, termsValue: Number(data.termsValue ?? 0),
bondValue: Number(data.initialBondValue ?? data.bondValue ?? 0), bondValue: Number(data.initialBondValue ?? data.bondValue ?? 0),
announcementDate, announcementDate,
closingDate, closingDate,
@@ -507,8 +524,16 @@ private getEffectiveTenderStatus(tender: {
if (data.bondValue !== undefined || data.initialBondValue !== undefined) { if (data.bondValue !== undefined || data.initialBondValue !== undefined) {
updateData.bondValue = Number(data.initialBondValue ?? data.bondValue ?? existing.bondValue); updateData.bondValue = Number(data.initialBondValue ?? data.bondValue ?? existing.bondValue);
} }
if (data.announcementDate !== undefined) updateData.announcementDate = new Date(data.announcementDate); // العمود مطلوب في قاعدة البيانات: نتجاهل القيمة الفاضية ونبقي على القيمة الحالية بدل كتابة null.
if (data.closingDate !== undefined) updateData.closingDate = new Date(data.closingDate); if (data.tenderNumber !== undefined && data.tenderNumber?.trim()) {
updateData.tenderNumber = data.tenderNumber.trim();
}
if (data.announcementDate !== undefined && data.announcementDate) {
updateData.announcementDate = new Date(data.announcementDate);
}
if (data.closingDate !== undefined && data.closingDate) {
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;

View File

@@ -182,11 +182,8 @@ function TendersContent() {
const errors: Record<string, string> = {} const errors: Record<string, string> = {}
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.closingDate) errors.closingDate = t('common.required')
if (Number(formData.initialBondValue || 0) < 0) { if (Number(formData.initialBondValue || 0) < 0) {
errors.initialBondValue = t('common.required') errors.initialBondValue = t('common.required')
@@ -231,11 +228,8 @@ function TendersContent() {
const errors: Record<string, string> = {} const errors: Record<string, string> = {}
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.closingDate) errors.closingDate = t('common.required')
if (Number(formData.initialBondValue || 0) < 0) { if (Number(formData.initialBondValue || 0) < 0) {
errors.initialBondValue = t('common.required') errors.initialBondValue = t('common.required')
@@ -289,11 +283,11 @@ function TendersContent() {
<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"> <label className="block text-sm font-medium text-gray-700 mb-1">
{t('tenders.tenderNumber')} * {t('tenders.tenderNumber')}
</label> </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"
/> />
@@ -354,9 +348,12 @@ function TendersContent() {
<input <input
type="number" type="number"
min={0} min={0}
value={formData.termsValue || ''} value={formData.termsValue ?? ''}
onChange={(e) => onChange={(e) =>
setFormData({ ...formData, termsValue: Number(e.target.value) || 0 }) setFormData({
...formData,
termsValue: e.target.value === '' ? 0 : Number(e.target.value),
})
} }
className="w-full px-3 py-2 border rounded-lg" className="w-full px-3 py-2 border rounded-lg"
/> />
@@ -369,14 +366,11 @@ function TendersContent() {
<input <input
type="number" type="number"
min={0} min={0}
value={formData.initialBondValue || ''} value={formData.initialBondValue ?? ''}
onChange={(e) => onChange={(e) => {
setFormData({ const v = e.target.value === '' ? 0 : Number(e.target.value)
...formData, setFormData({ ...formData, initialBondValue: v, bondValue: v })
initialBondValue: Number(e.target.value) || 0, }}
bondValue: Number(e.target.value) || 0,
})
}
className="w-full px-3 py-2 border rounded-lg" className="w-full px-3 py-2 border rounded-lg"
/> />
{formErrors.initialBondValue && ( {formErrors.initialBondValue && (
@@ -391,9 +385,12 @@ function TendersContent() {
<input <input
type="number" type="number"
min={0} min={0}
value={formData.finalBondValue || ''} value={formData.finalBondValue ?? ''}
onChange={(e) => onChange={(e) =>
setFormData({ ...formData, finalBondValue: Number(e.target.value) || 0 }) setFormData({
...formData,
finalBondValue: e.target.value === '' ? 0 : Number(e.target.value),
})
} }
className="w-full px-3 py-2 border rounded-lg" className="w-full px-3 py-2 border rounded-lg"
/> />
@@ -418,11 +415,11 @@ function TendersContent() {
<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"> <label className="block text-sm font-medium text-gray-700 mb-1">
{t('tenders.announcementDate')} * {t('tenders.announcementDate')}
</label> </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"
/> />
@@ -433,11 +430,11 @@ function TendersContent() {
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
{t('tenders.closingDate')} * {t('tenders.closingDate')}
</label> </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"
/> />

View File

@@ -2,7 +2,7 @@ import { api } from '../api'
export interface Tender { export interface Tender {
id: string id: string
tenderNumber: string tenderNumber?: string | null
issueNumber?: string | null issueNumber?: string | null
issuingBodyName: string issuingBodyName: string
title: string title: string
@@ -18,8 +18,8 @@ export interface Tender {
siteVisitLocation?: string | null siteVisitLocation?: string | null
termsPickupProvince?: string | null termsPickupProvince?: string | null
announcementDate: string announcementDate?: string | null
closingDate: string closingDate?: string | null
announcementLink?: string announcementLink?: string
source: string source: string
sourceOther?: string sourceOther?: string
@@ -57,7 +57,7 @@ export interface TenderDirective {
} }
export interface CreateTenderData { export interface CreateTenderData {
tenderNumber: string tenderNumber?: string | null
issueNumber?: string issueNumber?: string
issuingBodyName: string issuingBodyName: string
title: string title: string
@@ -73,8 +73,8 @@ export interface CreateTenderData {
siteVisitLocation?: string siteVisitLocation?: string
termsPickupProvince?: string termsPickupProvince?: string
announcementDate: string announcementDate?: string | null
closingDate: string closingDate?: string | null
announcementLink?: string announcementLink?: string
source: string source: string
sourceOther?: string sourceOther?: string