diff --git a/backend/prisma/migrations/20260603120000_leave_dates_to_timestamp/migration.sql b/backend/prisma/migrations/20260603120000_leave_dates_to_timestamp/migration.sql new file mode 100644 index 0000000..79fd2c3 --- /dev/null +++ b/backend/prisma/migrations/20260603120000_leave_dates_to_timestamp/migration.sql @@ -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); diff --git a/backend/src/modules/tenders/tenders.routes.ts b/backend/src/modules/tenders/tenders.routes.ts index 6828f22..da2ed55 100644 --- a/backend/src/modules/tenders/tenders.routes.ts +++ b/backend/src/modules/tenders/tenders.routes.ts @@ -81,11 +81,11 @@ router.post( [ body('issuingBodyName').optional().trim(), body('title').optional().trim(), - body('tenderNumber').optional().trim(), - body('termsValue').optional().isNumeric(), - body('bondValue').optional().isNumeric(), - body('announcementDate').optional().isISO8601(), - body('closingDate').optional().isISO8601(), + body('tenderNumber').optional({ checkFalsy: true }).trim(), + body('termsValue').optional({ checkFalsy: false }).isNumeric(), + body('bondValue').optional({ checkFalsy: false }).isNumeric(), + body('announcementDate').optional({ checkFalsy: true }).isISO8601(), + body('closingDate').optional({ checkFalsy: true }).isISO8601(), ], validate, tendersController.checkDuplicates @@ -101,14 +101,14 @@ router.post( '/', authorize('tenders', 'tenders', 'create'), [ - body('tenderNumber').notEmpty().trim(), + body('tenderNumber').optional({ checkFalsy: true }).trim(), body('issueNumber').optional().trim(), body('issuingBodyName').notEmpty().trim(), body('title').notEmpty().trim(), - body('termsValue').isNumeric(), - body('bondValue').isNumeric(), - body('announcementDate').isISO8601(), - body('closingDate').isISO8601(), + body('termsValue').optional({ checkFalsy: false }).isNumeric(), + body('bondValue').optional({ checkFalsy: false }).isNumeric(), + body('announcementDate').optional({ checkFalsy: true }).isISO8601(), + body('closingDate').optional({ checkFalsy: true }).isISO8601(), body('source').notEmpty(), body('announcementType').notEmpty(), ], diff --git a/backend/src/modules/tenders/tenders.service.ts b/backend/src/modules/tenders/tenders.service.ts index 95de1f7..9071e95 100644 --- a/backend/src/modules/tenders/tenders.service.ts +++ b/backend/src/modules/tenders/tenders.service.ts @@ -35,7 +35,7 @@ const DIRECTIVE_TYPE_VALUES = [ export interface CreateTenderData { issuingBodyName: string; title: string; - tenderNumber: string; + tenderNumber?: string; issueNumber?: string; termsValue: number; @@ -49,8 +49,8 @@ export interface CreateTenderData { siteVisitLocation?: string; termsPickupProvince?: string; - announcementDate: string; - closingDate: string; + announcementDate?: string; + closingDate?: string; announcementLink?: string; source: string; @@ -327,16 +327,33 @@ private getEffectiveTenderStatus(tender: { async create(data: CreateTenderData, userId: string): Promise { const possibleDuplicates = await this.findPossibleDuplicates(data); - const existing = await prisma.tender.findUnique({ - where: { tenderNumber: data.tenderNumber.trim() }, - }); - if (existing) { - throw new AppError(400, 'Tender number already exists - رقم المناقصة موجود مسبقاً'); + // رقم المناقصة اختياري في الواجهة، لكن العمود في قاعدة البيانات مطلوب وفريد. + // إذا تُرك فاضياً نولّد رقماً تلقائياً حتى لا يفشل الحفظ ولا نحتاج migration. + let tenderNumber = data.tenderNumber?.trim() || ''; + if (tenderNumber) { + 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()) { throw new AppError(400, 'مكان زيارة الموقع مطلوب عند اختيار زيارة موقع إجبارية'); @@ -357,7 +374,7 @@ private getEffectiveTenderStatus(tender: { issueNumber: data.issueNumber?.trim() || null, issuingBodyName: data.issuingBodyName.trim(), title: data.title.trim(), - termsValue: data.termsValue, + termsValue: Number(data.termsValue ?? 0), bondValue: Number(data.initialBondValue ?? data.bondValue ?? 0), announcementDate, closingDate, @@ -507,8 +524,16 @@ private getEffectiveTenderStatus(tender: { 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.closingDate !== undefined) updateData.closingDate = new Date(data.closingDate); + // العمود مطلوب في قاعدة البيانات: نتجاهل القيمة الفاضية ونبقي على القيمة الحالية بدل كتابة null. + 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.source !== undefined) updateData.source = data.source; if (data.sourceOther !== undefined) updateData.sourceOther = data.sourceOther?.trim() || null; diff --git a/frontend/src/app/tenders/page.tsx b/frontend/src/app/tenders/page.tsx index a6abada..9df2e3f 100644 --- a/frontend/src/app/tenders/page.tsx +++ b/frontend/src/app/tenders/page.tsx @@ -182,11 +182,8 @@ function TendersContent() { const errors: Record = {} - if (!formData.tenderNumber?.trim()) errors.tenderNumber = t('common.required') if (!formData.issuingBodyName?.trim()) errors.issuingBodyName = 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) { errors.initialBondValue = t('common.required') @@ -231,11 +228,8 @@ function TendersContent() { const errors: Record = {} - if (!formData.tenderNumber?.trim()) errors.tenderNumber = t('common.required') if (!formData.issuingBodyName?.trim()) errors.issuingBodyName = 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) { errors.initialBondValue = t('common.required') @@ -289,11 +283,11 @@ function TendersContent() {
setFormData({ ...formData, tenderNumber: e.target.value })} className="w-full px-3 py-2 border rounded-lg" /> @@ -354,9 +348,12 @@ function TendersContent() { - 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" /> @@ -369,14 +366,11 @@ function TendersContent() { - setFormData({ - ...formData, - initialBondValue: Number(e.target.value) || 0, - bondValue: Number(e.target.value) || 0, - }) - } + value={formData.initialBondValue ?? ''} + onChange={(e) => { + const v = e.target.value === '' ? 0 : Number(e.target.value) + setFormData({ ...formData, initialBondValue: v, bondValue: v }) + }} className="w-full px-3 py-2 border rounded-lg" /> {formErrors.initialBondValue && ( @@ -391,9 +385,12 @@ function TendersContent() { - 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" /> @@ -418,11 +415,11 @@ function TendersContent() {
setFormData({ ...formData, announcementDate: e.target.value })} className="w-full px-3 py-2 border rounded-lg" /> @@ -433,11 +430,11 @@ function TendersContent() {
setFormData({ ...formData, closingDate: e.target.value })} className="w-full px-3 py-2 border rounded-lg" /> diff --git a/frontend/src/lib/api/tenders.ts b/frontend/src/lib/api/tenders.ts index bac7b7b..2f72d27 100644 --- a/frontend/src/lib/api/tenders.ts +++ b/frontend/src/lib/api/tenders.ts @@ -2,7 +2,7 @@ import { api } from '../api' export interface Tender { id: string - tenderNumber: string + tenderNumber?: string | null issueNumber?: string | null issuingBodyName: string title: string @@ -18,8 +18,8 @@ export interface Tender { siteVisitLocation?: string | null termsPickupProvince?: string | null - announcementDate: string - closingDate: string + announcementDate?: string | null + closingDate?: string | null announcementLink?: string source: string sourceOther?: string @@ -57,7 +57,7 @@ export interface TenderDirective { } export interface CreateTenderData { - tenderNumber: string + tenderNumber?: string | null issueNumber?: string issuingBodyName: string title: string @@ -73,8 +73,8 @@ export interface CreateTenderData { siteVisitLocation?: string termsPickupProvince?: string - announcementDate: string - closingDate: string + announcementDate?: string | null + closingDate?: string | null announcementLink?: string source: string sourceOther?: string