From 18699e69267a4a718d841cffe292ea1d073ac687 Mon Sep 17 00:00:00 2001 From: Aya Date: Tue, 14 Apr 2026 14:47:10 +0300 Subject: [PATCH] add edit & delete button to tender & update contacts dashbaord --- .../src/modules/tenders/tenders.controller.ts | 14 + backend/src/modules/tenders/tenders.routes.ts | 8 + .../src/modules/tenders/tenders.service.ts | 56 ++ frontend/src/app/contacts/page.tsx | 29 +- frontend/src/app/tenders/page.tsx | 889 +++++++++++------- frontend/src/lib/api/tenders.ts | 4 + 6 files changed, 651 insertions(+), 349 deletions(-) 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') => ( +
+
+
+ + setFormData({ ...formData, tenderNumber: e.target.value })} + className="w-full px-3 py-2 border rounded-lg" + /> + {formErrors.tenderNumber && ( +

{formErrors.tenderNumber}

+ )} +
+ +
+ + setFormData({ ...formData, issuingBodyName: e.target.value })} + className="w-full px-3 py-2 border rounded-lg" + /> + {formErrors.issuingBodyName && ( +

{formErrors.issuingBodyName}

+ )} +
+
+ +
+ + setFormData({ ...formData, title: e.target.value })} + className="w-full px-3 py-2 border rounded-lg" + /> + {formErrors.title && ( +

{formErrors.title}

+ )} +
+ +
+
+ + + setFormData({ ...formData, termsValue: Number(e.target.value) || 0 }) + } + className="w-full px-3 py-2 border rounded-lg" + /> +
+ +
+ + + 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 && ( +

{formErrors.initialBondValue}

+ )} +
+ +
+ + + setFormData({ ...formData, finalBondValue: Number(e.target.value) || 0 }) + } + className="w-full px-3 py-2 border rounded-lg" + /> +
+ +
+ + + setFormData({ ...formData, finalBondRefundPeriod: e.target.value }) + } + placeholder="مثال: بعد 90 يوم من التسليم النهائي" + className="w-full px-3 py-2 border rounded-lg" + /> +
+
+ +
+
+ + setFormData({ ...formData, announcementDate: e.target.value })} + className="w-full px-3 py-2 border rounded-lg" + /> + {formErrors.announcementDate && ( +

{formErrors.announcementDate}

+ )} +
+ +
+ + setFormData({ ...formData, closingDate: e.target.value })} + className="w-full px-3 py-2 border rounded-lg" + /> + {formErrors.closingDate && ( +

{formErrors.closingDate}

+ )} +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ + {formData.siteVisitRequired && ( +
+ + setFormData({ ...formData, siteVisitLocation: e.target.value })} + className="w-full px-3 py-2 border rounded-lg" + placeholder="اكتب مكان أو عنوان زيارة الموقع" + /> + {formErrors.siteVisitLocation && ( +

{formErrors.siteVisitLocation}

+ )} +
+ )} + +
+ + setFormData({ ...formData, announcementLink: e.target.value })} + className="w-full px-3 py-2 border rounded-lg" + /> +
+ +
+ +