Deploy rule, CRM enhancements, Company Employee category, bilingual, contacts module completion
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -28,12 +28,25 @@ import {
|
||||
Loader2
|
||||
} from 'lucide-react'
|
||||
import { contactsAPI, Contact, CreateContactData, UpdateContactData, ContactFilters } from '@/lib/api/contacts'
|
||||
import { categoriesAPI, Category } from '@/lib/api/categories'
|
||||
import ContactForm from '@/components/contacts/ContactForm'
|
||||
import ContactImport from '@/components/contacts/ContactImport'
|
||||
|
||||
function flattenCategories(cats: Category[], result: Category[] = []): Category[] {
|
||||
for (const c of cats) {
|
||||
result.push(c)
|
||||
if (c.children?.length) flattenCategories(c.children, result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function ContactsContent() {
|
||||
// State Management
|
||||
const [contacts, setContacts] = useState<Contact[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedContacts, setSelectedContacts] = useState<Set<string>>(new Set())
|
||||
const [showBulkActions, setShowBulkActions] = useState(false)
|
||||
|
||||
// Pagination
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
@@ -45,28 +58,22 @@ function ContactsContent() {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [selectedType, setSelectedType] = useState('all')
|
||||
const [selectedStatus, setSelectedStatus] = useState('all')
|
||||
const [selectedSource, setSelectedSource] = useState('all')
|
||||
const [selectedRating, setSelectedRating] = useState('all')
|
||||
const [selectedCategory, setSelectedCategory] = useState('all')
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
|
||||
|
||||
// Modals
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [showExportModal, setShowExportModal] = useState(false)
|
||||
const [showImportModal, setShowImportModal] = useState(false)
|
||||
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
|
||||
|
||||
// Form Data
|
||||
const [formData, setFormData] = useState<CreateContactData>({
|
||||
type: 'INDIVIDUAL',
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
mobile: '',
|
||||
companyName: '',
|
||||
address: '',
|
||||
city: '',
|
||||
country: 'Saudi Arabia',
|
||||
source: 'WEBSITE'
|
||||
})
|
||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [exportExcludeCompanyEmployees, setExportExcludeCompanyEmployees] = useState(false)
|
||||
|
||||
// Fetch Contacts (with debouncing for search)
|
||||
const fetchContacts = useCallback(async () => {
|
||||
@@ -81,6 +88,9 @@ function ContactsContent() {
|
||||
if (searchTerm) filters.search = searchTerm
|
||||
if (selectedType !== 'all') filters.type = selectedType
|
||||
if (selectedStatus !== 'all') filters.status = selectedStatus
|
||||
if (selectedSource !== 'all') filters.source = selectedSource
|
||||
if (selectedRating !== 'all') filters.rating = parseInt(selectedRating)
|
||||
if (selectedCategory !== 'all') filters.category = selectedCategory
|
||||
|
||||
const data = await contactsAPI.getAll(filters)
|
||||
setContacts(data.contacts)
|
||||
@@ -92,7 +102,7 @@ function ContactsContent() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [currentPage, searchTerm, selectedType, selectedStatus])
|
||||
}, [currentPage, searchTerm, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory])
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
@@ -106,43 +116,13 @@ function ContactsContent() {
|
||||
// Fetch on filter/page change
|
||||
useEffect(() => {
|
||||
fetchContacts()
|
||||
}, [currentPage, selectedType, selectedStatus])
|
||||
|
||||
// Form Validation
|
||||
const validateForm = (): boolean => {
|
||||
const errors: Record<string, string> = {}
|
||||
|
||||
if (!formData.name || formData.name.trim().length < 2) {
|
||||
errors.name = 'Name must be at least 2 characters'
|
||||
}
|
||||
|
||||
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
errors.email = 'Invalid email format'
|
||||
}
|
||||
|
||||
if (formData.phone && !/^\+?[\d\s-()]+$/.test(formData.phone)) {
|
||||
errors.phone = 'Invalid phone format'
|
||||
}
|
||||
|
||||
if (!formData.type) {
|
||||
errors.type = 'Contact type is required'
|
||||
}
|
||||
|
||||
setFormErrors(errors)
|
||||
return Object.keys(errors).length === 0
|
||||
}
|
||||
}, [currentPage, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory])
|
||||
|
||||
// Create Contact
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!validateForm()) {
|
||||
toast.error('Please fix form errors')
|
||||
return
|
||||
}
|
||||
|
||||
const handleCreate = async (data: CreateContactData) => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await contactsAPI.create(formData)
|
||||
await contactsAPI.create(data)
|
||||
toast.success('Contact created successfully!')
|
||||
setShowCreateModal(false)
|
||||
resetForm()
|
||||
@@ -150,25 +130,19 @@ function ContactsContent() {
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.message || 'Failed to create contact'
|
||||
toast.error(message)
|
||||
if (err.response?.data?.errors) {
|
||||
setFormErrors(err.response.data.errors)
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Edit Contact
|
||||
const handleEdit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!selectedContact || !validateForm()) {
|
||||
toast.error('Please fix form errors')
|
||||
return
|
||||
}
|
||||
const handleEdit = async (data: UpdateContactData) => {
|
||||
if (!selectedContact) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await contactsAPI.update(selectedContact.id, formData as UpdateContactData)
|
||||
await contactsAPI.update(selectedContact.id, data)
|
||||
toast.success('Contact updated successfully!')
|
||||
setShowEditModal(false)
|
||||
resetForm()
|
||||
@@ -176,6 +150,7 @@ function ContactsContent() {
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.message || 'Failed to update contact'
|
||||
toast.error(message)
|
||||
throw err
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -202,38 +177,11 @@ function ContactsContent() {
|
||||
|
||||
// Utility Functions
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
type: 'INDIVIDUAL',
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
mobile: '',
|
||||
companyName: '',
|
||||
address: '',
|
||||
city: '',
|
||||
country: 'Saudi Arabia',
|
||||
source: 'WEBSITE'
|
||||
})
|
||||
setFormErrors({})
|
||||
setSelectedContact(null)
|
||||
}
|
||||
|
||||
const openEditModal = (contact: Contact) => {
|
||||
setSelectedContact(contact)
|
||||
setFormData({
|
||||
type: contact.type,
|
||||
name: contact.name,
|
||||
nameAr: contact.nameAr,
|
||||
email: contact.email || '',
|
||||
phone: contact.phone || '',
|
||||
mobile: contact.mobile || '',
|
||||
companyName: contact.companyName || '',
|
||||
companyNameAr: contact.companyNameAr || '',
|
||||
address: contact.address || '',
|
||||
city: contact.city || '',
|
||||
country: contact.country || 'Saudi Arabia',
|
||||
source: contact.source
|
||||
})
|
||||
setShowEditModal(true)
|
||||
}
|
||||
|
||||
@@ -252,6 +200,10 @@ function ContactsContent() {
|
||||
return colors[type] || 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
categoriesAPI.getTree().then(setCategories).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
INDIVIDUAL: 'فرد',
|
||||
@@ -262,216 +214,6 @@ function ContactsContent() {
|
||||
return labels[type] || type
|
||||
}
|
||||
|
||||
// Render Form Fields Component
|
||||
const FormFields = ({ isEdit = false }: { isEdit?: boolean }) => (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Contact Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Contact Type <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: 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"
|
||||
>
|
||||
<option value="INDIVIDUAL">Individual - فرد</option>
|
||||
<option value="COMPANY">Company - شركة</option>
|
||||
<option value="HOLDING">Holding - مجموعة</option>
|
||||
<option value="GOVERNMENT">Government - حكومي</option>
|
||||
</select>
|
||||
{formErrors.type && <p className="text-red-500 text-xs mt-1">{formErrors.type}</p>}
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Source <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.source}
|
||||
onChange={(e) => setFormData({ ...formData, source: 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"
|
||||
>
|
||||
<option value="WEBSITE">Website</option>
|
||||
<option value="REFERRAL">Referral</option>
|
||||
<option value="COLD_CALL">Cold Call</option>
|
||||
<option value="SOCIAL_MEDIA">Social Media</option>
|
||||
<option value="EVENT">Event</option>
|
||||
<option value="OTHER">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
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"
|
||||
placeholder="Enter contact name"
|
||||
/>
|
||||
{formErrors.name && <p className="text-red-500 text-xs mt-1">{formErrors.name}</p>}
|
||||
</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"
|
||||
placeholder="أدخل الاسم بالعربية"
|
||||
dir="rtl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email || ''}
|
||||
onChange={(e) => setFormData({ ...formData, email: 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"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
{formErrors.email && <p className="text-red-500 text-xs mt-1">{formErrors.email}</p>}
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Phone
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.phone || ''}
|
||||
onChange={(e) => setFormData({ ...formData, phone: 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"
|
||||
placeholder="+966 50 123 4567"
|
||||
/>
|
||||
{formErrors.phone && <p className="text-red-500 text-xs mt-1">{formErrors.phone}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Mobile */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Mobile
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.mobile || ''}
|
||||
onChange={(e) => setFormData({ ...formData, mobile: 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"
|
||||
placeholder="+966 55 123 4567"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Company Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Company Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.companyName || ''}
|
||||
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"
|
||||
placeholder="Company name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Address
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.address || ''}
|
||||
onChange={(e) => setFormData({ ...formData, address: 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"
|
||||
placeholder="Street address"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* City */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
City
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.city || ''}
|
||||
onChange={(e) => setFormData({ ...formData, city: 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"
|
||||
placeholder="City"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Country */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Country
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.country || ''}
|
||||
onChange={(e) => setFormData({ ...formData, country: 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"
|
||||
placeholder="Country"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
isEdit ? setShowEditModal(false) : setShowCreateModal(false)
|
||||
resetForm()
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{isEdit ? 'Updating...' : 'Creating...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isEdit ? 'Update Contact' : 'Create Contact'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
@@ -498,11 +240,36 @@ function ContactsContent() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
{selectedContacts.size > 0 && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<span className="text-sm font-medium text-blue-700">
|
||||
{selectedContacts.size} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowBulkActions(!showBulkActions)}
|
||||
className="text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Actions
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedContacts(new Set())}
|
||||
className="text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowImportModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Import
|
||||
</button>
|
||||
<button className="flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<button
|
||||
onClick={() => setShowExportModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Export
|
||||
</button>
|
||||
@@ -579,42 +346,135 @@ function ContactsContent() {
|
||||
|
||||
{/* Filters and Search */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200 mb-6">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search contacts (name, email, company...)"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pr-10 pl-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
{/* Main Filters Row */}
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search contacts (name, email, company...)"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pr-10 pl-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type Filter */}
|
||||
<select
|
||||
value={selectedType}
|
||||
onChange={(e) => setSelectedType(e.target.value)}
|
||||
className="px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="INDIVIDUAL">Individuals</option>
|
||||
<option value="COMPANY">Companies</option>
|
||||
<option value="HOLDING">Holdings</option>
|
||||
<option value="GOVERNMENT">Government</option>
|
||||
</select>
|
||||
|
||||
{/* Status Filter */}
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => setSelectedStatus(e.target.value)}
|
||||
className="px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="INACTIVE">Inactive</option>
|
||||
</select>
|
||||
|
||||
{/* Advanced Filters Toggle */}
|
||||
<button
|
||||
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
|
||||
className={`flex items-center gap-2 px-4 py-3 border rounded-lg transition-colors ${
|
||||
showAdvancedFilters
|
||||
? 'bg-blue-50 border-blue-300 text-blue-700'
|
||||
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
Advanced
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Type Filter */}
|
||||
<select
|
||||
value={selectedType}
|
||||
onChange={(e) => setSelectedType(e.target.value)}
|
||||
className="px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="INDIVIDUAL">Individuals</option>
|
||||
<option value="COMPANY">Companies</option>
|
||||
<option value="HOLDING">Holdings</option>
|
||||
<option value="GOVERNMENT">Government</option>
|
||||
</select>
|
||||
{/* Advanced Filters */}
|
||||
{showAdvancedFilters && (
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Source Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Source</label>
|
||||
<select
|
||||
value={selectedSource}
|
||||
onChange={(e) => setSelectedSource(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"
|
||||
>
|
||||
<option value="all">All Sources</option>
|
||||
<option value="WEBSITE">Website</option>
|
||||
<option value="REFERRAL">Referral</option>
|
||||
<option value="COLD_CALL">Cold Call</option>
|
||||
<option value="SOCIAL_MEDIA">Social Media</option>
|
||||
<option value="EXHIBITION">Exhibition</option>
|
||||
<option value="EVENT">Event</option>
|
||||
<option value="VISIT">Visit</option>
|
||||
<option value="OTHER">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => setSelectedStatus(e.target.value)}
|
||||
className="px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="INACTIVE">Inactive</option>
|
||||
</select>
|
||||
{/* Rating Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Rating</label>
|
||||
<select
|
||||
value={selectedRating}
|
||||
onChange={(e) => setSelectedRating(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"
|
||||
>
|
||||
<option value="all">All Ratings</option>
|
||||
<option value="5">5 Stars</option>
|
||||
<option value="4">4 Stars</option>
|
||||
<option value="3">3 Stars</option>
|
||||
<option value="2">2 Stars</option>
|
||||
<option value="1">1 Star</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Category</label>
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(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"
|
||||
>
|
||||
<option value="all">All Categories</option>
|
||||
{flattenCategories(categories).map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}{cat.nameAr ? ` (${cat.nameAr})` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Clear Filters */}
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchTerm('')
|
||||
setSelectedType('all')
|
||||
setSelectedStatus('all')
|
||||
setSelectedSource('all')
|
||||
setSelectedRating('all')
|
||||
setSelectedCategory('all')
|
||||
setCurrentPage(1)
|
||||
}}
|
||||
className="w-full px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Clear All Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -651,6 +511,20 @@ function ContactsContent() {
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-center w-12">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={contacts.length > 0 && contacts.every(c => selectedContacts.has(c.id))}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedContacts(new Set(contacts.map(c => c.id)))
|
||||
} else {
|
||||
setSelectedContacts(new Set())
|
||||
}
|
||||
}}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
</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>
|
||||
@@ -660,8 +534,26 @@ function ContactsContent() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{contacts.map((contact) => (
|
||||
<tr key={contact.id} className="hover:bg-gray-50 transition-colors">
|
||||
{contacts.map((contact) => {
|
||||
const isSelected = selectedContacts.has(contact.id)
|
||||
return (
|
||||
<tr key={contact.id} className={`hover:bg-gray-50 transition-colors ${isSelected ? 'bg-blue-50' : ''}`}>
|
||||
<td className="px-6 py-4 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<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">
|
||||
@@ -714,6 +606,13 @@ function ContactsContent() {
|
||||
</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"
|
||||
@@ -731,7 +630,7 @@ function ContactsContent() {
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
)})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -792,9 +691,16 @@ function ContactsContent() {
|
||||
title="Create New Contact"
|
||||
size="xl"
|
||||
>
|
||||
<form onSubmit={handleCreate}>
|
||||
<FormFields />
|
||||
</form>
|
||||
<ContactForm
|
||||
onSubmit={async (data) => {
|
||||
await handleCreate(data as CreateContactData)
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowCreateModal(false)
|
||||
resetForm()
|
||||
}}
|
||||
submitting={submitting}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
@@ -807,11 +713,113 @@ function ContactsContent() {
|
||||
title="Edit Contact"
|
||||
size="xl"
|
||||
>
|
||||
<form onSubmit={handleEdit}>
|
||||
<FormFields isEdit />
|
||||
</form>
|
||||
<ContactForm
|
||||
contact={selectedContact || undefined}
|
||||
onSubmit={async (data) => {
|
||||
await handleEdit(data as UpdateContactData)
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowEditModal(false)
|
||||
resetForm()
|
||||
}}
|
||||
submitting={submitting}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* Export Modal */}
|
||||
{showExportModal && (
|
||||
<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="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="relative bg-white rounded-xl shadow-2xl p-6 max-w-md w-full">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="bg-blue-100 p-3 rounded-full">
|
||||
<Download className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900">Export Contacts</h3>
|
||||
<p className="text-sm text-gray-600">Download contacts data</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700 mb-2">
|
||||
Export <span className="font-semibold">{total}</span> contacts matching current filters
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Format: Excel (.xlsx)
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportExcludeCompanyEmployees}
|
||||
onChange={(e) => setExportExcludeCompanyEmployees(e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Exclude company employees</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowExportModal(false)}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
disabled={exporting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
const filters: ContactFilters & { excludeCompanyEmployees?: boolean } = {}
|
||||
if (searchTerm) filters.search = searchTerm
|
||||
if (selectedType !== 'all') filters.type = selectedType
|
||||
if (selectedStatus !== 'all') filters.status = selectedStatus
|
||||
if (selectedCategory !== 'all') filters.category = selectedCategory
|
||||
if (exportExcludeCompanyEmployees) filters.excludeCompanyEmployees = true
|
||||
|
||||
const blob = await contactsAPI.export(filters)
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `contacts_${new Date().toISOString().split('T')[0]}.xlsx`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
|
||||
toast.success('Contacts exported successfully!')
|
||||
setShowExportModal(false)
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || 'Failed to export contacts')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
|
||||
disabled={exporting}
|
||||
>
|
||||
{exporting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Exporting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4" />
|
||||
Export
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
{showDeleteDialog && selectedContact && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
@@ -860,6 +868,17 @@ function ContactsContent() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Modal */}
|
||||
{showImportModal && (
|
||||
<ContactImport
|
||||
onClose={() => setShowImportModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowImportModal(false)
|
||||
fetchContacts()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user