980 lines
40 KiB
TypeScript
980 lines
40 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect, useCallback } from 'react'
|
||
import ProtectedRoute from '@/components/ProtectedRoute'
|
||
import Modal from '@/components/Modal'
|
||
import LoadingSpinner from '@/components/LoadingSpinner'
|
||
import Link from 'next/link'
|
||
import { toast } from 'react-hot-toast'
|
||
import {
|
||
Users,
|
||
Plus,
|
||
Search,
|
||
Filter,
|
||
Mail,
|
||
Phone,
|
||
Building2,
|
||
Star,
|
||
Edit,
|
||
Trash2,
|
||
Eye,
|
||
Download,
|
||
Upload,
|
||
ArrowLeft,
|
||
UserPlus,
|
||
Briefcase,
|
||
Tag,
|
||
X,
|
||
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() {
|
||
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)
|
||
|
||
const [currentPage, setCurrentPage] = useState(1)
|
||
const [totalPages, setTotalPages] = useState(1)
|
||
const [total, setTotal] = useState(0)
|
||
const pageSize = 10
|
||
|
||
const [searchTerm, setSearchTerm] = useState('')
|
||
const [selectedType, setSelectedType] = useState('all')
|
||
const [selectedSpecialization, setSelectedSpecialization] = useState('all')
|
||
const [createDefaultType, setCreateDefaultType] = useState('INDIVIDUAL')
|
||
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)
|
||
|
||
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)
|
||
const [submitting, setSubmitting] = useState(false)
|
||
const [exporting, setExporting] = useState(false)
|
||
const [exportExcludeCompanyEmployees, setExportExcludeCompanyEmployees] = useState(false)
|
||
|
||
const fetchContacts = useCallback(async () => {
|
||
setLoading(true)
|
||
setError(null)
|
||
try {
|
||
const filters: ContactFilters = {
|
||
page: currentPage,
|
||
pageSize,
|
||
}
|
||
|
||
if (searchTerm) filters.search = searchTerm
|
||
if (selectedType !== 'all') filters.type = selectedType
|
||
if (selectedSpecialization !== 'all') filters.specialization = selectedSpecialization
|
||
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)
|
||
setTotal(data.total)
|
||
setTotalPages(data.totalPages)
|
||
} catch (err: any) {
|
||
setError(err.response?.data?.message || 'Failed to load contacts')
|
||
toast.error('Failed to load contacts')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [currentPage, searchTerm, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory, selectedSpecialization])
|
||
|
||
useEffect(() => {
|
||
const debounce = setTimeout(() => {
|
||
setCurrentPage(1)
|
||
fetchContacts()
|
||
}, 500)
|
||
return () => clearTimeout(debounce)
|
||
}, [searchTerm])
|
||
|
||
useEffect(() => {
|
||
fetchContacts()
|
||
}, [currentPage, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory, selectedSpecialization])
|
||
|
||
const handleCreate = async (data: CreateContactData) => {
|
||
setSubmitting(true)
|
||
try {
|
||
await contactsAPI.create(data)
|
||
toast.success('Contact created successfully!')
|
||
setShowCreateModal(false)
|
||
resetForm()
|
||
fetchContacts()
|
||
} catch (err: any) {
|
||
const message = err.response?.data?.message || 'Failed to create contact'
|
||
toast.error(message)
|
||
throw err
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
const handleEdit = async (data: UpdateContactData) => {
|
||
if (!selectedContact) return
|
||
|
||
setSubmitting(true)
|
||
try {
|
||
await contactsAPI.update(selectedContact.id, data)
|
||
toast.success('Contact updated successfully!')
|
||
setShowEditModal(false)
|
||
resetForm()
|
||
fetchContacts()
|
||
} catch (err: any) {
|
||
const message = err.response?.data?.message || 'Failed to update contact'
|
||
toast.error(message)
|
||
throw err
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
const handleDelete = async () => {
|
||
if (!selectedContact) return
|
||
|
||
setSubmitting(true)
|
||
try {
|
||
await contactsAPI.archive(selectedContact.id, 'Deleted by user')
|
||
toast.success('Contact deleted successfully!')
|
||
setShowDeleteDialog(false)
|
||
setSelectedContact(null)
|
||
fetchContacts()
|
||
} catch (err: any) {
|
||
const message = err.response?.data?.message || 'Failed to delete contact'
|
||
toast.error(message)
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
const resetForm = () => {
|
||
setSelectedContact(null)
|
||
}
|
||
|
||
const openEditModal = (contact: Contact) => {
|
||
setSelectedContact(contact)
|
||
setShowEditModal(true)
|
||
}
|
||
|
||
const openDeleteDialog = (contact: Contact) => {
|
||
setSelectedContact(contact)
|
||
setShowDeleteDialog(true)
|
||
}
|
||
|
||
const getTypeColor = (type: string) => {
|
||
const colors: Record<string, string> = {
|
||
INDIVIDUAL: 'bg-blue-100 text-blue-700',
|
||
COMPANY: 'bg-green-100 text-green-700',
|
||
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',
|
||
UN: 'bg-sky-100 text-sky-700',
|
||
NGO: 'bg-pink-100 text-pink-700',
|
||
INSTITUTION: 'bg-gray-100 text-gray-700'
|
||
}
|
||
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: 'فرد',
|
||
COMPANY: 'شركة',
|
||
HOLDING: 'مجموعة',
|
||
GOVERNMENT: 'حكومي',
|
||
ORGANIZATION: 'منظمات',
|
||
EMBASSIES: 'سفارات',
|
||
BANK: 'بنوك',
|
||
UNIVERSITY: 'جامعات',
|
||
SCHOOL: 'مدارس',
|
||
UN: 'UN',
|
||
NGO: 'NGO',
|
||
INSTITUTION: 'مؤسسة'
|
||
}
|
||
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 || ''
|
||
}
|
||
|
||
const supplierSpecializations = [
|
||
'كاميرات',
|
||
'شبكات',
|
||
'أجهزة كومبيوتر',
|
||
'projectors',
|
||
'مقاسم هاتفية',
|
||
' Mobile - Tablet',
|
||
'firewall',
|
||
'طاقة بديلة',
|
||
'حديد',
|
||
' باركود - POS',
|
||
'أجهزة منزلية',
|
||
'تكييف وتبريد',
|
||
]
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gray-50">
|
||
<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="flex items-center justify-between">
|
||
<div className="flex items-center gap-4">
|
||
<Link
|
||
href="/dashboard"
|
||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||
>
|
||
<ArrowLeft className="h-5 w-5 text-gray-600" />
|
||
</Link>
|
||
<div className="flex items-center gap-3">
|
||
<div className="bg-blue-100 p-2 rounded-lg">
|
||
<Users className="h-6 w-6 text-blue-600" />
|
||
</div>
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-gray-900">إدارة جهات الاتصال</h1>
|
||
<p className="text-sm text-gray-600">Contact Management</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
{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
|
||
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>
|
||
<button
|
||
onClick={() => {
|
||
resetForm()
|
||
setCreateDefaultType('SUPPLIER')
|
||
setShowCreateModal(true)
|
||
}}
|
||
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors"
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
إضافة موردين
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
resetForm()
|
||
setCreateDefaultType('INDIVIDUAL')
|
||
setShowCreateModal(true)
|
||
}}
|
||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
Add Contact
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-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="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm text-gray-600">Total Contacts</p>
|
||
<p className="text-3xl font-bold text-gray-900 mt-1">{total}</p>
|
||
</div>
|
||
<div className="bg-blue-100 p-3 rounded-lg">
|
||
<Users className="h-8 w-8 text-blue-600" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm text-gray-600">Active Individuals</p>
|
||
<p className="text-3xl font-bold text-gray-900 mt-1">
|
||
{contacts.filter(c => c.type === 'INDIVIDUAL' && c.status === 'ACTIVE').length}
|
||
</p>
|
||
</div>
|
||
<div className="bg-green-100 p-3 rounded-lg">
|
||
<UserPlus className="h-8 w-8 text-green-600" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm text-gray-600">Companies</p>
|
||
<p className="text-3xl font-bold text-gray-900 mt-1">
|
||
{contacts.filter(c => c.type === 'COMPANY').length}
|
||
</p>
|
||
</div>
|
||
<div className="bg-orange-100 p-3 rounded-lg">
|
||
<Star className="h-8 w-8 text-orange-600" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm text-gray-600">This Page</p>
|
||
<p className="text-3xl font-bold text-gray-900 mt-1">{contacts.length}</p>
|
||
</div>
|
||
<div className="bg-purple-100 p-3 rounded-lg">
|
||
<Briefcase className="h-8 w-8 text-purple-600" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200 mb-6">
|
||
<div className="space-y-4">
|
||
<div className="flex flex-col md:flex-row gap-4">
|
||
<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>
|
||
|
||
<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>
|
||
<option value="ORGANIZATION">Organizations</option>
|
||
<option value="EMBASSIES">Embassies</option>
|
||
<option value="BANK">Banks</option>
|
||
<option value="UNIVERSITY">Universities</option>
|
||
<option value="SCHOOL">Schools</option>
|
||
<option value="UN">UN</option>
|
||
<option value="NGO">NGO</option>
|
||
<option value="INSTITUTION">Institution</option>
|
||
<option value="SUPPLIER">Suppliers - موردين</option>
|
||
</select>
|
||
|
||
|
||
<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>
|
||
|
||
<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>
|
||
|
||
{showAdvancedFilters && (
|
||
<div className="pt-4 border-t border-gray-200">
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<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>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
اختصاص المورد
|
||
</label>
|
||
<select
|
||
value={selectedSpecialization}
|
||
onChange={(e) => setSelectedSpecialization(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">كل الاختصاصات</option>
|
||
{supplierSpecializations.map((item) => (
|
||
<option key={item} value={item}>
|
||
{item}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<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>
|
||
|
||
<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>
|
||
|
||
<div className="flex items-end">
|
||
<button
|
||
onClick={() => {
|
||
setSearchTerm('')
|
||
setSelectedType('all')
|
||
setSelectedStatus('all')
|
||
setSelectedSource('all')
|
||
setSelectedRating('all')
|
||
setSelectedCategory('all')
|
||
setCurrentPage(1)
|
||
setSelectedSpecialization('all')
|
||
}}
|
||
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>
|
||
|
||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||
{loading ? (
|
||
<div className="p-12">
|
||
<LoadingSpinner size="lg" message="Loading contacts..." />
|
||
</div>
|
||
) : error ? (
|
||
<div className="p-12 text-center">
|
||
<p className="text-red-600 mb-4">{error}</p>
|
||
<button
|
||
onClick={fetchContacts}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||
>
|
||
Retry
|
||
</button>
|
||
</div>
|
||
) : contacts.length === 0 ? (
|
||
<div className="p-12 text-center">
|
||
<Users className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||
<p className="text-gray-600 mb-4">No contacts found</p>
|
||
<button
|
||
onClick={() => setShowCreateModal(true)}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||
>
|
||
{createDefaultType === 'SUPPLIER' ? 'إضافة مورد' : 'Create New Contact'}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="overflow-x-auto">
|
||
<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">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">Status</th>
|
||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-200">
|
||
{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">
|
||
{getListCompanyName(contact) !== '-' ? (
|
||
<div className="flex items-center gap-3">
|
||
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-slate-600 to-slate-700 flex items-center justify-center text-white font-bold">
|
||
{getListCompanyName(contact).charAt(0).toUpperCase()}
|
||
</div>
|
||
<div>
|
||
<p className="font-semibold text-gray-900">
|
||
{getListCompanyName(contact)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<span className="text-sm text-gray-400">-</span>
|
||
)}
|
||
</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">
|
||
<div>
|
||
<p className="text-sm text-gray-900">
|
||
{getListContactName(contact)}
|
||
</p>
|
||
{getListContactNameAr(contact) && (
|
||
<p className="text-sm text-gray-600">
|
||
{getListContactNameAr(contact)}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</td>
|
||
|
||
<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>
|
||
</table>
|
||
</div>
|
||
|
||
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||
<p className="text-sm text-gray-600">
|
||
Showing <span className="font-semibold">{((currentPage - 1) * pageSize) + 1}</span> to{' '}
|
||
<span className="font-semibold">{Math.min(currentPage * pageSize, total)}</span> of{' '}
|
||
<span className="font-semibold">{total}</span> contacts
|
||
</p>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => setCurrentPage(currentPage - 1)}
|
||
disabled={currentPage === 1}
|
||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
Previous
|
||
</button>
|
||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||
const page = i + 1
|
||
return (
|
||
<button
|
||
key={page}
|
||
onClick={() => setCurrentPage(page)}
|
||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||
currentPage === page
|
||
? 'bg-blue-600 text-white'
|
||
: 'border border-gray-300 text-gray-700 hover:bg-gray-50'
|
||
}`}
|
||
>
|
||
{page}
|
||
</button>
|
||
)
|
||
})}
|
||
{totalPages > 5 && <span className="px-2">...</span>}
|
||
<button
|
||
onClick={() => setCurrentPage(currentPage + 1)}
|
||
disabled={currentPage === totalPages}
|
||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
Next
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</main>
|
||
|
||
<Modal
|
||
isOpen={showCreateModal}
|
||
onClose={() => {
|
||
setShowCreateModal(false)
|
||
resetForm()
|
||
}}
|
||
title="Create New Contact"
|
||
size="xl"
|
||
>
|
||
<ContactForm
|
||
key={`create-${createDefaultType}`}
|
||
defaultType={createDefaultType}
|
||
onSubmit={async (data) => {
|
||
await handleCreate(data as CreateContactData)
|
||
}}
|
||
onCancel={() => {
|
||
setShowCreateModal(false)
|
||
resetForm()
|
||
}}
|
||
submitting={submitting}
|
||
/>
|
||
</Modal>
|
||
|
||
<Modal
|
||
isOpen={showEditModal}
|
||
onClose={() => {
|
||
setShowEditModal(false)
|
||
resetForm()
|
||
}}
|
||
title="Edit Contact"
|
||
size="xl"
|
||
>
|
||
<ContactForm
|
||
key={selectedContact?.id || 'edit-contact'}
|
||
contact={selectedContact || undefined}
|
||
onSubmit={async (data) => {
|
||
await handleEdit(data as UpdateContactData)
|
||
}}
|
||
onCancel={() => {
|
||
setShowEditModal(false)
|
||
resetForm()
|
||
}}
|
||
submitting={submitting}
|
||
/>
|
||
</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>
|
||
)}
|
||
|
||
{showDeleteDialog && selectedContact && (
|
||
<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="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-red-100 p-3 rounded-full">
|
||
<Trash2 className="h-6 w-6 text-red-600" />
|
||
</div>
|
||
<div>
|
||
<h3 className="text-lg font-bold text-gray-900">Delete Contact</h3>
|
||
<p className="text-sm text-gray-600">This action cannot be undone</p>
|
||
</div>
|
||
</div>
|
||
<p className="text-gray-700 mb-6">
|
||
Are you sure you want to delete <span className="font-semibold">{selectedContact.name}</span>?
|
||
</p>
|
||
<div className="flex items-center justify-end gap-3">
|
||
<button
|
||
onClick={() => {
|
||
setShowDeleteDialog(false)
|
||
setSelectedContact(null)
|
||
}}
|
||
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
|
||
onClick={handleDelete}
|
||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
|
||
disabled={submitting}
|
||
>
|
||
{submitting ? (
|
||
<>
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
Deleting...
|
||
</>
|
||
) : (
|
||
'Delete Contact'
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{showImportModal && (
|
||
<ContactImport
|
||
onClose={() => setShowImportModal(false)}
|
||
onSuccess={() => {
|
||
setShowImportModal(false)
|
||
fetchContacts()
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function ContactsPage() {
|
||
return (
|
||
<ProtectedRoute>
|
||
<ContactsContent />
|
||
</ProtectedRoute>
|
||
)
|
||
} |