diff --git a/backend/src/modules/tenders/tenders.controller.ts b/backend/src/modules/tenders/tenders.controller.ts
index 5d51733..60993b5 100644
--- a/backend/src/modules/tenders/tenders.controller.ts
+++ b/backend/src/modules/tenders/tenders.controller.ts
@@ -27,6 +27,20 @@ export class TendersController {
}
}
+ async delete(req: AuthRequest, res: Response, next: NextFunction) {
+ try {
+ await tendersService.delete(req.params.id, req.user!.id)
+ res.json(
+ ResponseFormatter.success(
+ true,
+ 'تم حذف المناقصة بنجاح - Tender deleted successfully'
+ )
+ )
+ } catch (error) {
+ next(error)
+ }
+ }
+
async findAll(req: AuthRequest, res: Response, next: NextFunction) {
try {
const page = parseInt(req.query.page as string) || 1;
diff --git a/backend/src/modules/tenders/tenders.routes.ts b/backend/src/modules/tenders/tenders.routes.ts
index daba50f..816258a 100644
--- a/backend/src/modules/tenders/tenders.routes.ts
+++ b/backend/src/modules/tenders/tenders.routes.ts
@@ -112,6 +112,14 @@ router.put(
tendersController.update
);
+router.delete(
+ '/:id',
+ authorize('tenders', 'tenders', 'delete'),
+ param('id').isUUID(),
+ validate,
+ tendersController.delete
+);
+
// Tender history
router.get(
'/:id/history',
diff --git a/backend/src/modules/tenders/tenders.service.ts b/backend/src/modules/tenders/tenders.service.ts
index f350b3f..cfe3798 100644
--- a/backend/src/modules/tenders/tenders.service.ts
+++ b/backend/src/modules/tenders/tenders.service.ts
@@ -125,6 +125,62 @@ class TendersService {
}
}
+ async delete(id: string, userId: string) {
+ const tender = await prisma.tender.findUnique({
+ where: { id },
+ include: {
+ attachments: true,
+ directives: {
+ include: {
+ attachments: true,
+ },
+ },
+ convertedDeal: {
+ select: { id: true },
+ },
+ },
+ });
+
+ if (!tender) {
+ throw new AppError(404, 'Tender not found');
+ }
+
+ if (tender.convertedDeal) {
+ throw new AppError(400, 'Cannot delete tender that has been converted to deal');
+ }
+
+ for (const attachment of tender.attachments || []) {
+ if (attachment.path && fs.existsSync(attachment.path)) {
+ fs.unlinkSync(attachment.path);
+ }
+ }
+
+ for (const directive of tender.directives || []) {
+ for (const attachment of directive.attachments || []) {
+ if (attachment.path && fs.existsSync(attachment.path)) {
+ fs.unlinkSync(attachment.path);
+ }
+ }
+ }
+
+ await prisma.tender.delete({
+ where: { id },
+ });
+
+ await AuditLogger.log({
+ entityType: 'TENDER',
+ entityId: id,
+ action: 'DELETE',
+ userId,
+ changes: {
+ deletedTenderNumber: tender.tenderNumber,
+ deletedTitle: tender.title,
+ },
+ });
+
+ return true;
+ }
+
private buildTenderNotes(
plainNotes?: string | null,
extra?: {
diff --git a/frontend/src/app/contacts/page.tsx b/frontend/src/app/contacts/page.tsx
index 791d126..2867a25 100644
--- a/frontend/src/app/contacts/page.tsx
+++ b/frontend/src/app/contacts/page.tsx
@@ -582,13 +582,19 @@ function ContactsContent() {
- {getListCompanyName(contact) !== '-' && (
-
-
-
- {getListCompanyName(contact)}
-
+ {getListCompanyName(contact) !== '-' ? (
+
+
+ {getListCompanyName(contact).charAt(0).toUpperCase()}
+
+
+
+ {getListCompanyName(contact)}
+
+
+ ) : (
+ -
)}
|
@@ -609,13 +615,9 @@ function ContactsContent() {
-
-
-
- {getListContactName(contact).charAt(0)}
-
+
-
+
{getListContactName(contact)}
{getListContactNameAr(contact) && (
@@ -624,8 +626,7 @@ function ContactsContent() {
)}
-
- |
+ |
diff --git a/frontend/src/app/tenders/page.tsx b/frontend/src/app/tenders/page.tsx
index a5c1c06..1a268ac 100644
--- a/frontend/src/app/tenders/page.tsx
+++ b/frontend/src/app/tenders/page.tsx
@@ -14,7 +14,10 @@ import {
ArrowLeft,
Eye,
Loader2,
+ Edit,
+ Trash2,
} from 'lucide-react'
+import { useAuth } from '@/contexts/AuthContext'
import { tendersAPI, Tender, CreateTenderData, TenderFilters } from '@/lib/api/tenders'
import { useLanguage } from '@/contexts/LanguageContext'
@@ -59,14 +62,12 @@ const getInitialFormData = (): CreateTenderData => ({
title: '',
termsValue: 0,
bondValue: 0,
-
initialBondValue: 0,
finalBondValue: 0,
finalBondRefundPeriod: '',
siteVisitRequired: false,
siteVisitLocation: '',
termsPickupProvince: '',
-
announcementDate: '',
closingDate: '',
source: 'MANUAL',
@@ -76,6 +77,8 @@ const getInitialFormData = (): CreateTenderData => ({
function TendersContent() {
const { t } = useLanguage()
+ const { hasPermission } = useAuth()
+
const [tenders, setTenders] = useState([])
const [loading, setLoading] = useState(true)
const [currentPage, setCurrentPage] = useState(1)
@@ -93,6 +96,14 @@ function TendersContent() {
const [showDuplicateWarning, setShowDuplicateWarning] = useState(false)
const [sourceValues, setSourceValues] = useState([])
const [announcementTypeValues, setAnnouncementTypeValues] = useState([])
+ const [showEditModal, setShowEditModal] = useState(false)
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false)
+ const [selectedTender, setSelectedTender] = useState(null)
+
+ const canCreateTender = hasPermission('tenders', 'create')
+ const canViewTender = hasPermission('tenders', 'view')
+ const canEditTender = hasPermission('tenders', 'edit')
+ const canDeleteTender = hasPermission('tenders', 'delete')
const resetForm = () => {
setFormData(getInitialFormData())
@@ -101,6 +112,42 @@ function TendersContent() {
setShowDuplicateWarning(false)
}
+ const fillFormFromTender = (tender: Tender): CreateTenderData => ({
+ tenderNumber: tender.tenderNumber || '',
+ issuingBodyName: tender.issuingBodyName || '',
+ title: tender.title || '',
+ termsValue: Number(tender.termsValue || 0),
+ bondValue: Number(tender.bondValue || 0),
+ initialBondValue: Number(tender.initialBondValue ?? tender.bondValue ?? 0),
+ finalBondValue: tender.finalBondValue != null ? Number(tender.finalBondValue) : 0,
+ finalBondRefundPeriod: tender.finalBondRefundPeriod || '',
+ siteVisitRequired: !!tender.siteVisitRequired,
+ siteVisitLocation: tender.siteVisitLocation || '',
+ termsPickupProvince: tender.termsPickupProvince || '',
+ announcementDate: tender.announcementDate?.split('T')[0] || '',
+ closingDate: tender.closingDate?.split('T')[0] || '',
+ announcementLink: tender.announcementLink || '',
+ source: tender.source || 'MANUAL',
+ sourceOther: tender.sourceOther || '',
+ announcementType: tender.announcementType || 'FIRST',
+ notes: tender.notes || '',
+ contactId: tender.contactId || '',
+ })
+
+ const openEditModal = (tender: Tender) => {
+ setSelectedTender(tender)
+ setFormData(fillFormFromTender(tender))
+ setFormErrors({})
+ setPossibleDuplicates([])
+ setShowDuplicateWarning(false)
+ setShowEditModal(true)
+ }
+
+ const openDeleteDialog = (tender: Tender) => {
+ setSelectedTender(tender)
+ setShowDeleteDialog(true)
+ }
+
const fetchTenders = useCallback(async () => {
setLoading(true)
try {
@@ -176,6 +223,386 @@ function TendersContent() {
}
}
+ const handleEdit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!selectedTender) return
+
+ 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')
+ }
+
+ if (formData.siteVisitRequired && !formData.siteVisitLocation?.trim()) {
+ errors.siteVisitLocation = t('common.required')
+ }
+
+ setFormErrors(errors)
+ if (Object.keys(errors).length > 0) return
+
+ setSubmitting(true)
+ try {
+ await tendersAPI.update(selectedTender.id, {
+ ...formData,
+ bondValue: Number(formData.initialBondValue ?? formData.bondValue ?? 0),
+ })
+
+ toast.success(t('tenders.updateSuccess') || 'Tender updated successfully')
+ setShowEditModal(false)
+ setSelectedTender(null)
+ resetForm()
+ fetchTenders()
+ } catch (err: any) {
+ toast.error(err.response?.data?.message || 'Failed to update tender')
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ const handleDelete = async () => {
+ if (!selectedTender) return
+
+ setSubmitting(true)
+ try {
+ await tendersAPI.delete(selectedTender.id)
+ toast.success(t('tenders.deleteSuccess') || 'Tender deleted successfully')
+ setShowDeleteDialog(false)
+ setSelectedTender(null)
+ fetchTenders()
+ } catch (err: any) {
+ toast.error(err.response?.data?.message || 'Failed to delete tender')
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ const renderTenderForm = (mode: 'create' | 'edit') => (
+
+ )
+
return (
@@ -201,16 +628,18 @@ function TendersContent() {
-
+ {canCreateTender && (
+
+ )}
@@ -251,22 +680,22 @@ function TendersContent() {
- |
+ |
{t('tenders.tenderNumber') || 'Number'}
|
-
+ |
{t('tenders.title') || 'Title'}
|
-
+ |
{t('tenders.issuingBody') || 'Issuing body'}
|
-
+ |
{t('tenders.closingDate') || 'Closing date'}
|
-
+ |
{t('common.status')}
|
-
+ |
{t('common.actions')}
|
@@ -275,21 +704,21 @@ function TendersContent() {
{tenders.map((tender) => (
- |
+ |
{tender.tenderNumber}
|
-
+ |
{tender.title}
|
-
+ |
{tender.issuingBodyName}
|
-
+ |
{tender.closingDate?.split('T')[0]}
|
-
+ |
|
-
-
-
- {t('common.view') || 'View'}
-
+ |
+
+ {canViewTender && (
+
+
+
+ )}
+
+ {canEditTender && (
+
+ )}
+
+ {canDeleteTender && (
+
+
+ )}
+
|
))}
@@ -351,316 +807,79 @@ function TendersContent() {
}}
title={t('tenders.addTender') || 'Add Tender'}
>
- |