edit contact form

This commit is contained in:
Aya
2026-04-12 16:52:44 +03:00
parent 13f2214df5
commit bda70feb18
4 changed files with 292 additions and 265 deletions

View File

@@ -58,7 +58,29 @@ router.put(
authorize('contacts', 'contacts', 'update'), authorize('contacts', 'contacts', 'update'),
[ [
param('id').isUUID(), 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, validate,
], ],
contactsController.update contactsController.update

View File

@@ -328,46 +328,50 @@ class ContactsService {
// Update contact // Update contact
const contact = await prisma.contact.update({ const contact = await prisma.contact.update({
where: { id }, where: { id },
data: { data: {
name: data.name, type: data.type,
nameAr: data.nameAr, name: data.name,
email: data.email, nameAr: data.nameAr,
phone: data.phone, email: data.email === '' || data.email === undefined ? null : data.email,
mobile: data.mobile, phone: data.phone,
website: data.website, mobile: data.mobile,
companyName: data.companyName, website: data.website,
companyNameAr: data.companyNameAr, companyName: data.companyName,
taxNumber: data.taxNumber, companyNameAr: data.companyNameAr,
commercialRegister: data.commercialRegister, taxNumber: data.taxNumber,
address: data.address, commercialRegister: data.commercialRegister,
city: data.city, address: data.address,
country: data.country, city: data.city,
postalCode: data.postalCode, country: data.country,
categories: data.categories ? { postalCode: data.postalCode,
set: data.categories.map(id => ({ id })) categories: data.categories
} : undefined, ? {
tags: data.tags, set: data.categories.map((id) => ({ id })),
employeeId: data.employeeId !== undefined ? (data.employeeId || null) : undefined, }
source: data.source, : undefined,
status: data.status, tags: data.tags,
rating: data.rating, employeeId:
customFields: data.customFields, 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 // Log audit
await AuditLogger.log({ await AuditLogger.log({

View File

@@ -41,20 +41,17 @@ function flattenCategories(cats: Category[], result: Category[] = []): Category[
} }
function ContactsContent() { function ContactsContent() {
// State Management
const [contacts, setContacts] = useState<Contact[]>([]) const [contacts, setContacts] = useState<Contact[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [selectedContacts, setSelectedContacts] = useState<Set<string>>(new Set()) const [selectedContacts, setSelectedContacts] = useState<Set<string>>(new Set())
const [showBulkActions, setShowBulkActions] = useState(false) const [showBulkActions, setShowBulkActions] = useState(false)
// Pagination
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1) const [totalPages, setTotalPages] = useState(1)
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const pageSize = 10 const pageSize = 10
// Filters
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [selectedType, setSelectedType] = useState('all') const [selectedType, setSelectedType] = useState('all')
const [selectedStatus, setSelectedStatus] = useState('all') const [selectedStatus, setSelectedStatus] = useState('all')
@@ -64,7 +61,6 @@ function ContactsContent() {
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false) const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
// Modals
const [showCreateModal, setShowCreateModal] = useState(false) const [showCreateModal, setShowCreateModal] = useState(false)
const [showEditModal, setShowEditModal] = useState(false) const [showEditModal, setShowEditModal] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false)
@@ -75,7 +71,6 @@ function ContactsContent() {
const [exporting, setExporting] = useState(false) const [exporting, setExporting] = useState(false)
const [exportExcludeCompanyEmployees, setExportExcludeCompanyEmployees] = useState(false) const [exportExcludeCompanyEmployees, setExportExcludeCompanyEmployees] = useState(false)
// Fetch Contacts (with debouncing for search)
const fetchContacts = useCallback(async () => { const fetchContacts = useCallback(async () => {
setLoading(true) setLoading(true)
setError(null) setError(null)
@@ -104,21 +99,18 @@ function ContactsContent() {
} }
}, [currentPage, searchTerm, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory]) }, [currentPage, searchTerm, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory])
// Debounced search
useEffect(() => { useEffect(() => {
const debounce = setTimeout(() => { const debounce = setTimeout(() => {
setCurrentPage(1) // Reset to page 1 on new search setCurrentPage(1)
fetchContacts() fetchContacts()
}, 500) }, 500)
return () => clearTimeout(debounce) return () => clearTimeout(debounce)
}, [searchTerm]) }, [searchTerm])
// Fetch on filter/page change
useEffect(() => { useEffect(() => {
fetchContacts() fetchContacts()
}, [currentPage, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory]) }, [currentPage, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory])
// Create Contact
const handleCreate = async (data: CreateContactData) => { const handleCreate = async (data: CreateContactData) => {
setSubmitting(true) setSubmitting(true)
try { try {
@@ -136,7 +128,6 @@ function ContactsContent() {
} }
} }
// Edit Contact
const handleEdit = async (data: UpdateContactData) => { const handleEdit = async (data: UpdateContactData) => {
if (!selectedContact) return if (!selectedContact) return
@@ -156,7 +147,6 @@ function ContactsContent() {
} }
} }
// Delete Contact
const handleDelete = async () => { const handleDelete = async () => {
if (!selectedContact) return if (!selectedContact) return
@@ -175,7 +165,6 @@ function ContactsContent() {
} }
} }
// Utility Functions
const resetForm = () => { const resetForm = () => {
setSelectedContact(null) setSelectedContact(null)
} }
@@ -197,6 +186,7 @@ function ContactsContent() {
HOLDING: 'bg-purple-100 text-purple-700', HOLDING: 'bg-purple-100 text-purple-700',
GOVERNMENT: 'bg-orange-100 text-orange-700', GOVERNMENT: 'bg-orange-100 text-orange-700',
ORGANIZATION: 'bg-cyan-100 text-cyan-700', ORGANIZATION: 'bg-cyan-100 text-cyan-700',
EMBASSIES: 'bg-red-100 text-red-700',
BANK: 'bg-emerald-100 text-emerald-700', BANK: 'bg-emerald-100 text-emerald-700',
UNIVERSITY: 'bg-indigo-100 text-indigo-700', UNIVERSITY: 'bg-indigo-100 text-indigo-700',
SCHOOL: 'bg-yellow-100 text-yellow-700', SCHOOL: 'bg-yellow-100 text-yellow-700',
@@ -218,6 +208,7 @@ function ContactsContent() {
HOLDING: 'مجموعة', HOLDING: 'مجموعة',
GOVERNMENT: 'حكومي', GOVERNMENT: 'حكومي',
ORGANIZATION: 'منظمات', ORGANIZATION: 'منظمات',
EMBASSIES: 'سفارات',
BANK: 'بنوك', BANK: 'بنوك',
UNIVERSITY: 'جامعات', UNIVERSITY: 'جامعات',
SCHOOL: 'مدارس', SCHOOL: 'مدارس',
@@ -228,14 +219,40 @@ function ContactsContent() {
return labels[type] || type 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 ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white shadow-sm border-b"> <header className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link <Link
href="/dashboard" href="/dashboard"
className="p-2 hover:bg-gray-100 rounded-lg transition-colors" className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
@@ -303,7 +320,6 @@ function ContactsContent() {
</header> </header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8"> <div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200"> <div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -358,12 +374,9 @@ function ContactsContent() {
</div> </div>
</div> </div>
{/* Filters and Search */}
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200 mb-6"> <div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200 mb-6">
<div className="space-y-4"> <div className="space-y-4">
{/* Main Filters Row */}
<div className="flex flex-col md:flex-row gap-4"> <div className="flex flex-col md:flex-row gap-4">
{/* Search */}
<div className="flex-1 relative"> <div className="flex-1 relative">
<Search className="absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" /> <Search className="absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input <input
@@ -375,7 +388,6 @@ function ContactsContent() {
/> />
</div> </div>
{/* Type Filter */}
<select <select
value={selectedType} value={selectedType}
onChange={(e) => setSelectedType(e.target.value)} onChange={(e) => setSelectedType(e.target.value)}
@@ -396,7 +408,6 @@ function ContactsContent() {
<option value="INSTITUTION">Institution</option> <option value="INSTITUTION">Institution</option>
</select> </select>
{/* Status Filter */}
<select <select
value={selectedStatus} value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value)} onChange={(e) => setSelectedStatus(e.target.value)}
@@ -407,7 +418,6 @@ function ContactsContent() {
<option value="INACTIVE">Inactive</option> <option value="INACTIVE">Inactive</option>
</select> </select>
{/* Advanced Filters Toggle */}
<button <button
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)} onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
className={`flex items-center gap-2 px-4 py-3 border rounded-lg transition-colors ${ className={`flex items-center gap-2 px-4 py-3 border rounded-lg transition-colors ${
@@ -421,11 +431,9 @@ function ContactsContent() {
</button> </button>
</div> </div>
{/* Advanced Filters */}
{showAdvancedFilters && ( {showAdvancedFilters && (
<div className="pt-4 border-t border-gray-200"> <div className="pt-4 border-t border-gray-200">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Source Filter */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">Source</label> <label className="block text-sm font-medium text-gray-700 mb-2">Source</label>
<select <select
@@ -445,7 +453,6 @@ function ContactsContent() {
</select> </select>
</div> </div>
{/* Rating Filter */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">Rating</label> <label className="block text-sm font-medium text-gray-700 mb-2">Rating</label>
<select <select
@@ -462,7 +469,6 @@ function ContactsContent() {
</select> </select>
</div> </div>
{/* Category Filter */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">Category</label> <label className="block text-sm font-medium text-gray-700 mb-2">Category</label>
<select <select
@@ -477,7 +483,6 @@ function ContactsContent() {
</select> </select>
</div> </div>
{/* Clear Filters */}
<div className="flex items-end"> <div className="flex items-end">
<button <button
onClick={() => { onClick={() => {
@@ -500,7 +505,6 @@ function ContactsContent() {
</div> </div>
</div> </div>
{/* Contacts Table */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden"> <div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
{loading ? ( {loading ? (
<div className="p-12"> <div className="p-12">
@@ -547,9 +551,9 @@ function ContactsContent() {
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/> />
</th> </th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Contact</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Contact Info</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Company</th> <th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Company</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Contact Info</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Contact</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Type</th> <th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Type</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Status</th> <th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Status</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Actions</th> <th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Actions</th>
@@ -559,105 +563,119 @@ function ContactsContent() {
{contacts.map((contact) => { {contacts.map((contact) => {
const isSelected = selectedContacts.has(contact.id) const isSelected = selectedContacts.has(contact.id)
return ( return (
<tr key={contact.id} className={`hover:bg-gray-50 transition-colors ${isSelected ? 'bg-blue-50' : ''}`}> <tr key={contact.id} className={`hover:bg-gray-50 transition-colors ${isSelected ? 'bg-blue-50' : ''}`}>
<td className="px-6 py-4 text-center"> <td className="px-6 py-4 text-center">
<input <input
type="checkbox" type="checkbox"
checked={isSelected} checked={isSelected}
onChange={(e) => { onChange={(e) => {
const newSelected = new Set(selectedContacts) const newSelected = new Set(selectedContacts)
if (e.target.checked) { if (e.target.checked) {
newSelected.add(contact.id) newSelected.add(contact.id)
} else { } else {
newSelected.delete(contact.id) newSelected.delete(contact.id)
} }
setSelectedContacts(newSelected) setSelectedContacts(newSelected)
}} }}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/> />
</td> </td>
<td className="px-6 py-4">
{getListCompanyName(contact) !== '-' && (
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-900">
{getListCompanyName(contact)}
</span>
</div>
)}
</td>
<td className="px-6 py-4">
<div className="space-y-1">
{contact.email && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<Mail className="h-4 w-4" />
{contact.email}
</div>
)}
{(contact.phone || contact.mobile) && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<Phone className="h-4 w-4" />
{contact.phone || contact.mobile}
</div>
)}
</div>
</td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold"> <div className="h-10 w-10 rounded-full bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold">
{contact.name.charAt(0)} {getListContactName(contact).charAt(0)}
</div> </div>
<div> <div>
<p className="font-semibold text-gray-900">{contact.name}</p> <p className="font-semibold text-gray-900">
{contact.nameAr && <p className="text-sm text-gray-600">{contact.nameAr}</p>} {getListContactName(contact)}
</p>
{getListContactNameAr(contact) && (
<p className="text-sm text-gray-600">
{getListContactNameAr(contact)}
</p>
)}
</div> </div>
</div> </div>
</td> </td>
<td className="px-6 py-4">
<div className="space-y-1"> <td className="px-6 py-4">
{contact.email && ( <span className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium ${getTypeColor(contact.type)}`}>
<div className="flex items-center gap-2 text-sm text-gray-600"> <Tag className="h-3 w-3" />
<Mail className="h-4 w-4" /> {getTypeLabel(contact.type)}
{contact.email} </span>
</div> </td>
)}
{contact.phone && ( <td className="px-6 py-4">
<div className="flex items-center gap-2 text-sm text-gray-600"> <span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
<Phone className="h-4 w-4" /> contact.status === 'ACTIVE'
{contact.phone} ? 'bg-green-100 text-green-700'
</div> : 'bg-gray-100 text-gray-700'
)} }`}>
</div> {contact.status === 'ACTIVE' ? 'Active' : 'Inactive'}
</td> </span>
<td className="px-6 py-4"> </td>
{contact.companyName && (
<td className="px-6 py-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-gray-400" /> <Link
<span className="text-sm text-gray-900">{contact.companyName}</span> href={`/contacts/${contact.id}`}
className="p-2 hover:bg-blue-50 text-blue-600 rounded-lg transition-colors"
title="View"
>
<Eye className="h-4 w-4" />
</Link>
<button
onClick={() => openEditModal(contact)}
className="p-2 hover:bg-green-50 text-green-600 rounded-lg transition-colors"
title="Edit"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => openDeleteDialog(contact)}
className="p-2 hover:bg-red-50 text-red-600 rounded-lg transition-colors"
title="Delete"
>
<Trash2 className="h-4 w-4" />
</button>
</div> </div>
)} </td>
</td> </tr>
<td className="px-6 py-4"> )
<span className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium ${getTypeColor(contact.type)}`}> })}
<Tag className="h-3 w-3" />
{getTypeLabel(contact.type)}
</span>
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
contact.status === 'ACTIVE'
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-700'
}`}>
{contact.status === 'ACTIVE' ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<Link
href={`/contacts/${contact.id}`}
className="p-2 hover:bg-blue-50 text-blue-600 rounded-lg transition-colors"
title="View"
>
<Eye className="h-4 w-4" />
</Link>
<button
onClick={() => openEditModal(contact)}
className="p-2 hover:bg-green-50 text-green-600 rounded-lg transition-colors"
title="Edit"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => openDeleteDialog(contact)}
className="p-2 hover:bg-red-50 text-red-600 rounded-lg transition-colors"
title="Delete"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
)})}
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Pagination */}
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between"> <div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
Showing <span className="font-semibold">{((currentPage - 1) * pageSize) + 1}</span> to{' '} Showing <span className="font-semibold">{((currentPage - 1) * pageSize) + 1}</span> to{' '}
@@ -703,7 +721,6 @@ function ContactsContent() {
</div> </div>
</main> </main>
{/* Create Modal */}
<Modal <Modal
isOpen={showCreateModal} isOpen={showCreateModal}
onClose={() => { onClose={() => {
@@ -714,6 +731,7 @@ function ContactsContent() {
size="xl" size="xl"
> >
<ContactForm <ContactForm
key="create-contact"
onSubmit={async (data) => { onSubmit={async (data) => {
await handleCreate(data as CreateContactData) await handleCreate(data as CreateContactData)
}} }}
@@ -725,7 +743,6 @@ function ContactsContent() {
/> />
</Modal> </Modal>
{/* Edit Modal */}
<Modal <Modal
isOpen={showEditModal} isOpen={showEditModal}
onClose={() => { onClose={() => {
@@ -736,6 +753,7 @@ function ContactsContent() {
size="xl" size="xl"
> >
<ContactForm <ContactForm
key={selectedContact?.id || 'edit-contact'}
contact={selectedContact || undefined} contact={selectedContact || undefined}
onSubmit={async (data) => { onSubmit={async (data) => {
await handleEdit(data as UpdateContactData) await handleEdit(data as UpdateContactData)
@@ -748,7 +766,6 @@ function ContactsContent() {
/> />
</Modal> </Modal>
{/* Export Modal */}
{showExportModal && ( {showExportModal && (
<div className="fixed inset-0 z-50 overflow-y-auto"> <div className="fixed inset-0 z-50 overflow-y-auto">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowExportModal(false)} /> <div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowExportModal(false)} />
@@ -842,7 +859,6 @@ function ContactsContent() {
</div> </div>
)} )}
{/* Delete Confirmation Dialog */}
{showDeleteDialog && selectedContact && ( {showDeleteDialog && selectedContact && (
<div className="fixed inset-0 z-50 overflow-y-auto"> <div className="fixed inset-0 z-50 overflow-y-auto">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowDeleteDialog(false)} /> <div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowDeleteDialog(false)} />
@@ -891,7 +907,6 @@ function ContactsContent() {
</div> </div>
)} )}
{/* Import Modal */}
{showImportModal && ( {showImportModal && (
<ContactImport <ContactImport
onClose={() => setShowImportModal(false)} onClose={() => setShowImportModal(false)}

View File

@@ -15,40 +15,47 @@ interface ContactFormProps {
submitting?: boolean 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) { export default function ContactForm({ contact, onSubmit, onCancel, submitting = false }: ContactFormProps) {
const isEdit = !!contact const isEdit = !!contact
// Form state const [formData, setFormData] = useState<CreateContactData>(buildInitialFormData(contact))
const [formData, setFormData] = useState<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
})
const [rating, setRating] = useState<number>(contact?.rating || 0) const [rating, setRating] = useState<number>(contact?.rating || 0)
const [newTag, setNewTag] = useState('') const [newTag, setNewTag] = useState('')
const [formErrors, setFormErrors] = useState<Record<string, string>>({}) const [formErrors, setFormErrors] = useState<Record<string, string>>({})
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [employees, setEmployees] = useState<Employee[]>([]) const [employees, setEmployees] = useState<Employee[]>([])
useEffect(() => {
setFormData(buildInitialFormData(contact))
setRating(contact?.rating || 0)
setNewTag('')
setFormErrors({})
}, [contact])
useEffect(() => { useEffect(() => {
categoriesAPI.getTree().then(setCategories).catch(() => {}) categoriesAPI.getTree().then(setCategories).catch(() => {})
}, []) }, [])
@@ -72,7 +79,23 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
const isCompanyEmployeeSelected = companyEmployeeCategoryId && (formData.categories || []).includes(companyEmployeeCategoryId) 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 validateForm = (): boolean => {
const errors: Record<string, string> = {} const errors: Record<string, string> = {}
@@ -103,28 +126,49 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!validateForm()) return 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 // keep required fields as-is
const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, value]) => { if (requiredFields.includes(key)) {
// Keep the value if it's not an empty string, or if it's a required field acc[key] = value
if (value !== '' || ['type', 'name', 'source', 'country'].includes(key)) {
acc[key] = value
}
return acc 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) { if (!cleanData.parentId) {
delete cleanData.parentId delete cleanData.parentId
} }
// Remove categories if empty array
if (cleanData.categories && cleanData.categories.length === 0) { if (cleanData.categories && cleanData.categories.length === 0) {
delete cleanData.categories delete cleanData.categories
} }
// Remove employeeId if empty if (!cleanData.parentId) {
if (!cleanData.employeeId) { 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 delete cleanData.employeeId
} }
@@ -152,16 +196,12 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
}) })
} }
const showCompanyFields = ['COMPANY', 'HOLDING', 'GOVERNMENT'].includes(formData.type)
return ( return (
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} noValidate className="space-y-6">
{/* Basic Information Section */}
<div> <div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Basic Information</h3> <h3 className="text-lg font-semibold text-gray-900 mb-4">Basic Information</h3>
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Contact Type */}
<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">
Contact Type <span className="text-red-500">*</span> Contact Type <span className="text-red-500">*</span>
@@ -187,7 +227,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
{formErrors.type && <p className="text-red-500 text-xs mt-1">{formErrors.type}</p>} {formErrors.type && <p className="text-red-500 text-xs mt-1">{formErrors.type}</p>}
</div> </div>
{/* Source */}
<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">
Source <span className="text-red-500">*</span> Source <span className="text-red-500">*</span>
@@ -210,37 +249,20 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
</div> </div>
</div> </div>
{/* Name */}
<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">
Name <span className="text-red-500">*</span> {isOrganizationType ? 'Contact Person Name' : 'Name'} <span className="text-red-500">*</span>
</label> </label>
<input <input
type="text" type="text"
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => 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" 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 && <p className="text-red-500 text-xs mt-1">{formErrors.name}</p>} {formErrors.name && <p className="text-red-500 text-xs mt-1">{formErrors.name}</p>}
</div> </div>
{/* Arabic Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Arabic Name - الاسم بالعربية
</label>
<input
type="text"
value={formData.nameAr || ''}
onChange={(e) => 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"
/>
</div>
{/* Rating */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Rating Rating
@@ -276,12 +298,10 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
</div> </div>
</div> </div>
{/* Contact Methods Section */}
<div className="pt-6 border-t"> <div className="pt-6 border-t">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Contact Methods</h3> <h3 className="text-lg font-semibold text-gray-900 mb-4">Contact Methods</h3>
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Email */}
<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">
Email Email
@@ -296,7 +316,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
{formErrors.email && <p className="text-red-500 text-xs mt-1">{formErrors.email}</p>} {formErrors.email && <p className="text-red-500 text-xs mt-1">{formErrors.email}</p>}
</div> </div>
{/* Phone */}
<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">
Phone Phone
@@ -313,7 +332,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Mobile */}
<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">
Mobile Mobile
@@ -327,7 +345,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
/> />
</div> </div>
{/* Website */}
<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">
Website Website
@@ -344,44 +361,27 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
</div> </div>
</div> </div>
{/* Company Information Section (conditional) */}
{showCompanyFields && ( {showCompanyFields && (
<div className="pt-6 border-t"> <div className="pt-6 border-t">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Company Information</h3> <h3 className="text-lg font-semibold text-gray-900 mb-4">Company Information</h3>
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Company Name */}
<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">
Company Name {formData.type === 'EMBASSIES' ? 'Embassy Name' : 'Company / Organization Name'}
</label> </label>
<input <input
type="text" type="text"
value={formData.companyName || ''} value={formData.companyName || ''}
onChange={(e) => setFormData({ ...formData, companyName: e.target.value })} onChange={(e) => 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" 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'}
/> />
</div> </div>
{/* Company Name Arabic */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Company Name (Arabic) - اسم الشركة
</label>
<input
type="text"
value={formData.companyNameAr || ''}
onChange={(e) => 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"
/>
</div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Tax Number */}
<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">
Tax Number Tax Number
@@ -395,7 +395,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
/> />
</div> </div>
{/* Commercial Register */}
<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">
Commercial Register Commercial Register
@@ -413,11 +412,9 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
</div> </div>
)} )}
{/* Address Section */}
<div className="pt-6 border-t"> <div className="pt-6 border-t">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Address Information</h3> <h3 className="text-lg font-semibold text-gray-900 mb-4">Address Information</h3>
<div className="space-y-4"> <div className="space-y-4">
{/* Address */}
<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">
Street Address Street Address
@@ -432,7 +429,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* City */}
<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">
City City
@@ -446,7 +442,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
/> />
</div> </div>
{/* Country */}
<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">
Country Country
@@ -460,7 +455,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
/> />
</div> </div>
{/* Postal Code */}
<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">
Postal Code Postal Code
@@ -477,7 +471,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
</div> </div>
</div> </div>
{/* Categories Section */}
<div className="pt-6 border-t"> <div className="pt-6 border-t">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Categories</h3> <h3 className="text-lg font-semibold text-gray-900 mb-4">Categories</h3>
<CategorySelector <CategorySelector
@@ -487,7 +480,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
/> />
</div> </div>
{/* Employee Link - when Company Employee category is selected */}
{isCompanyEmployeeSelected && ( {isCompanyEmployeeSelected && (
<div className="pt-6 border-t"> <div className="pt-6 border-t">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Link to Employee (Optional)</h3> <h3 className="text-lg font-semibold text-gray-900 mb-4">Link to Employee (Optional)</h3>
@@ -509,11 +501,9 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
</div> </div>
)} )}
{/* Tags Section */}
<div className="pt-6 border-t"> <div className="pt-6 border-t">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Tags</h3> <h3 className="text-lg font-semibold text-gray-900 mb-4">Tags</h3>
<div className="space-y-3"> <div className="space-y-3">
{/* Tag input */}
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="text" type="text"
@@ -532,7 +522,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
</button> </button>
</div> </div>
{/* Tags display */}
{formData.tags && formData.tags.length > 0 && ( {formData.tags && formData.tags.length > 0 && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{formData.tags.map((tag, index) => ( {formData.tags.map((tag, index) => (
@@ -555,7 +544,6 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
</div> </div>
</div> </div>
{/* Duplicate Detection */}
<DuplicateAlert <DuplicateAlert
email={formData.email} email={formData.email}
phone={formData.phone} phone={formData.phone}
@@ -564,14 +552,12 @@ export default function ContactForm({ contact, onSubmit, onCancel, submitting =
commercialRegister={formData.commercialRegister} commercialRegister={formData.commercialRegister}
excludeId={contact?.id} excludeId={contact?.id}
onMerge={(contactId) => { onMerge={(contactId) => {
// Navigate to merge page with pre-selected contacts
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.location.href = `/contacts/merge?sourceId=${contactId}` window.location.href = `/contacts/merge?sourceId=${contactId}`
} }
}} }}
/> />
{/* Form Actions */}
<div className="flex items-center justify-end gap-3 pt-6 border-t"> <div className="flex items-center justify-end gap-3 pt-6 border-t">
<button <button
type="button" type="button"