From bda70feb188ea01b2eb7592db53b0315bdf47927 Mon Sep 17 00:00:00 2001 From: Aya Date: Sun, 12 Apr 2026 16:52:44 +0300 Subject: [PATCH] edit contact form --- .../src/modules/contacts/contacts.routes.ts | 24 +- .../src/modules/contacts/contacts.service.ts | 82 +++--- frontend/src/app/contacts/page.tsx | 263 +++++++++--------- .../src/components/contacts/ContactForm.tsx | 188 ++++++------- 4 files changed, 292 insertions(+), 265 deletions(-) diff --git a/backend/src/modules/contacts/contacts.routes.ts b/backend/src/modules/contacts/contacts.routes.ts index 0d53491..f896bc5 100644 --- a/backend/src/modules/contacts/contacts.routes.ts +++ b/backend/src/modules/contacts/contacts.routes.ts @@ -58,7 +58,29 @@ router.put( authorize('contacts', 'contacts', 'update'), [ param('id').isUUID(), - body('email').optional().isEmail(), + body('type') + .optional() + .isIn([ + 'INDIVIDUAL', + 'COMPANY', + 'HOLDING', + 'GOVERNMENT', + 'ORGANIZATION', + 'EMBASSIES', + 'BANK', + 'UNIVERSITY', + 'SCHOOL', + 'UN', + 'NGO', + 'INSTITUTION', + ]), + body('email') + .optional({ values: 'falsy' }) + .custom((value) => { + if (value === null || value === undefined || value === '') return true + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) + }) + .withMessage('Invalid email format'), validate, ], contactsController.update diff --git a/backend/src/modules/contacts/contacts.service.ts b/backend/src/modules/contacts/contacts.service.ts index dee2ed9..3cd2e1a 100644 --- a/backend/src/modules/contacts/contacts.service.ts +++ b/backend/src/modules/contacts/contacts.service.ts @@ -328,46 +328,50 @@ class ContactsService { // Update contact const contact = await prisma.contact.update({ - where: { id }, - data: { - name: data.name, - nameAr: data.nameAr, - email: data.email, - phone: data.phone, - mobile: data.mobile, - website: data.website, - companyName: data.companyName, - companyNameAr: data.companyNameAr, - taxNumber: data.taxNumber, - commercialRegister: data.commercialRegister, - address: data.address, - city: data.city, - country: data.country, - postalCode: data.postalCode, - categories: data.categories ? { - set: data.categories.map(id => ({ id })) - } : undefined, - tags: data.tags, - employeeId: data.employeeId !== undefined ? (data.employeeId || null) : undefined, - source: data.source, - status: data.status, - rating: data.rating, - customFields: data.customFields, + where: { id }, + data: { + type: data.type, + name: data.name, + nameAr: data.nameAr, + email: data.email === '' || data.email === undefined ? null : data.email, + phone: data.phone, + mobile: data.mobile, + website: data.website, + companyName: data.companyName, + companyNameAr: data.companyNameAr, + taxNumber: data.taxNumber, + commercialRegister: data.commercialRegister, + address: data.address, + city: data.city, + country: data.country, + postalCode: data.postalCode, + categories: data.categories + ? { + set: data.categories.map((id) => ({ id })), + } + : undefined, + tags: data.tags, + employeeId: + data.employeeId !== undefined ? (data.employeeId || null) : undefined, + source: data.source, + status: data.status, + rating: data.rating, + customFields: data.customFields, + }, + include: { + categories: true, + parent: true, + employee: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + uniqueEmployeeId: true, }, - include: { - categories: true, - parent: true, - employee: { - select: { - id: true, - firstName: true, - lastName: true, - email: true, - uniqueEmployeeId: true, - }, - }, - }, - }); + }, + }, +}); // Log audit await AuditLogger.log({ diff --git a/frontend/src/app/contacts/page.tsx b/frontend/src/app/contacts/page.tsx index 708dc35..791d126 100644 --- a/frontend/src/app/contacts/page.tsx +++ b/frontend/src/app/contacts/page.tsx @@ -41,20 +41,17 @@ function flattenCategories(cats: Category[], result: Category[] = []): Category[ } function ContactsContent() { - // State Management const [contacts, setContacts] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [selectedContacts, setSelectedContacts] = useState>(new Set()) const [showBulkActions, setShowBulkActions] = useState(false) - - // Pagination + const [currentPage, setCurrentPage] = useState(1) const [totalPages, setTotalPages] = useState(1) const [total, setTotal] = useState(0) const pageSize = 10 - // Filters const [searchTerm, setSearchTerm] = useState('') const [selectedType, setSelectedType] = useState('all') const [selectedStatus, setSelectedStatus] = useState('all') @@ -64,7 +61,6 @@ function ContactsContent() { const [categories, setCategories] = useState([]) const [showAdvancedFilters, setShowAdvancedFilters] = useState(false) - // Modals const [showCreateModal, setShowCreateModal] = useState(false) const [showEditModal, setShowEditModal] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false) @@ -75,7 +71,6 @@ function ContactsContent() { const [exporting, setExporting] = useState(false) const [exportExcludeCompanyEmployees, setExportExcludeCompanyEmployees] = useState(false) - // Fetch Contacts (with debouncing for search) const fetchContacts = useCallback(async () => { setLoading(true) setError(null) @@ -84,7 +79,7 @@ function ContactsContent() { page: currentPage, pageSize, } - + if (searchTerm) filters.search = searchTerm if (selectedType !== 'all') filters.type = selectedType if (selectedStatus !== 'all') filters.status = selectedStatus @@ -104,21 +99,18 @@ function ContactsContent() { } }, [currentPage, searchTerm, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory]) - // Debounced search useEffect(() => { const debounce = setTimeout(() => { - setCurrentPage(1) // Reset to page 1 on new search + setCurrentPage(1) fetchContacts() }, 500) return () => clearTimeout(debounce) }, [searchTerm]) - // Fetch on filter/page change useEffect(() => { fetchContacts() }, [currentPage, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory]) - // Create Contact const handleCreate = async (data: CreateContactData) => { setSubmitting(true) try { @@ -136,7 +128,6 @@ function ContactsContent() { } } - // Edit Contact const handleEdit = async (data: UpdateContactData) => { if (!selectedContact) return @@ -156,7 +147,6 @@ function ContactsContent() { } } - // Delete Contact const handleDelete = async () => { if (!selectedContact) return @@ -175,7 +165,6 @@ function ContactsContent() { } } - // Utility Functions const resetForm = () => { setSelectedContact(null) } @@ -197,6 +186,7 @@ function ContactsContent() { HOLDING: 'bg-purple-100 text-purple-700', GOVERNMENT: 'bg-orange-100 text-orange-700', ORGANIZATION: 'bg-cyan-100 text-cyan-700', + EMBASSIES: 'bg-red-100 text-red-700', BANK: 'bg-emerald-100 text-emerald-700', UNIVERSITY: 'bg-indigo-100 text-indigo-700', SCHOOL: 'bg-yellow-100 text-yellow-700', @@ -218,6 +208,7 @@ function ContactsContent() { HOLDING: 'مجموعة', GOVERNMENT: 'حكومي', ORGANIZATION: 'منظمات', + EMBASSIES: 'سفارات', BANK: 'بنوك', UNIVERSITY: 'جامعات', SCHOOL: 'مدارس', @@ -228,14 +219,40 @@ function ContactsContent() { return labels[type] || type } + const organizationTypes = new Set([ + 'COMPANY', + 'HOLDING', + 'GOVERNMENT', + 'ORGANIZATION', + 'EMBASSIES', + 'BANK', + 'UNIVERSITY', + 'SCHOOL', + 'UN', + 'NGO', + 'INSTITUTION', + ]) + + const isOrganizationContact = (contact: Contact) => organizationTypes.has(contact.type) + + const getListContactName = (contact: Contact) => { + return contact.name || '-' + } + + const getListCompanyName = (contact: Contact) => { + return contact.companyName || '-' + } + + const getListContactNameAr = (contact: Contact) => { + return (contact as any).nameAr || '' +} return (
- {/* Header */}
-
+
- {/* Stats Cards */}
@@ -358,12 +374,9 @@ function ContactsContent() {
- {/* Filters and Search */}
- {/* Main Filters Row */}
- {/* Search */}
- {/* Type Filter */} - {/* Status Filter */} - {/* Advanced Filters Toggle */}
- {/* Advanced Filters */} {showAdvancedFilters && (
- {/* Source Filter */}
- {/* Category Filter */}
{ - const newSelected = new Set(selectedContacts) - if (e.target.checked) { - newSelected.add(contact.id) - } else { - newSelected.delete(contact.id) - } - setSelectedContacts(newSelected) - }} - className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" - /> - + + + { + const newSelected = new Set(selectedContacts) + if (e.target.checked) { + newSelected.add(contact.id) + } else { + newSelected.delete(contact.id) + } + setSelectedContacts(newSelected) + }} + className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> + + + + {getListCompanyName(contact) !== '-' && ( +
+ + + {getListCompanyName(contact)} + +
+ )} + + + +
+ {contact.email && ( +
+ + {contact.email} +
+ )} + {(contact.phone || contact.mobile) && ( +
+ + {contact.phone || contact.mobile} +
+ )} +
+ +
- {contact.name.charAt(0)} + {getListContactName(contact).charAt(0)}
-

{contact.name}

- {contact.nameAr &&

{contact.nameAr}

} +

+ {getListContactName(contact)} +

+ {getListContactNameAr(contact) && ( +

+ {getListContactNameAr(contact)} +

+ )}
- -
- {contact.email && ( -
- - {contact.email} -
- )} - {contact.phone && ( -
- - {contact.phone} -
- )} -
- - - {contact.companyName && ( + + + + + {getTypeLabel(contact.type)} + + + + + + {contact.status === 'ACTIVE' ? 'Active' : 'Inactive'} + + + +
- - {contact.companyName} + + + + +
- )} - - - - - {getTypeLabel(contact.type)} - - - - - {contact.status === 'ACTIVE' ? 'Active' : 'Inactive'} - - - -
- - - - - -
- - - )})} + + + ) + })}
- {/* Pagination */}

Showing {((currentPage - 1) * pageSize) + 1} to{' '} @@ -703,7 +721,6 @@ function ContactsContent() {

- {/* Create Modal */} { @@ -714,6 +731,7 @@ function ContactsContent() { size="xl" > { await handleCreate(data as CreateContactData) }} @@ -725,7 +743,6 @@ function ContactsContent() { /> - {/* Edit Modal */} { @@ -736,6 +753,7 @@ function ContactsContent() { size="xl" > { await handleEdit(data as UpdateContactData) @@ -748,7 +766,6 @@ function ContactsContent() { /> - {/* Export Modal */} {showExportModal && (
setShowExportModal(false)} /> @@ -763,7 +780,7 @@ function ContactsContent() {

Download contacts data

- +

@@ -812,7 +829,7 @@ function ContactsContent() { a.click() window.URL.revokeObjectURL(url) document.body.removeChild(a) - + toast.success('Contacts exported successfully!') setShowExportModal(false) } catch (err: any) { @@ -842,7 +859,6 @@ function ContactsContent() {

)} - {/* Delete Confirmation Dialog */} {showDeleteDialog && selectedContact && (
setShowDeleteDialog(false)} /> @@ -891,7 +907,6 @@ function ContactsContent() {
)} - {/* Import Modal */} {showImportModal && ( setShowImportModal(false)} @@ -911,4 +926,4 @@ export default function ContactsPage() { ) -} +} \ No newline at end of file diff --git a/frontend/src/components/contacts/ContactForm.tsx b/frontend/src/components/contacts/ContactForm.tsx index c43a17b..b2f9d76 100644 --- a/frontend/src/components/contacts/ContactForm.tsx +++ b/frontend/src/components/contacts/ContactForm.tsx @@ -15,40 +15,47 @@ interface ContactFormProps { submitting?: boolean } +const buildInitialFormData = (contact?: Contact): CreateContactData => ({ + type: contact?.type || 'INDIVIDUAL', + name: contact?.name || '', + nameAr: contact?.nameAr, + email: contact?.email, + phone: contact?.phone, + mobile: contact?.mobile, + website: contact?.website, + companyName: contact?.companyName, + companyNameAr: contact?.companyNameAr, + taxNumber: contact?.taxNumber, + commercialRegister: contact?.commercialRegister, + address: contact?.address, + city: contact?.city, + country: contact?.country || 'Syria', + postalCode: contact?.postalCode, + source: contact?.source || 'WEBSITE', + categories: contact?.categories?.map((c: any) => c.id || c) || [], + tags: contact?.tags || [], + parentId: contact?.parent?.id, + employeeId: contact?.employeeId ?? undefined, + customFields: contact?.customFields +}) + export default function ContactForm({ contact, onSubmit, onCancel, submitting = false }: ContactFormProps) { const isEdit = !!contact - // Form state - const [formData, setFormData] = useState({ - type: contact?.type || 'INDIVIDUAL', - name: contact?.name || '', - nameAr: contact?.nameAr, - email: contact?.email, - phone: contact?.phone, - mobile: contact?.mobile, - website: contact?.website, - companyName: contact?.companyName, - companyNameAr: contact?.companyNameAr, - taxNumber: contact?.taxNumber, - commercialRegister: contact?.commercialRegister, - address: contact?.address, - city: contact?.city, - country: contact?.country || 'Syria', - postalCode: contact?.postalCode, - source: contact?.source || 'WEBSITE', - categories: contact?.categories?.map((c: any) => c.id || c) || [], - tags: contact?.tags || [], - parentId: contact?.parent?.id, - employeeId: contact?.employeeId ?? undefined, - customFields: contact?.customFields - }) - + const [formData, setFormData] = useState(buildInitialFormData(contact)) const [rating, setRating] = useState(contact?.rating || 0) const [newTag, setNewTag] = useState('') const [formErrors, setFormErrors] = useState>({}) const [categories, setCategories] = useState([]) const [employees, setEmployees] = useState([]) + useEffect(() => { + setFormData(buildInitialFormData(contact)) + setRating(contact?.rating || 0) + setNewTag('') + setFormErrors({}) + }, [contact]) + useEffect(() => { categoriesAPI.getTree().then(setCategories).catch(() => {}) }, []) @@ -72,7 +79,23 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting = const isCompanyEmployeeSelected = companyEmployeeCategoryId && (formData.categories || []).includes(companyEmployeeCategoryId) - // Validation + const organizationTypes = new Set([ + 'COMPANY', + 'HOLDING', + 'GOVERNMENT', + 'ORGANIZATION', + 'EMBASSIES', + 'BANK', + 'UNIVERSITY', + 'SCHOOL', + 'UN', + 'NGO', + 'INSTITUTION', + ]) + + const isOrganizationType = organizationTypes.has(formData.type) + const showCompanyFields = isOrganizationType + const validateForm = (): boolean => { const errors: Record = {} @@ -103,28 +126,49 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting = const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() if (!validateForm()) return +const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, value]) => { + const requiredFields = ['type', 'name', 'source', 'country'] - // Clean up empty strings to undefined for optional fields - const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, value]) => { - // Keep the value if it's not an empty string, or if it's a required field - if (value !== '' || ['type', 'name', 'source', 'country'].includes(key)) { - acc[key] = value - } + // keep required fields as-is + if (requiredFields.includes(key)) { + acc[key] = value return acc - }, {} as any) + } + + // in edit mode, allow clearing optional fields by sending null + if (isEdit && value === '') { + acc[key] = null + return acc + } + + // in create mode, ignore empty optional fields + if (value !== '') { + acc[key] = value + } + + return acc + }, {} as any) - // Remove parentId if it's empty or undefined if (!cleanData.parentId) { delete cleanData.parentId } - // Remove categories if empty array if (cleanData.categories && cleanData.categories.length === 0) { delete cleanData.categories } - // Remove employeeId if empty - if (!cleanData.employeeId) { + if (!cleanData.parentId) { + delete cleanData.parentId +} + + if (cleanData.categories && cleanData.categories.length === 0) { + delete cleanData.categories + } + + // employeeId: + // - in create: remove if empty + // - in edit: keep null if user cleared it + if (!isEdit && !cleanData.employeeId) { delete cleanData.employeeId } @@ -152,16 +196,12 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting = }) } - const showCompanyFields = ['COMPANY', 'HOLDING', 'GOVERNMENT'].includes(formData.type) - return ( -
- {/* Basic Information Section */} +

Basic Information

- {/* Contact Type */}
- {/* Source */}
- {/* Name */}
setFormData({ ...formData, name: e.target.value })} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900" - placeholder="Enter contact name" + placeholder={isOrganizationType ? 'Enter contact person name' : 'Enter contact name'} /> {formErrors.name &&

{formErrors.name}

}
- {/* Arabic Name */} -
- - setFormData({ ...formData, nameAr: e.target.value })} - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900" - placeholder="أدخل الاسم بالعربية" - dir="rtl" - /> -
- - {/* Rating */}
- {/* Contact Methods Section */}

Contact Methods

- {/* Email */}
- {/* Phone */}
- {/* Mobile */}
- {/* Website */}
- {/* Company Information Section (conditional) */} {showCompanyFields && (

Company Information

- {/* Company Name */}
setFormData({ ...formData, companyName: e.target.value })} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900" - placeholder="Company name" + placeholder={formData.type === 'EMBASSIES' ? 'Embassy name' : 'Company / organization name'} />
- {/* Company Name Arabic */} -
- - setFormData({ ...formData, companyNameAr: e.target.value })} - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900" - placeholder="اسم الشركة بالعربية" - dir="rtl" - /> -
- {/* Tax Number */}
- {/* Commercial Register */}
)} - {/* Address Section */}

Address Information

- {/* Address */}
- {/* City */}
- {/* Country */}
- {/* Postal Code */}
- {/* Categories Section */}

Categories

- {/* Employee Link - when Company Employee category is selected */} {isCompanyEmployeeSelected && (

Link to Employee (Optional)

@@ -509,11 +501,9 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
)} - {/* Tags Section */}

Tags

- {/* Tag input */}
- {/* Tags display */} {formData.tags && formData.tags.length > 0 && (
{formData.tags.map((tag, index) => ( @@ -555,7 +544,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
- {/* Duplicate Detection */} { - // Navigate to merge page with pre-selected contacts if (typeof window !== 'undefined') { window.location.href = `/contacts/merge?sourceId=${contactId}` } }} /> - {/* Form Actions */}