Deploy rule, CRM enhancements, Company Employee category, bilingual, contacts module completion

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Talal Sharabi
2026-02-19 14:59:34 +04:00
parent 0b126cb676
commit 680ba3871e
51 changed files with 11456 additions and 477 deletions

View File

@@ -0,0 +1,673 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { toast } from 'react-hot-toast'
import {
ArrowLeft,
Mail,
Phone,
Globe,
MapPin,
Building2,
User,
Calendar,
Tag,
Star,
Edit,
Archive,
History,
Download,
Copy,
CheckCircle,
XCircle,
Loader2,
FileText,
Users,
Briefcase,
Clock,
TrendingUp
} from 'lucide-react'
import ProtectedRoute from '@/components/ProtectedRoute'
import LoadingSpinner from '@/components/LoadingSpinner'
import ContactHistory from '@/components/contacts/ContactHistory'
import QuickActions from '@/components/contacts/QuickActions'
import RelationshipManager from '@/components/contacts/RelationshipManager'
import HierarchyTree from '@/components/contacts/HierarchyTree'
import { contactsAPI, Contact } from '@/lib/api/contacts'
function ContactDetailContent() {
const params = useParams()
const router = useRouter()
const contactId = params.id as string
const [contact, setContact] = useState<Contact | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'info' | 'company' | 'address' | 'categories' | 'relationships' | 'hierarchy' | 'activities' | 'history'>('info')
const [copiedField, setCopiedField] = useState<string | null>(null)
useEffect(() => {
fetchContact()
}, [contactId])
const fetchContact = async () => {
setLoading(true)
setError(null)
try {
const data = await contactsAPI.getById(contactId)
setContact(data)
} catch (err: any) {
const message = err.response?.data?.message || 'Failed to load contact'
setError(message)
toast.error(message)
} finally {
setLoading(false)
}
}
const copyToClipboard = (text: string, field: string) => {
navigator.clipboard.writeText(text)
setCopiedField(field)
toast.success(`${field} copied to clipboard`)
setTimeout(() => setCopiedField(null), 2000)
}
const handleArchive = async () => {
if (!contact) return
if (confirm(`Are you sure you want to archive ${contact.name}?`)) {
try {
await contactsAPI.archive(contactId, 'Archived by user')
toast.success('Contact archived successfully')
router.push('/contacts')
} catch (err: any) {
toast.error(err.response?.data?.message || 'Failed to archive contact')
}
}
}
const handleExport = async () => {
// TODO: Implement single contact export
toast.success('Export feature coming soon')
}
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'
}
return colors[type] || 'bg-gray-100 text-gray-700'
}
const getTypeLabel = (type: string) => {
const labels: Record<string, string> = {
INDIVIDUAL: 'فرد - Individual',
COMPANY: 'شركة - Company',
HOLDING: 'مجموعة - Holding',
GOVERNMENT: 'حكومي - Government'
}
return labels[type] || type
}
const renderStars = (rating?: number) => {
if (!rating) return <span className="text-gray-400 text-sm">No rating</span>
return (
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`h-5 w-5 ${star <= rating ? 'fill-yellow-400 text-yellow-400' : 'text-gray-300'}`}
/>
))}
</div>
)
}
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<LoadingSpinner size="lg" message="Loading contact details..." />
</div>
)
}
if (error || !contact) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<XCircle className="h-16 w-16 text-red-500 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-gray-900 mb-2">Contact Not Found</h2>
<p className="text-gray-600 mb-6">{error || 'This contact does not exist'}</p>
<Link
href="/contacts"
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<ArrowLeft className="h-4 w-4" />
Back to Contacts
</Link>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<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="/contacts"
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<ArrowLeft className="h-5 w-5 text-gray-600" />
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-gray-900">{contact.name}</h1>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getTypeColor(contact.type)}`}>
{getTypeLabel(contact.type)}
</span>
<span className={`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}
</span>
</div>
<p className="text-sm text-gray-600 mt-1">ID: {contact.uniqueContactId}</p>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => router.push(`/contacts?edit=${contactId}`)}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
<Edit className="h-4 w-4" />
Edit
</button>
<button
onClick={() => router.push(`/contacts/merge?sourceId=${contactId}`)}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
<Users className="h-4 w-4" />
Merge
</button>
<button
onClick={handleArchive}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
<Archive className="h-4 w-4" />
Archive
</button>
<button
onClick={() => setActiveTab('history')}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
<History className="h-4 w-4" />
History
</button>
<button
onClick={handleExport}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
<Download className="h-4 w-4" />
Export
</button>
</div>
</div>
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-sm text-gray-600 mt-4">
<Link href="/dashboard" className="hover:text-blue-600">Dashboard</Link>
<span>/</span>
<Link href="/contacts" className="hover:text-blue-600">Contacts</Link>
<span>/</span>
<span className="text-gray-900 font-medium">{contact.name}</span>
</nav>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Quick Actions Bar */}
<div className="mb-6">
<QuickActions contact={contact} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Avatar and Quick Info */}
<div className="lg:col-span-1">
<div className="bg-white rounded-xl shadow-sm border p-6">
{/* Avatar */}
<div className="text-center mb-6">
<div className="h-32 w-32 rounded-full bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white text-4xl font-bold mx-auto mb-4">
{contact.name.charAt(0)}
</div>
<h2 className="text-xl font-bold text-gray-900">{contact.name}</h2>
{contact.nameAr && (
<p className="text-gray-600 mt-1" dir="rtl">{contact.nameAr}</p>
)}
{contact.companyName && (
<p className="text-sm text-gray-500 mt-2 flex items-center justify-center gap-2">
<Building2 className="h-4 w-4" />
{contact.companyName}
</p>
)}
</div>
{/* Rating */}
<div className="mb-6 pb-6 border-b">
<label className="text-sm font-medium text-gray-700 block mb-2">Rating</label>
{renderStars(contact.rating)}
</div>
{/* Quick Actions */}
<div className="space-y-2">
{contact.email && (
<button
onClick={() => copyToClipboard(contact.email!, 'Email')}
className="w-full flex items-center gap-3 px-4 py-3 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
<Mail className="h-5 w-5 text-gray-600" />
<span className="flex-1 text-left text-sm">{contact.email}</span>
{copiedField === 'Email' ? (
<CheckCircle className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4 text-gray-400" />
)}
</button>
)}
{contact.phone && (
<button
onClick={() => copyToClipboard(contact.phone!, 'Phone')}
className="w-full flex items-center gap-3 px-4 py-3 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
<Phone className="h-5 w-5 text-gray-600" />
<span className="flex-1 text-left text-sm">{contact.phone}</span>
{copiedField === 'Phone' ? (
<CheckCircle className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4 text-gray-400" />
)}
</button>
)}
{contact.mobile && (
<button
onClick={() => copyToClipboard(contact.mobile!, 'Mobile')}
className="w-full flex items-center gap-3 px-4 py-3 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
<Phone className="h-5 w-5 text-gray-600" />
<span className="flex-1 text-left text-sm">{contact.mobile}</span>
{copiedField === 'Mobile' ? (
<CheckCircle className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4 text-gray-400" />
)}
</button>
)}
{contact.website && (
<a
href={contact.website.startsWith('http') ? contact.website : `https://${contact.website}`}
target="_blank"
rel="noopener noreferrer"
className="w-full flex items-center gap-3 px-4 py-3 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
<Globe className="h-5 w-5 text-gray-600" />
<span className="flex-1 text-left text-sm">{contact.website}</span>
</a>
)}
</div>
{/* Metadata */}
<div className="mt-6 pt-6 border-t space-y-2">
<div className="flex items-center gap-2 text-sm text-gray-600">
<Calendar className="h-4 w-4" />
<span>Created: {new Date(contact.createdAt).toLocaleDateString()}</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<Clock className="h-4 w-4" />
<span>Updated: {new Date(contact.updatedAt).toLocaleDateString()}</span>
</div>
{contact.createdBy && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<User className="h-4 w-4" />
<span>By: {contact.createdBy.name}</span>
</div>
)}
</div>
</div>
</div>
{/* Right Column - Tabbed Content */}
<div className="lg:col-span-2">
{/* Tabs */}
<div className="bg-white rounded-t-xl shadow-sm border-x border-t">
<div className="flex overflow-x-auto">
{[
{ id: 'info', label: 'Contact Info', icon: User },
{ id: 'company', label: 'Company', icon: Building2 },
{ id: 'address', label: 'Address', icon: MapPin },
{ id: 'categories', label: 'Categories & Tags', icon: Tag },
{ id: 'relationships', label: 'Relationships', icon: Users },
...((contact.type === 'COMPANY' || contact.type === 'HOLDING')
? [{ id: 'hierarchy', label: 'Hierarchy', icon: Building2 }]
: []
),
{ id: 'activities', label: 'Activities', icon: TrendingUp },
{ id: 'history', label: 'History', icon: History }
].map((tab) => {
const Icon = tab.icon
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex items-center gap-2 px-6 py-4 border-b-2 transition-colors whitespace-nowrap ${
activeTab === tab.id
? 'border-blue-600 text-blue-600 bg-blue-50'
: 'border-transparent text-gray-600 hover:text-gray-900 hover:bg-gray-50'
}`}
>
<Icon className="h-4 w-4" />
<span className="font-medium text-sm">{tab.label}</span>
</button>
)
})}
</div>
</div>
{/* Tab Content */}
<div className="bg-white rounded-b-xl shadow-sm border-x border-b p-6">
{/* Contact Info Tab */}
{activeTab === 'info' && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Contact Information</h3>
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-gray-500">Name</dt>
<dd className="mt-1 text-sm text-gray-900">{contact.name}</dd>
</div>
{contact.nameAr && (
<div>
<dt className="text-sm font-medium text-gray-500">Arabic Name</dt>
<dd className="mt-1 text-sm text-gray-900" dir="rtl">{contact.nameAr}</dd>
</div>
)}
<div>
<dt className="text-sm font-medium text-gray-500">Type</dt>
<dd className="mt-1 text-sm text-gray-900">{getTypeLabel(contact.type)}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Source</dt>
<dd className="mt-1 text-sm text-gray-900">{contact.source}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Status</dt>
<dd className="mt-1">
<span className={`inline-flex px-2 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}
</span>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Rating</dt>
<dd className="mt-1">{renderStars(contact.rating)}</dd>
</div>
</dl>
</div>
{/* Contact Methods */}
<div className="pt-6 border-t">
<h4 className="text-md font-semibold text-gray-900 mb-4">Contact Methods</h4>
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
{contact.email && (
<div>
<dt className="text-sm font-medium text-gray-500">Email</dt>
<dd className="mt-1 text-sm text-gray-900">{contact.email}</dd>
</div>
)}
{contact.phone && (
<div>
<dt className="text-sm font-medium text-gray-500">Phone</dt>
<dd className="mt-1 text-sm text-gray-900">{contact.phone}</dd>
</div>
)}
{contact.mobile && (
<div>
<dt className="text-sm font-medium text-gray-500">Mobile</dt>
<dd className="mt-1 text-sm text-gray-900">{contact.mobile}</dd>
</div>
)}
{contact.website && (
<div>
<dt className="text-sm font-medium text-gray-500">Website</dt>
<dd className="mt-1 text-sm text-blue-600">
<a
href={contact.website.startsWith('http') ? contact.website : `https://${contact.website}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{contact.website}
</a>
</dd>
</div>
)}
</dl>
</div>
</div>
)}
{/* Company Tab */}
{activeTab === 'company' && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Company Information</h3>
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
{contact.companyName && (
<div>
<dt className="text-sm font-medium text-gray-500">Company Name</dt>
<dd className="mt-1 text-sm text-gray-900">{contact.companyName}</dd>
</div>
)}
{contact.companyNameAr && (
<div>
<dt className="text-sm font-medium text-gray-500">Arabic Company Name</dt>
<dd className="mt-1 text-sm text-gray-900" dir="rtl">{contact.companyNameAr}</dd>
</div>
)}
{contact.taxNumber && (
<div>
<dt className="text-sm font-medium text-gray-500">Tax Number</dt>
<dd className="mt-1 text-sm text-gray-900 font-mono">{contact.taxNumber}</dd>
</div>
)}
{contact.commercialRegister && (
<div>
<dt className="text-sm font-medium text-gray-500">Commercial Register</dt>
<dd className="mt-1 text-sm text-gray-900 font-mono">{contact.commercialRegister}</dd>
</div>
)}
</dl>
</div>
{/* Parent Company */}
{contact.parent && (
<div className="pt-6 border-t">
<h4 className="text-md font-semibold text-gray-900 mb-4">Parent Company</h4>
<Link
href={`/contacts/${contact.parent.id}`}
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
<Building2 className="h-8 w-8 text-gray-400" />
<div>
<p className="font-medium text-gray-900">{contact.parent.name}</p>
<p className="text-sm text-gray-500">{contact.parent.type}</p>
</div>
</Link>
</div>
)}
{!contact.companyName && !contact.taxNumber && !contact.commercialRegister && !contact.parent && (
<div className="text-center py-8 text-gray-500">
<Building2 className="h-12 w-12 mx-auto mb-2 text-gray-300" />
<p>No company information available</p>
</div>
)}
</div>
)}
{/* Address Tab */}
{activeTab === 'address' && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Address Information</h3>
<dl className="space-y-4">
{contact.address && (
<div>
<dt className="text-sm font-medium text-gray-500">Street Address</dt>
<dd className="mt-1 text-sm text-gray-900">{contact.address}</dd>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{contact.city && (
<div>
<dt className="text-sm font-medium text-gray-500">City</dt>
<dd className="mt-1 text-sm text-gray-900">{contact.city}</dd>
</div>
)}
{contact.country && (
<div>
<dt className="text-sm font-medium text-gray-500">Country</dt>
<dd className="mt-1 text-sm text-gray-900">{contact.country}</dd>
</div>
)}
{contact.postalCode && (
<div>
<dt className="text-sm font-medium text-gray-500">Postal Code</dt>
<dd className="mt-1 text-sm text-gray-900">{contact.postalCode}</dd>
</div>
)}
</div>
</dl>
</div>
{!contact.address && !contact.city && !contact.country && !contact.postalCode && (
<div className="text-center py-8 text-gray-500">
<MapPin className="h-12 w-12 mx-auto mb-2 text-gray-300" />
<p>No address information available</p>
</div>
)}
{/* Map placeholder */}
{contact.address && (
<div className="pt-6 border-t">
<div className="bg-gray-100 rounded-lg h-64 flex items-center justify-center">
<div className="text-center text-gray-500">
<MapPin className="h-12 w-12 mx-auto mb-2" />
<p>Map integration coming soon</p>
</div>
</div>
</div>
)}
</div>
)}
{/* Categories & Tags Tab */}
{activeTab === 'categories' && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Categories</h3>
{contact.categories && contact.categories.length > 0 ? (
<div className="flex flex-wrap gap-2">
{contact.categories.map((category: any, index: number) => (
<span
key={index}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-100 text-blue-700 rounded-lg"
>
<Tag className="h-4 w-4" />
{category.name || category}
</span>
))}
</div>
) : (
<p className="text-gray-500">No categories assigned</p>
)}
</div>
<div className="pt-6 border-t">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Tags</h3>
{contact.tags && contact.tags.length > 0 ? (
<div className="flex flex-wrap gap-2">
{contact.tags.map((tag, index) => (
<span
key={index}
className="inline-flex items-center px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm"
>
#{tag}
</span>
))}
</div>
) : (
<p className="text-gray-500">No tags assigned</p>
)}
</div>
</div>
)}
{/* Relationships Tab */}
{activeTab === 'relationships' && (
<div>
<RelationshipManager contactId={contactId} />
</div>
)}
{/* Hierarchy Tab */}
{activeTab === 'hierarchy' && (contact.type === 'COMPANY' || contact.type === 'HOLDING') && (
<div>
<HierarchyTree rootContactId={contactId} />
</div>
)}
{/* Activities Tab */}
{activeTab === 'activities' && (
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Activity Timeline</h3>
{/* Placeholder for activities */}
<div className="text-center py-12 text-gray-500">
<TrendingUp className="h-12 w-12 mx-auto mb-2 text-gray-300" />
<p className="mb-2">No activities found</p>
<p className="text-sm">Activity timeline coming soon</p>
</div>
</div>
)}
{/* History Tab */}
{activeTab === 'history' && (
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Contact History</h3>
<ContactHistory contactId={contactId} />
</div>
)}
</div>
</div>
</div>
</main>
</div>
)
}
export default function ContactDetailPage() {
return (
<ProtectedRoute>
<ContactDetailContent />
</ProtectedRoute>
)
}

View File

@@ -0,0 +1,638 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { toast } from 'react-hot-toast'
import {
ArrowLeft,
Search,
Check,
X,
AlertTriangle,
ChevronRight,
Loader2,
User,
Building2,
Mail,
Phone,
Globe,
MapPin,
Calendar,
Star,
Tag as TagIcon
} from 'lucide-react'
import ProtectedRoute from '@/components/ProtectedRoute'
import { contactsAPI, Contact } from '@/lib/api/contacts'
import { format } from 'date-fns'
import Link from 'next/link'
type MergeStep = 'select' | 'compare' | 'preview' | 'confirm' | 'success'
interface FieldChoice {
[key: string]: 'source' | 'target' | 'custom'
}
function MergeContent() {
const router = useRouter()
const searchParams = useSearchParams()
const preSelectedSourceId = searchParams?.get('sourceId')
const [step, setStep] = useState<MergeStep>('select')
const [searchTerm, setSearchTerm] = useState('')
const [searchResults, setSearchResults] = useState<Contact[]>([])
const [searching, setSearching] = useState(false)
const [sourceContact, setSourceContact] = useState<Contact | null>(null)
const [targetContact, setTargetContact] = useState<Contact | null>(null)
const [fieldChoices, setFieldChoices] = useState<FieldChoice>({})
const [mergedData, setMergedData] = useState<any>({})
const [reason, setReason] = useState('')
const [merging, setMerging] = useState(false)
const [mergedContactId, setMergedContactId] = useState<string | null>(null)
// Load pre-selected source contact
useEffect(() => {
if (preSelectedSourceId) {
contactsAPI.getById(preSelectedSourceId).then(contact => {
setSourceContact(contact)
}).catch(error => {
toast.error('Failed to load pre-selected contact')
})
}
}, [preSelectedSourceId])
// Search contacts with debouncing
useEffect(() => {
if (!searchTerm || searchTerm.length < 2) {
setSearchResults([])
return
}
const debounce = setTimeout(async () => {
setSearching(true)
try {
const data = await contactsAPI.getAll({ search: searchTerm, pageSize: 10 })
setSearchResults(data.contacts.filter(c =>
c.id !== sourceContact?.id && c.id !== targetContact?.id
))
} catch (error) {
console.error('Search error:', error)
} finally {
setSearching(false)
}
}, 500)
return () => clearTimeout(debounce)
}, [searchTerm, sourceContact, targetContact])
// Initialize field choices with smart defaults
const initializeFieldChoices = useCallback(() => {
if (!sourceContact || !targetContact) return
const choices: FieldChoice = {}
const fields = [
'type', 'name', 'nameAr', 'email', 'phone', 'mobile', 'website',
'companyName', 'companyNameAr', 'taxNumber', 'commercialRegister',
'address', 'city', 'country', 'postalCode', 'rating', 'tags'
]
fields.forEach(field => {
const sourceValue = (sourceContact as any)[field]
const targetValue = (targetContact as any)[field]
// Prefer non-empty values
if (sourceValue && !targetValue) {
choices[field] = 'source'
} else if (!sourceValue && targetValue) {
choices[field] = 'target'
} else if (sourceValue && targetValue) {
// Prefer newer data
choices[field] = new Date(sourceContact.createdAt) > new Date(targetContact.createdAt)
? 'source'
: 'target'
} else {
choices[field] = 'source'
}
})
setFieldChoices(choices)
}, [sourceContact, targetContact])
useEffect(() => {
if (step === 'compare' && sourceContact && targetContact) {
initializeFieldChoices()
}
}, [step, sourceContact, targetContact, initializeFieldChoices])
// Generate merged data preview
useEffect(() => {
if (!sourceContact || !targetContact || Object.keys(fieldChoices).length === 0) return
const merged: any = {}
Object.keys(fieldChoices).forEach(field => {
const choice = fieldChoices[field]
if (choice === 'source') {
merged[field] = (sourceContact as any)[field]
} else if (choice === 'target') {
merged[field] = (targetContact as any)[field]
}
})
setMergedData(merged)
}, [fieldChoices, sourceContact, targetContact])
const handleSelectContact = (contact: Contact, type: 'source' | 'target') => {
if (type === 'source') {
setSourceContact(contact)
} else {
setTargetContact(contact)
}
setSearchTerm('')
setSearchResults([])
}
const handleFieldChoice = (field: string, choice: 'source' | 'target') => {
setFieldChoices(prev => ({ ...prev, [field]: choice }))
}
const handleMerge = async () => {
if (!sourceContact || !targetContact || !reason.trim()) {
toast.error('Please provide a reason for merging')
return
}
setMerging(true)
try {
const result = await contactsAPI.merge(sourceContact.id, targetContact.id, reason)
setMergedContactId(result.id)
setStep('success')
toast.success('Contacts merged successfully!')
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to merge contacts')
} finally {
setMerging(false)
}
}
const renderFieldValue = (value: any) => {
if (Array.isArray(value)) {
return value.length > 0 ? value.join(', ') : <span className="text-gray-400">Empty</span>
}
if (value === null || value === undefined || value === '') {
return <span className="text-gray-400">Empty</span>
}
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No'
}
return value
}
const ContactCard = ({ contact, type, onRemove }: { contact: Contact, type: 'source' | 'target', onRemove: () => void }) => (
<div className="border-2 border-blue-200 bg-blue-50 rounded-lg p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
{contact.type === 'INDIVIDUAL' ? <User size={20} className="text-blue-600" /> : <Building2 size={20} className="text-blue-600" />}
<div>
<h4 className="font-semibold text-gray-900">{contact.name}</h4>
<p className="text-sm text-gray-600">{contact.uniqueContactId}</p>
</div>
</div>
<button
onClick={onRemove}
className="text-gray-400 hover:text-red-600"
>
<X size={18} />
</button>
</div>
<div className="space-y-1 text-sm">
{contact.email && (
<div className="flex items-center gap-2 text-gray-700">
<Mail size={14} />
<span>{contact.email}</span>
</div>
)}
{contact.phone && (
<div className="flex items-center gap-2 text-gray-700">
<Phone size={14} />
<span>{contact.phone}</span>
</div>
)}
{contact.city && (
<div className="flex items-center gap-2 text-gray-700">
<MapPin size={14} />
<span>{contact.city}, {contact.country}</span>
</div>
)}
<div className="flex items-center gap-2 text-gray-500 text-xs pt-2">
<Calendar size={12} />
<span>Created {format(new Date(contact.createdAt), 'MMM d, yyyy')}</span>
</div>
</div>
</div>
)
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center gap-4">
<Link
href="/contacts"
className="text-gray-600 hover:text-gray-900"
>
<ArrowLeft size={24} />
</Link>
<div>
<h1 className="text-2xl font-bold text-gray-900">
دمج جهات الاتصال - Merge Contacts
</h1>
<p className="text-sm text-gray-600 mt-1">
{step === 'select' && 'Select two contacts to merge'}
{step === 'compare' && 'Choose which data to keep'}
{step === 'preview' && 'Preview merged contact'}
{step === 'confirm' && 'Confirm merge'}
{step === 'success' && 'Merge completed'}
</p>
</div>
</div>
</div>
</div>
{/* Progress Steps */}
<div className="bg-white 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">
{[
{ key: 'select', label: 'Select Contacts' },
{ key: 'compare', label: 'Compare Fields' },
{ key: 'preview', label: 'Preview' },
{ key: 'confirm', label: 'Confirm' }
].map((s, idx) => (
<div key={s.key} className="flex items-center">
<div className={`flex items-center gap-2 ${
step === s.key ? 'text-blue-600' :
['select', 'compare', 'preview', 'confirm'].indexOf(step) > idx ? 'text-green-600' : 'text-gray-400'
}`}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
step === s.key ? 'bg-blue-600 text-white' :
['select', 'compare', 'preview', 'confirm'].indexOf(step) > idx ? 'bg-green-600 text-white' : 'bg-gray-200'
}`}>
{['select', 'compare', 'preview', 'confirm'].indexOf(step) > idx ? <Check size={16} /> : idx + 1}
</div>
<span className="font-medium hidden sm:inline">{s.label}</span>
</div>
{idx < 3 && <ChevronRight className="mx-4 text-gray-400" size={20} />}
</div>
))}
</div>
</div>
</div>
{/* Content */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Step 1: Select Contacts */}
{step === 'select' && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Source Contact */}
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Source Contact (will be archived)
</h3>
{sourceContact ? (
<ContactCard
contact={sourceContact}
type="source"
onRemove={() => setSourceContact(null)}
/>
) : (
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
<p className="text-gray-600">Search and select a contact</p>
</div>
)}
</div>
{/* Target Contact */}
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Target Contact (will be kept)
</h3>
{targetContact ? (
<ContactCard
contact={targetContact}
type="target"
onRemove={() => setTargetContact(null)}
/>
) : (
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
<p className="text-gray-600">Search and select a contact</p>
</div>
)}
</div>
</div>
{/* Search */}
<div className="bg-white border rounded-lg p-6">
<div className="relative">
<Search className="absolute left-3 top-3 text-gray-400" size={20} />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search contacts by name, email, or phone..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900"
/>
</div>
{searching && (
<div className="mt-4 text-center text-gray-600">
<Loader2 className="inline animate-spin mr-2" size={20} />
Searching...
</div>
)}
{searchResults.length > 0 && (
<div className="mt-4 space-y-2 max-h-96 overflow-y-auto">
{searchResults.map(contact => (
<button
key={contact.id}
onClick={() => {
if (!sourceContact) {
handleSelectContact(contact, 'source')
} else if (!targetContact) {
handleSelectContact(contact, 'target')
} else {
toast.error('Both contacts are already selected')
}
}}
className="w-full text-left p-3 border border-gray-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{contact.type === 'INDIVIDUAL' ? <User size={18} /> : <Building2 size={18} />}
<div>
<p className="font-medium text-gray-900">{contact.name}</p>
<p className="text-sm text-gray-600">{contact.email || contact.phone || contact.uniqueContactId}</p>
</div>
</div>
<span className="text-xs bg-gray-100 px-2 py-1 rounded">{contact.type}</span>
</div>
</button>
))}
</div>
)}
</div>
{/* Navigation */}
<div className="flex justify-between">
<Link
href="/contacts"
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Cancel
</Link>
<button
onClick={() => setStep('compare')}
disabled={!sourceContact || !targetContact}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed"
>
Next: Compare Fields
</button>
</div>
</div>
)}
{/* Step 2: Compare Fields */}
{step === 'compare' && sourceContact && targetContact && (
<div className="space-y-6">
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 flex items-start gap-3">
<AlertTriangle className="text-yellow-600 flex-shrink-0 mt-0.5" size={20} />
<div className="text-sm text-yellow-800">
<p className="font-semibold mb-1">Choose which data to keep</p>
<p>Select the value you want to keep for each field. Smart defaults are pre-selected based on data quality.</p>
</div>
</div>
<div className="bg-white border rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Field
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Source Contact
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Target Contact
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{Object.keys(fieldChoices).map(field => {
const sourceValue = (sourceContact as any)[field]
const targetValue = (targetContact as any)[field]
const isDifferent = JSON.stringify(sourceValue) !== JSON.stringify(targetValue)
return (
<tr key={field} className={isDifferent ? 'bg-yellow-50' : ''}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{field.charAt(0).toUpperCase() + field.slice(1).replace(/([A-Z])/g, ' $1')}
</td>
<td className="px-6 py-4 text-sm text-gray-900">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name={field}
checked={fieldChoices[field] === 'source'}
onChange={() => handleFieldChoice(field, 'source')}
className="w-4 h-4 text-blue-600"
/>
<span className={fieldChoices[field] === 'source' ? 'font-semibold' : ''}>
{renderFieldValue(sourceValue)}
</span>
</label>
</td>
<td className="px-6 py-4 text-sm text-gray-900">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name={field}
checked={fieldChoices[field] === 'target'}
onChange={() => handleFieldChoice(field, 'target')}
className="w-4 h-4 text-blue-600"
/>
<span className={fieldChoices[field] === 'target' ? 'font-semibold' : ''}>
{renderFieldValue(targetValue)}
</span>
</label>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{/* Navigation */}
<div className="flex justify-between">
<button
onClick={() => setStep('select')}
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Back
</button>
<button
onClick={() => setStep('preview')}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Next: Preview
</button>
</div>
</div>
)}
{/* Step 3: Preview */}
{step === 'preview' && (
<div className="space-y-6">
<div className="bg-white border rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Merged Contact Preview</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.keys(mergedData).map(field => (
<div key={field} className="border-b border-gray-200 pb-2">
<p className="text-sm text-gray-600">
{field.charAt(0).toUpperCase() + field.slice(1).replace(/([A-Z])/g, ' $1')}
</p>
<p className="text-base text-gray-900 font-medium">
{renderFieldValue(mergedData[field])}
</p>
</div>
))}
</div>
</div>
{/* Navigation */}
<div className="flex justify-between">
<button
onClick={() => setStep('compare')}
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Back
</button>
<button
onClick={() => setStep('confirm')}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Next: Confirm
</button>
</div>
</div>
)}
{/* Step 4: Confirm */}
{step === 'confirm' && (
<div className="space-y-6">
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-6">
<div className="flex items-start gap-3">
<AlertTriangle className="text-red-600 flex-shrink-0 mt-1" size={24} />
<div>
<h3 className="text-lg font-semibold text-red-900 mb-2">
This action cannot be undone!
</h3>
<p className="text-sm text-red-800 mb-4">
The source contact will be archived and all its data will be merged into the target contact.
Relationships, activities, and history will be transferred.
</p>
<div className="space-y-2 text-sm">
<p>
<span className="font-semibold">Source (will be archived):</span> {sourceContact?.name}
</p>
<p>
<span className="font-semibold">Target (will be kept):</span> {targetContact?.name}
</p>
</div>
</div>
</div>
</div>
<div className="bg-white border rounded-lg p-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Reason for Merge <span className="text-red-500">*</span>
</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Explain why these contacts are being merged..."
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900"
required
/>
</div>
{/* Navigation */}
<div className="flex justify-between">
<button
onClick={() => setStep('preview')}
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
disabled={merging}
>
Back
</button>
<button
onClick={handleMerge}
disabled={!reason.trim() || merging}
className="flex items-center gap-2 px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:bg-gray-300 disabled:cursor-not-allowed"
>
{merging ? (
<>
<Loader2 className="animate-spin" size={18} />
Merging...
</>
) : (
'Merge Contacts'
)}
</button>
</div>
</div>
)}
{/* Step 5: Success */}
{step === 'success' && mergedContactId && (
<div className="max-w-2xl mx-auto text-center py-12">
<div className="bg-green-100 rounded-full w-20 h-20 flex items-center justify-center mx-auto mb-6">
<Check className="text-green-600" size={40} />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-4">
تم دمج جهات الاتصال بنجاح - Contacts Merged Successfully!
</h2>
<p className="text-gray-600 mb-8">
The contacts have been merged and the source contact has been archived.
</p>
<div className="flex items-center justify-center gap-4">
<Link
href="/contacts"
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Back to Contacts
</Link>
<Link
href={`/contacts/${mergedContactId}`}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
View Merged Contact
</Link>
</div>
</div>
)}
</div>
</div>
)
}
export default function MergePage() {
return (
<ProtectedRoute>
<MergeContent />
</ProtectedRoute>
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,540 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { toast } from 'react-hot-toast'
import {
ArrowLeft,
Edit,
Archive,
History,
Award,
TrendingDown,
DollarSign,
Target,
Calendar,
User,
Building2,
FileText,
Clock,
Loader2
} from 'lucide-react'
import ProtectedRoute from '@/components/ProtectedRoute'
import LoadingSpinner from '@/components/LoadingSpinner'
import { dealsAPI, Deal } from '@/lib/api/deals'
import { quotesAPI, Quote } from '@/lib/api/quotes'
import { useLanguage } from '@/contexts/LanguageContext'
function DealDetailContent() {
const params = useParams()
const router = useRouter()
const dealId = params.id as string
const { t } = useLanguage()
const [deal, setDeal] = useState<Deal | null>(null)
const [quotes, setQuotes] = useState<Quote[]>([])
const [history, setHistory] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'info' | 'quotes' | 'history'>('info')
const [showWinDialog, setShowWinDialog] = useState(false)
const [showLoseDialog, setShowLoseDialog] = useState(false)
const [winData, setWinData] = useState({ actualValue: 0, wonReason: '' })
const [loseData, setLoseData] = useState({ lostReason: '' })
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
fetchDeal()
}, [dealId])
useEffect(() => {
if (deal) {
fetchQuotes()
fetchHistory()
}
}, [deal])
const fetchDeal = async () => {
setLoading(true)
setError(null)
try {
const data = await dealsAPI.getById(dealId)
setDeal(data)
} catch (err: any) {
const message = err.response?.data?.message || 'Failed to load deal'
setError(message)
toast.error(message)
} finally {
setLoading(false)
}
}
const fetchQuotes = async () => {
try {
const data = await quotesAPI.getByDeal(dealId)
setQuotes(data || [])
} catch {
setQuotes([])
}
}
const fetchHistory = async () => {
try {
const data = await dealsAPI.getHistory(dealId)
setHistory(data || [])
} catch {
setHistory([])
}
}
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
ACTIVE: 'bg-green-100 text-green-700',
WON: 'bg-blue-100 text-blue-700',
LOST: 'bg-red-100 text-red-700'
}
return colors[status] || 'bg-gray-100 text-gray-700'
}
const getStructureLabel = (structure: string) => {
const labels: Record<string, string> = {
B2B: 'B2B',
B2C: 'B2C',
B2G: 'B2G',
PARTNERSHIP: 'Partnership'
}
return labels[structure] || structure
}
const formatDate = (dateStr?: string) => {
if (!dateStr) return '—'
return new Date(dateStr).toLocaleDateString()
}
const handleWin = async () => {
if (!deal || !winData.actualValue || !winData.wonReason) {
toast.error(t('crm.fixFormErrors'))
return
}
setSubmitting(true)
try {
await dealsAPI.win(deal.id, winData.actualValue, winData.wonReason)
toast.success(t('crm.winSuccess'))
setShowWinDialog(false)
setWinData({ actualValue: 0, wonReason: '' })
fetchDeal()
} catch (err: any) {
toast.error(err.response?.data?.message || 'Failed to mark as won')
} finally {
setSubmitting(false)
}
}
const handleLose = async () => {
if (!deal || !loseData.lostReason) {
toast.error(t('crm.fixFormErrors'))
return
}
setSubmitting(true)
try {
await dealsAPI.lose(deal.id, loseData.lostReason)
toast.success(t('crm.loseSuccess'))
setShowLoseDialog(false)
setLoseData({ lostReason: '' })
fetchDeal()
} catch (err: any) {
toast.error(err.response?.data?.message || 'Failed to mark as lost')
} finally {
setSubmitting(false)
}
}
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<LoadingSpinner size="lg" message={t('crm.loadingDeals')} />
</div>
)
}
if (error || !deal) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<p className="text-2xl font-bold text-gray-900 mb-2">{error || 'Deal not found'}</p>
<Link
href="/crm"
className="inline-flex items-center gap-2 px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700"
>
<ArrowLeft className="h-4 w-4" />
{t('common.back')} {t('nav.crm')}
</Link>
</div>
</div>
)
}
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="/crm"
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<ArrowLeft className="h-5 w-5 text-gray-600" />
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-gray-900">{deal.name}</h1>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(deal.status)}`}>
{deal.status}
</span>
<span className="px-3 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-700">
{getStructureLabel(deal.structure)} - {deal.stage}
</span>
</div>
<p className="text-sm text-gray-600 mt-1">{deal.dealNumber}</p>
</div>
</div>
<div className="flex items-center gap-3">
{deal.status === 'ACTIVE' && (
<>
<button
onClick={() => {
setWinData({ actualValue: deal.estimatedValue, wonReason: '' })
setShowWinDialog(true)
}}
className="flex items-center gap-2 px-4 py-2 border border-green-300 text-green-700 rounded-lg hover:bg-green-50"
>
<Award className="h-4 w-4" />
{t('crm.win')}
</button>
<button
onClick={() => {
setLoseData({ lostReason: '' })
setShowLoseDialog(true)
}}
className="flex items-center gap-2 px-4 py-2 border border-red-300 text-red-700 rounded-lg hover:bg-red-50"
>
<TrendingDown className="h-4 w-4" />
{t('crm.lose')}
</button>
</>
)}
<button
onClick={() => router.push(`/crm?edit=${dealId}`)}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
<Edit className="h-4 w-4" />
{t('common.edit')}
</button>
<button
onClick={() => setActiveTab('history')}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
<History className="h-4 w-4" />
{t('crm.history')}
</button>
</div>
</div>
<nav className="flex items-center gap-2 text-sm text-gray-600 mt-4">
<Link href="/dashboard" className="hover:text-green-600">{t('nav.dashboard')}</Link>
<span>/</span>
<Link href="/crm" className="hover:text-green-600">{t('nav.crm')}</Link>
<span>/</span>
<span className="text-gray-900 font-medium">{deal.name}</span>
</nav>
</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 lg:grid-cols-3 gap-6">
<div className="lg:col-span-1">
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="text-center mb-6">
<div className="h-24 w-24 rounded-full bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center text-white text-3xl font-bold mx-auto">
{deal.name.charAt(0)}
</div>
<h2 className="mt-3 font-semibold text-gray-900">{deal.name}</h2>
<span className={`inline-block mt-2 px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(deal.status)}`}>
{deal.status}
</span>
</div>
</div>
</div>
<div className="lg:col-span-2">
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<div className="border-b border-gray-200">
<nav className="flex gap-4 px-6">
{(['info', 'quotes', 'history'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === tab
? 'border-green-600 text-green-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{tab === 'info' ? t('crm.dealInfo') : tab === 'quotes' ? t('crm.quotes') : t('crm.history')}
</button>
))}
</nav>
</div>
<div className="p-6">
{activeTab === 'info' && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="flex items-start gap-3">
<User className="h-5 w-5 text-gray-400 mt-0.5" />
<div>
<p className="text-sm text-gray-500">{t('crm.contact')}</p>
<Link
href={`/contacts/${deal.contactId}`}
className="font-medium text-green-600 hover:underline"
>
{deal.contact?.name || '—'}
</Link>
</div>
</div>
<div className="flex items-start gap-3">
<Target className="h-5 w-5 text-gray-400 mt-0.5" />
<div>
<p className="text-sm text-gray-500">{t('crm.stage')}</p>
<p className="font-medium text-gray-900">{deal.stage}</p>
</div>
</div>
<div className="flex items-start gap-3">
<DollarSign className="h-5 w-5 text-gray-400 mt-0.5" />
<div>
<p className="text-sm text-gray-500">{t('crm.estimatedValue')}</p>
<p className="font-medium text-gray-900">
{deal.estimatedValue?.toLocaleString() || 0} SAR
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Target className="h-5 w-5 text-gray-400 mt-0.5" />
<div>
<p className="text-sm text-gray-500">{t('crm.probability')}</p>
<p className="font-medium text-gray-900">{deal.probability || 0}%</p>
</div>
</div>
<div className="flex items-start gap-3">
<Calendar className="h-5 w-5 text-gray-400 mt-0.5" />
<div>
<p className="text-sm text-gray-500">{t('crm.expectedCloseDate')}</p>
<p className="font-medium text-gray-900">{formatDate(deal.expectedCloseDate)}</p>
</div>
</div>
<div className="flex items-start gap-3">
<User className="h-5 w-5 text-gray-400 mt-0.5" />
<div>
<p className="text-sm text-gray-500">{t('crm.owner')}</p>
<p className="font-medium text-gray-900">{deal.owner?.username || deal.owner?.email || '—'}</p>
</div>
</div>
</div>
</div>
)}
{activeTab === 'quotes' && (
<div>
{quotes.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<FileText className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>{t('common.noData')}</p>
</div>
) : (
<div className="space-y-4">
{quotes.map((q) => (
<div
key={q.id}
className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50"
>
<div className="flex justify-between items-start">
<div>
<p className="font-medium text-gray-900">{q.quoteNumber}</p>
<p className="text-sm text-gray-500">v{q.version} · {q.status}</p>
</div>
<p className="font-semibold text-gray-900">{Number(q.total)?.toLocaleString()} SAR</p>
</div>
<p className="text-xs text-gray-500 mt-2">
{formatDate(q.validUntil)} · {formatDate(q.createdAt)}
</p>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'history' && (
<div>
{history.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<Clock className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>{t('common.noData')}</p>
</div>
) : (
<div className="space-y-4">
{history.map((h: any, i: number) => (
<div key={i} className="flex gap-4 border-b border-gray-100 pb-4 last:border-0">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center">
<History className="h-5 w-5 text-gray-500" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900">{h.action}</p>
<p className="text-sm text-gray-500">
{formatDate(h.createdAt)} · {h.userId || '—'}
</p>
{h.changes && (
<pre className="mt-2 text-xs text-gray-600 overflow-x-auto max-h-24">
{JSON.stringify(h.changes, null, 2)}
</pre>
)}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
</main>
{/* Win Dialog */}
{showWinDialog && deal && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowWinDialog(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-green-100 p-3 rounded-full">
<Award className="h-6 w-6 text-green-600" />
</div>
<div>
<h3 className="text-lg font-bold text-gray-900">{t('crm.markDealWon')}</h3>
<p className="text-sm text-gray-600">{deal.name}</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('crm.actualValue')} <span className="text-red-500">*</span>
</label>
<input
type="number"
min="0"
value={winData.actualValue}
onChange={(e) => setWinData({ ...winData, actualValue: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('crm.reasonForWinning')} <span className="text-red-500">*</span>
</label>
<textarea
value={winData.wonReason}
onChange={(e) => setWinData({ ...winData, wonReason: e.target.value })}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
placeholder={t('crm.winPlaceholder')}
/>
</div>
</div>
<div className="flex items-center justify-end gap-3 mt-6">
<button
onClick={() => setShowWinDialog(false)}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
disabled={submitting}
>
{t('common.cancel')}
</button>
<button
onClick={handleWin}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
disabled={submitting}
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{t('crm.markWon')}
</button>
</div>
</div>
</div>
</div>
)}
{/* Lose Dialog */}
{showLoseDialog && deal && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowLoseDialog(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">
<TrendingDown className="h-6 w-6 text-red-600" />
</div>
<div>
<h3 className="text-lg font-bold text-gray-900">{t('crm.markDealLost')}</h3>
<p className="text-sm text-gray-600">{deal.name}</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('crm.reasonForLosing')} <span className="text-red-500">*</span>
</label>
<textarea
value={loseData.lostReason}
onChange={(e) => setLoseData({ ...loseData, lostReason: e.target.value })}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
placeholder={t('crm.losePlaceholder')}
/>
</div>
</div>
<div className="flex items-center justify-end gap-3 mt-6">
<button
onClick={() => setShowLoseDialog(false)}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
disabled={submitting}
>
{t('common.cancel')}
</button>
<button
onClick={handleLose}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
disabled={submitting}
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{t('crm.markLost')}
</button>
</div>
</div>
</div>
</div>
)}
</div>
)
}
export default function DealDetailPage() {
return (
<ProtectedRoute>
<DealDetailContent />
</ProtectedRoute>
)
}

View File

@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useSearchParams } from 'next/navigation'
import ProtectedRoute from '@/components/ProtectedRoute'
import Modal from '@/components/Modal'
import LoadingSpinner from '@/components/LoadingSpinner'
@@ -31,8 +32,12 @@ import {
} from 'lucide-react'
import { dealsAPI, Deal, CreateDealData, UpdateDealData, DealFilters } from '@/lib/api/deals'
import { contactsAPI } from '@/lib/api/contacts'
import { pipelinesAPI, Pipeline } from '@/lib/api/pipelines'
import { useLanguage } from '@/contexts/LanguageContext'
function CRMContent() {
const { t } = useLanguage()
const searchParams = useSearchParams()
// State Management
const [deals, setDeals] = useState<Deal[]>([])
const [loading, setLoading] = useState(true)
@@ -80,6 +85,11 @@ function CRMContent() {
const [contacts, setContacts] = useState<any[]>([])
const [loadingContacts, setLoadingContacts] = useState(false)
// Pipelines for dropdown
const [pipelines, setPipelines] = useState<Pipeline[]>([])
const [loadingPipelines, setLoadingPipelines] = useState(false)
const editHandledRef = useRef<string | null>(null)
// Fetch Contacts for dropdown
useEffect(() => {
const fetchContacts = async () => {
@@ -96,6 +106,23 @@ function CRMContent() {
fetchContacts()
}, [])
// Fetch Pipelines for dropdown
useEffect(() => {
const fetchPipelines = async () => {
setLoadingPipelines(true)
try {
const data = await pipelinesAPI.getAll()
setPipelines(data)
} catch (err) {
console.error('Failed to load pipelines:', err)
toast.error('Failed to load pipelines')
} finally {
setLoadingPipelines(false)
}
}
fetchPipelines()
}, [])
// Fetch Deals (with debouncing for search)
const fetchDeals = useCallback(async () => {
setLoading(true)
@@ -137,28 +164,70 @@ function CRMContent() {
fetchDeals()
}, [currentPage, selectedStructure, selectedStage, selectedStatus])
// Handle ?edit=dealId from URL (e.g. from deal detail page)
const editId = searchParams.get('edit')
useEffect(() => {
if (!editId || editHandledRef.current === editId) return
const deal = deals.find(d => d.id === editId)
if (deal) {
editHandledRef.current = editId
setSelectedDeal(deal)
setFormData({
name: deal.name,
contactId: deal.contactId,
structure: deal.structure,
pipelineId: deal.pipelineId,
stage: deal.stage,
estimatedValue: deal.estimatedValue,
probability: deal.probability,
expectedCloseDate: deal.expectedCloseDate?.split('T')[0] || ''
})
setShowEditModal(true)
} else if (!loading) {
editHandledRef.current = editId
dealsAPI.getById(editId).then((d) => {
setSelectedDeal(d)
setFormData({
name: d.name,
contactId: d.contactId,
structure: d.structure,
pipelineId: d.pipelineId,
stage: d.stage,
estimatedValue: d.estimatedValue,
probability: d.probability,
expectedCloseDate: d.expectedCloseDate?.split('T')[0] || ''
})
setShowEditModal(true)
}).catch(() => toast.error('Deal not found'))
}
}, [editId, loading, deals])
// Form Validation
const validateForm = (): boolean => {
const errors: Record<string, string> = {}
if (!formData.name || formData.name.trim().length < 3) {
errors.name = 'Deal name must be at least 3 characters'
errors.name = t('crm.dealNameMin')
}
if (!formData.contactId) {
errors.contactId = 'Contact is required'
errors.contactId = t('crm.contactRequired')
}
if (!formData.structure) {
errors.structure = 'Deal structure is required'
errors.structure = t('crm.structureRequired')
}
if (!formData.pipelineId) {
errors.pipelineId = t('crm.pipelineRequired')
}
if (!formData.stage) {
errors.stage = 'Stage is required'
errors.stage = t('crm.stageRequired')
}
if (!formData.estimatedValue || formData.estimatedValue <= 0) {
errors.estimatedValue = 'Estimated value must be greater than 0'
errors.estimatedValue = t('crm.valueRequired')
}
setFormErrors(errors)
@@ -169,19 +238,14 @@ function CRMContent() {
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
if (!validateForm()) {
toast.error('Please fix form errors')
toast.error(t('crm.fixFormErrors'))
return
}
setSubmitting(true)
try {
// Create a default pipeline ID for now (we'll need to fetch pipelines later)
const dealData = {
...formData,
pipelineId: '00000000-0000-0000-0000-000000000001' // Placeholder
}
await dealsAPI.create(dealData)
toast.success('Deal created successfully!')
await dealsAPI.create(formData)
toast.success(t('crm.createSuccess'))
setShowCreateModal(false)
resetForm()
fetchDeals()
@@ -200,14 +264,14 @@ function CRMContent() {
const handleEdit = async (e: React.FormEvent) => {
e.preventDefault()
if (!selectedDeal || !validateForm()) {
toast.error('Please fix form errors')
toast.error(t('crm.fixFormErrors'))
return
}
setSubmitting(true)
try {
await dealsAPI.update(selectedDeal.id, formData as UpdateDealData)
toast.success('Deal updated successfully!')
toast.success(t('crm.updateSuccess'))
setShowEditModal(false)
resetForm()
fetchDeals()
@@ -248,7 +312,7 @@ function CRMContent() {
setSubmitting(true)
try {
await dealsAPI.win(selectedDeal.id, winData.actualValue, winData.wonReason)
toast.success('🎉 Deal won successfully!')
toast.success(t('crm.winSuccess'))
setShowWinDialog(false)
setSelectedDeal(null)
setWinData({ actualValue: 0, wonReason: '' })
@@ -271,7 +335,7 @@ function CRMContent() {
setSubmitting(true)
try {
await dealsAPI.lose(selectedDeal.id, loseData.lostReason)
toast.success('Deal marked as lost')
toast.success(t('crm.loseSuccess'))
setShowLoseDialog(false)
setSelectedDeal(null)
setLoseData({ lostReason: '' })
@@ -284,14 +348,26 @@ function CRMContent() {
}
}
// Pipelines filtered by selected structure (or all if no match)
const filteredPipelines = formData.structure
? pipelines.filter(p => p.structure === formData.structure)
: pipelines
const displayPipelines = filteredPipelines.length > 0 ? filteredPipelines : pipelines
// Utility Functions
const resetForm = () => {
const defaultStructure = 'B2B'
const matchingPipelines = pipelines.filter(p => p.structure === defaultStructure)
const firstPipeline = matchingPipelines[0] || pipelines[0]
const firstStage = firstPipeline?.stages?.length
? (firstPipeline.stages as { name: string }[])[0].name
: 'LEAD'
setFormData({
name: '',
contactId: '',
structure: 'B2B',
pipelineId: '',
stage: 'LEAD',
structure: defaultStructure,
pipelineId: firstPipeline?.id ?? '',
stage: firstStage,
estimatedValue: 0,
probability: 50,
expectedCloseDate: ''
@@ -379,25 +455,59 @@ function CRMContent() {
{/* Deal Structure */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Deal Structure <span className="text-red-500">*</span>
{t('crm.structure')} <span className="text-red-500">*</span>
</label>
<select
value={formData.structure}
onChange={(e) => setFormData({ ...formData, structure: e.target.value })}
onChange={(e) => {
const structure = e.target.value
const matchingPipelines = pipelines.filter(p => p.structure === structure)
const firstPipeline = matchingPipelines[0] || pipelines[0]
const firstStage = firstPipeline?.stages?.length
? (firstPipeline.stages as { name: string }[])[0].name
: 'LEAD'
setFormData({ ...formData, structure, pipelineId: firstPipeline?.id ?? '', stage: firstStage })
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value="B2B">B2B - شركة لشركة</option>
<option value="B2C">B2C - شركة لفرد</option>
<option value="B2G">B2G - شركة لحكومة</option>
<option value="PARTNERSHIP">Partnership - شراكة</option>
<option value="B2B">{t('crm.structureB2B')}</option>
<option value="B2C">{t('crm.structureB2C')}</option>
<option value="B2G">{t('crm.structureB2G')}</option>
<option value="PARTNERSHIP">{t('crm.structurePartnership')}</option>
</select>
{formErrors.structure && <p className="text-red-500 text-xs mt-1">{formErrors.structure}</p>}
</div>
{/* Pipeline */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('crm.pipeline')} <span className="text-red-500">*</span>
</label>
<select
value={formData.pipelineId}
onChange={(e) => {
const pipelineId = e.target.value
const pipeline = displayPipelines.find(p => p.id === pipelineId)
const firstStage = pipeline?.stages?.length
? (pipeline.stages as { name: string }[])[0].name
: 'LEAD'
setFormData({ ...formData, pipelineId, stage: firstStage })
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
disabled={loadingPipelines || isEdit}
>
<option value="">{loadingPipelines ? t('common.loading') : t('crm.selectPipeline')}</option>
{displayPipelines.map(p => (
<option key={p.id} value={p.id}>{p.name} {p.structure ? `(${p.structure})` : ''}</option>
))}
</select>
{formErrors.pipelineId && <p className="text-red-500 text-xs mt-1">{formErrors.pipelineId}</p>}
</div>
{/* Contact */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Contact <span className="text-red-500">*</span>
{t('crm.contact')} <span className="text-red-500">*</span>
</label>
<select
value={formData.contactId}
@@ -405,7 +515,7 @@ function CRMContent() {
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
disabled={loadingContacts}
>
<option value="">Select Contact</option>
<option value="">{t('crm.selectContact')}</option>
{contacts.map(contact => (
<option key={contact.id} value={contact.id}>{contact.name}</option>
))}
@@ -417,14 +527,14 @@ function CRMContent() {
{/* Deal Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Deal Name <span className="text-red-500">*</span>
{t('crm.dealName')} <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-green-500"
placeholder="Enter deal name"
placeholder={t('crm.enterDealName')}
/>
{formErrors.name && <p className="text-red-500 text-xs mt-1">{formErrors.name}</p>}
</div>
@@ -433,17 +543,35 @@ function CRMContent() {
{/* Stage */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Stage <span className="text-red-500">*</span>
{t('crm.stage')} <span className="text-red-500">*</span>
</label>
<select
value={formData.stage}
onChange={(e) => setFormData({ ...formData, stage: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value="LEAD">Lead - عميل محتمل</option>
<option value="QUALIFIED">Qualified - مؤهل</option>
<option value="PROPOSAL">Proposal - عرض</option>
<option value="NEGOTIATION">Negotiation - تفاوض</option>
{(() => {
const selectedPipeline = pipelines.find(p => p.id === formData.pipelineId)
const stages = (selectedPipeline?.stages as { name: string; nameAr?: string }[] | undefined) ?? []
if (stages.length > 0) {
const stageNames = new Set(stages.map(s => s.name))
const options = stages.map(s => (
<option key={s.name} value={s.name}>{s.nameAr ? `${s.name} - ${s.nameAr}` : s.name}</option>
))
if (formData.stage && !stageNames.has(formData.stage)) {
options.unshift(<option key={formData.stage} value={formData.stage}>{formData.stage}</option>)
}
return options
}
return (
<>
<option value="LEAD">Lead - عميل محتمل</option>
<option value="QUALIFIED">Qualified - مؤهل</option>
<option value="PROPOSAL">Proposal - عرض</option>
<option value="NEGOTIATION">Negotiation - تفاوض</option>
</>
)
})()}
</select>
{formErrors.stage && <p className="text-red-500 text-xs mt-1">{formErrors.stage}</p>}
</div>
@@ -451,7 +579,7 @@ function CRMContent() {
{/* Probability */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Probability (%)
{t('crm.probability')} (%)
</label>
<input
type="number"
@@ -468,7 +596,7 @@ function CRMContent() {
{/* Estimated Value */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Estimated Value (SAR) <span className="text-red-500">*</span>
{t('crm.estimatedValue')} <span className="text-red-500">*</span>
</label>
<input
type="number"
@@ -484,7 +612,7 @@ function CRMContent() {
{/* Expected Close Date */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Expected Close Date
{t('crm.expectedCloseDate')}
</label>
<input
type="date"
@@ -519,7 +647,7 @@ function CRMContent() {
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
disabled={submitting}
>
Cancel
{t('common.cancel')}
</button>
<button
type="submit"
@@ -529,11 +657,11 @@ function CRMContent() {
{submitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{isEdit ? 'Updating...' : 'Creating...'}
{isEdit ? t('crm.updating') : t('crm.creating')}
</>
) : (
<>
{isEdit ? 'Update Deal' : 'Create Deal'}
{isEdit ? t('crm.updateDeal') : t('crm.createDeal')}
</>
)}
</button>
@@ -559,8 +687,8 @@ function CRMContent() {
<TrendingUp className="h-6 w-6 text-green-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">إدارة علاقات العملاء</h1>
<p className="text-sm text-gray-600">CRM & Sales Pipeline</p>
<h1 className="text-2xl font-bold text-gray-900">{t('crm.title')}</h1>
<p className="text-sm text-gray-600">{t('crm.subtitle')}</p>
</div>
</div>
</div>
@@ -574,7 +702,7 @@ function CRMContent() {
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
<Plus className="h-4 w-4" />
New Deal
{t('crm.addDeal')}
</button>
</div>
</div>
@@ -587,7 +715,7 @@ function CRMContent() {
<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 Value</p>
<p className="text-sm text-gray-600">{t('crm.totalValue')}</p>
<p className="text-3xl font-bold text-gray-900 mt-1">
{(totalValue / 1000).toFixed(0)}K
</p>
@@ -602,12 +730,12 @@ function CRMContent() {
<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">Expected Value</p>
<p className="text-sm text-gray-600">{t('crm.expectedValue')}</p>
<p className="text-3xl font-bold text-gray-900 mt-1">
{(expectedValue / 1000).toFixed(0)}K
</p>
<p className="text-xs text-green-600 mt-1">
{totalValue > 0 ? ((expectedValue / totalValue) * 100).toFixed(0) : 0}% conversion
{totalValue > 0 ? ((expectedValue / totalValue) * 100).toFixed(0) : 0}% {t('crm.conversion')}
</p>
</div>
<div className="bg-green-100 p-3 rounded-lg">
@@ -619,9 +747,9 @@ function CRMContent() {
<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 Deals</p>
<p className="text-sm text-gray-600">{t('crm.activeDeals')}</p>
<p className="text-3xl font-bold text-gray-900 mt-1">{activeDeals}</p>
<p className="text-xs text-orange-600 mt-1">In pipeline</p>
<p className="text-xs text-orange-600 mt-1">{t('crm.inPipeline')}</p>
</div>
<div className="bg-orange-100 p-3 rounded-lg">
<Clock className="h-8 w-8 text-orange-600" />
@@ -632,10 +760,10 @@ function CRMContent() {
<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">Won Deals</p>
<p className="text-sm text-gray-600">{t('crm.wonDeals')}</p>
<p className="text-3xl font-bold text-gray-900 mt-1">{wonDeals}</p>
<p className="text-xs text-green-600 mt-1">
{total > 0 ? ((wonDeals / total) * 100).toFixed(0) : 0}% win rate
{total > 0 ? ((wonDeals / total) * 100).toFixed(0) : 0}% {t('crm.winRate')}
</p>
</div>
<div className="bg-purple-100 p-3 rounded-lg">
@@ -653,7 +781,7 @@ function CRMContent() {
<Search className="absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder="Search deals (name, deal number...)"
placeholder={t('crm.searchPlaceholder')}
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-green-500"
@@ -666,7 +794,7 @@ function CRMContent() {
onChange={(e) => setSelectedStructure(e.target.value)}
className="px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value="all">All Structures</option>
<option value="all">{t('crm.allStructures')}</option>
<option value="B2B">B2B</option>
<option value="B2C">B2C</option>
<option value="B2G">B2G</option>
@@ -679,7 +807,7 @@ function CRMContent() {
onChange={(e) => setSelectedStage(e.target.value)}
className="px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value="all">All Stages</option>
<option value="all">{t('crm.allStages')}</option>
<option value="LEAD">Lead</option>
<option value="QUALIFIED">Qualified</option>
<option value="PROPOSAL">Proposal</option>
@@ -694,7 +822,7 @@ function CRMContent() {
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-green-500"
>
<option value="all">All Status</option>
<option value="all">{t('crm.allStatus')}</option>
<option value="ACTIVE">Active</option>
<option value="WON">Won</option>
<option value="LOST">Lost</option>
@@ -706,7 +834,7 @@ function CRMContent() {
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
{loading ? (
<div className="p-12">
<LoadingSpinner size="lg" message="Loading deals..." />
<LoadingSpinner size="lg" message={t('crm.loadingDeals')} />
</div>
) : error ? (
<div className="p-12 text-center">
@@ -715,18 +843,18 @@ function CRMContent() {
onClick={fetchDeals}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
>
Retry
{t('crm.retry')}
</button>
</div>
) : deals.length === 0 ? (
<div className="p-12 text-center">
<TrendingUp className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600 mb-4">No deals found</p>
<p className="text-gray-600 mb-4">{t('crm.noDealsFound')}</p>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
>
Create First Deal
{t('crm.createFirstDeal')}
</button>
</div>
) : (
@@ -735,13 +863,13 @@ function CRMContent() {
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Deal</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">Structure</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Value</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Probability</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Stage</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">{t('crm.deal')}</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">{t('crm.contact')}</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">{t('crm.structure')}</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">{t('crm.value')}</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">{t('crm.probability')}</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">{t('crm.stage')}</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">{t('common.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
@@ -749,7 +877,12 @@ function CRMContent() {
<tr key={deal.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4">
<div>
<p className="font-semibold text-gray-900">{deal.name}</p>
<Link
href={`/crm/deals/${deal.id}`}
className="font-semibold text-gray-900 hover:text-green-600 hover:underline"
>
{deal.name}
</Link>
<p className="text-xs text-gray-600">{deal.dealNumber}</p>
</div>
</td>
@@ -801,14 +934,14 @@ function CRMContent() {
<button
onClick={() => openWinDialog(deal)}
className="p-2 hover:bg-green-50 text-green-600 rounded-lg transition-colors"
title="Mark as Won"
title={t('crm.markWon')}
>
<CheckCircle2 className="h-4 w-4" />
</button>
<button
onClick={() => openLoseDialog(deal)}
className="p-2 hover:bg-red-50 text-red-600 rounded-lg transition-colors"
title="Mark as Lost"
title={t('crm.markLost')}
>
<XCircle className="h-4 w-4" />
</button>
@@ -817,14 +950,14 @@ function CRMContent() {
<button
onClick={() => openEditModal(deal)}
className="p-2 hover:bg-blue-50 text-blue-600 rounded-lg transition-colors"
title="Edit"
title={t('common.edit')}
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => openDeleteDialog(deal)}
className="p-2 hover:bg-red-50 text-red-600 rounded-lg transition-colors"
title="Delete"
title={t('crm.deleteDeal')}
>
<Trash2 className="h-4 w-4" />
</button>
@@ -849,7 +982,7 @@ function CRMContent() {
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
{t('crm.paginationPrevious')}
</button>
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const page = i + 1
@@ -873,7 +1006,7 @@ function CRMContent() {
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
{t('crm.paginationNext')}
</button>
</div>
</div>
@@ -889,7 +1022,7 @@ function CRMContent() {
setShowCreateModal(false)
resetForm()
}}
title="Create New Deal"
title={t('crm.createNewDeal')}
size="xl"
>
<form onSubmit={handleCreate}>
@@ -904,7 +1037,7 @@ function CRMContent() {
setShowEditModal(false)
resetForm()
}}
title="Edit Deal"
title={t('crm.editDeal')}
size="xl"
>
<form onSubmit={handleEdit}>
@@ -923,14 +1056,14 @@ function CRMContent() {
<Award className="h-6 w-6 text-green-600" />
</div>
<div>
<h3 className="text-lg font-bold text-gray-900">Mark Deal as Won</h3>
<h3 className="text-lg font-bold text-gray-900">{t('crm.markDealWon')}</h3>
<p className="text-sm text-gray-600">{selectedDeal.name}</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Actual Value (SAR) <span className="text-red-500">*</span>
{t('crm.actualValue')} <span className="text-red-500">*</span>
</label>
<input
type="number"
@@ -942,14 +1075,14 @@ function CRMContent() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Reason for Winning <span className="text-red-500">*</span>
{t('crm.reasonForWinning')} <span className="text-red-500">*</span>
</label>
<textarea
value={winData.wonReason}
onChange={(e) => setWinData({ ...winData, wonReason: e.target.value })}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
placeholder="Why did we win this deal?"
placeholder={t('crm.winPlaceholder')}
/>
</div>
</div>
@@ -959,7 +1092,7 @@ function CRMContent() {
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
disabled={submitting}
>
Cancel
{t('common.cancel')}
</button>
<button
onClick={handleWin}
@@ -969,10 +1102,10 @@ function CRMContent() {
{submitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Processing...
{t('crm.processing')}
</>
) : (
'🎉 Mark as Won'
t('crm.markWon')
)}
</button>
</div>
@@ -992,21 +1125,21 @@ function CRMContent() {
<TrendingDown className="h-6 w-6 text-red-600" />
</div>
<div>
<h3 className="text-lg font-bold text-gray-900">Mark Deal as Lost</h3>
<h3 className="text-lg font-bold text-gray-900">{t('crm.markDealLost')}</h3>
<p className="text-sm text-gray-600">{selectedDeal.name}</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Reason for Losing <span className="text-red-500">*</span>
{t('crm.reasonForLosing')} <span className="text-red-500">*</span>
</label>
<textarea
value={loseData.lostReason}
onChange={(e) => setLoseData({ ...loseData, lostReason: e.target.value })}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
placeholder="Why did we lose this deal?"
placeholder={t('crm.losePlaceholder')}
/>
</div>
</div>
@@ -1016,7 +1149,7 @@ function CRMContent() {
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
disabled={submitting}
>
Cancel
{t('common.cancel')}
</button>
<button
onClick={handleLose}
@@ -1026,10 +1159,10 @@ function CRMContent() {
{submitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Processing...
{t('crm.processing')}
</>
) : (
'Mark as Lost'
t('crm.markLost')
)}
</button>
</div>
@@ -1049,12 +1182,12 @@ function CRMContent() {
<Trash2 className="h-6 w-6 text-red-600" />
</div>
<div>
<h3 className="text-lg font-bold text-gray-900">Delete Deal</h3>
<p className="text-sm text-gray-600">This will mark the deal as lost</p>
<h3 className="text-lg font-bold text-gray-900">{t('crm.deleteDeal')}</h3>
<p className="text-sm text-gray-600">{t('crm.deleteDealDesc')}</p>
</div>
</div>
<p className="text-gray-700 mb-6">
Are you sure you want to delete <span className="font-semibold">{selectedDeal.name}</span>?
{t('crm.deleteDealConfirm')} <span className="font-semibold">{selectedDeal.name}</span>?
</p>
<div className="flex items-center justify-end gap-3">
<button
@@ -1065,7 +1198,7 @@ function CRMContent() {
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
disabled={submitting}
>
Cancel
{t('common.cancel')}
</button>
<button
onClick={handleDelete}
@@ -1075,10 +1208,10 @@ function CRMContent() {
{submitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Deleting...
{t('crm.deleting')}
</>
) : (
'Delete Deal'
t('crm.deleteDeal')
)}
</button>
</div>

View File

@@ -2,6 +2,8 @@
import ProtectedRoute from '@/components/ProtectedRoute'
import { useAuth } from '@/contexts/AuthContext'
import { useLanguage } from '@/contexts/LanguageContext'
import LanguageSwitcher from '@/components/LanguageSwitcher'
import Link from 'next/link'
import {
Users,
@@ -19,6 +21,7 @@ import {
function DashboardContent() {
const { user, logout, hasPermission } = useAuth()
const { t, language, dir } = useLanguage()
const allModules = [
{
@@ -105,6 +108,9 @@ function DashboardContent() {
</div>
<div className="flex items-center gap-4">
{/* Language Switcher */}
<LanguageSwitcher />
{/* User Info */}
<div className="text-right">
<p className="text-sm font-semibold text-gray-900">{user?.username}</p>

View File

@@ -105,3 +105,134 @@ p, span, div, a, button, input, textarea, select, label, td, th {
margin-left: auto;
}
/* ==============================================
ACCESSIBILITY IMPROVEMENTS (WCAG AA)
============================================== */
/* Focus Indicators - Visible outline for keyboard navigation */
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
[role="button"]:focus-visible,
[tabindex]:focus-visible {
outline: 2px solid #3b82f6 !important;
outline-offset: 2px !important;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1) !important;
}
/* Enhanced focus for interactive elements */
button:focus-visible {
outline-color: #2563eb;
}
/* Focus for checkboxes and radio buttons */
input[type="checkbox"]:focus-visible,
input[type="radio"]:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
/* Remove default focus for mouse users (keep for keyboard) */
button:focus:not(:focus-visible),
a:focus:not(:focus-visible),
input:focus:not(:focus-visible),
select:focus:not(:focus-visible),
textarea:focus:not(:focus-visible) {
outline: none;
}
/* High contrast mode support */
@media (prefers-contrast: high) {
button:focus-visible,
a:focus-visible,
input:focus-visible {
outline-width: 3px !important;
}
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Screen reader only content */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* Skip to main content link */
.skip-to-main {
position: absolute;
left: -9999px;
z-index: 999;
padding: 1rem;
background-color: #1f2937;
color: white;
text-decoration: none;
}
.skip-to-main:focus {
left: 0;
top: 0;
}
/* Ensure sufficient color contrast for links */
a {
color: #2563eb;
text-decoration-skip-ink: auto;
}
a:hover {
color: #1d4ed8;
}
/* Better button states for accessibility */
button:disabled,
[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.6;
}
/* Loading states with aria-busy */
[aria-busy="true"] {
cursor: wait;
}
/* Error states */
[aria-invalid="true"] {
border-color: #dc2626 !important;
}
/* Required field indicators */
[aria-required="true"]::after {
content: " *";
color: #dc2626;
}
/* Keyboard navigation hints */
[data-keyboard-hint]:focus-visible::after {
content: attr(data-keyboard-hint);
position: absolute;
bottom: -1.5rem;
left: 0;
font-size: 0.75rem;
color: #6b7280;
}

View File

@@ -3,6 +3,7 @@ import { Cairo, Readex_Pro } from 'next/font/google'
import './globals.css'
import { Providers } from './providers'
import { AuthProvider } from '@/contexts/AuthContext'
import { LanguageProvider } from '@/contexts/LanguageContext'
import { Toaster } from 'react-hot-toast'
const cairo = Cairo({
@@ -28,11 +29,12 @@ export default function RootLayout({
children: React.ReactNode
}) {
return (
<html lang="ar" dir="rtl">
<html lang="en">
<body className={`${readexPro.variable} ${cairo.variable} font-readex`}>
<AuthProvider>
<Providers>{children}</Providers>
<Toaster
<LanguageProvider>
<AuthProvider>
<Providers>{children}</Providers>
<Toaster
position="top-center"
reverseOrder={false}
toastOptions={{
@@ -58,7 +60,8 @@ export default function RootLayout({
},
}}
/>
</AuthProvider>
</AuthProvider>
</LanguageProvider>
</body>
</html>
)

View File

@@ -0,0 +1,36 @@
'use client'
import { useLanguage } from '@/contexts/LanguageContext'
import { Globe } from 'lucide-react'
export default function LanguageSwitcher() {
const { language, setLanguage } = useLanguage()
return (
<div className="flex items-center gap-2">
<Globe className="h-5 w-5 text-gray-600" />
<div className="flex items-center bg-gray-100 rounded-lg p-1">
<button
onClick={() => setLanguage('en')}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
language === 'en'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
EN
</button>
<button
onClick={() => setLanguage('ar')}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
language === 'ar'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
AR
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,331 @@
'use client'
import { useState, useEffect } from 'react'
import { ChevronRight, ChevronDown, Plus, Check, Folder, FolderOpen, X } from 'lucide-react'
import { categoriesAPI, Category } from '@/lib/api/categories'
import { toast } from 'react-hot-toast'
interface CategorySelectorProps {
selectedIds: string[]
onChange: (selectedIds: string[]) => void
multiSelect?: boolean
}
export default function CategorySelector({ selectedIds, onChange, multiSelect = true }: CategorySelectorProps) {
const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(true)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const [showAddModal, setShowAddModal] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [newCategoryNameAr, setNewCategoryNameAr] = useState('')
const [newCategoryParentId, setNewCategoryParentId] = useState<string | undefined>()
const [searchTerm, setSearchTerm] = useState('')
useEffect(() => {
fetchCategories()
}, [])
const fetchCategories = async () => {
setLoading(true)
try {
const data = await categoriesAPI.getTree()
setCategories(data)
} catch (error) {
toast.error('Failed to load categories')
} finally {
setLoading(false)
}
}
const toggleExpand = (id: string) => {
const newExpanded = new Set(expandedIds)
if (newExpanded.has(id)) {
newExpanded.delete(id)
} else {
newExpanded.add(id)
}
setExpandedIds(newExpanded)
}
const toggleSelect = (id: string) => {
if (multiSelect) {
const newSelected = selectedIds.includes(id)
? selectedIds.filter(sid => sid !== id)
: [...selectedIds, id]
onChange(newSelected)
} else {
onChange([id])
}
}
const handleAddCategory = async () => {
if (!newCategoryName.trim()) {
toast.error('Category name is required')
return
}
try {
await categoriesAPI.create({
name: newCategoryName,
nameAr: newCategoryNameAr || undefined,
parentId: newCategoryParentId
})
toast.success('Category created successfully')
setNewCategoryName('')
setNewCategoryNameAr('')
setNewCategoryParentId(undefined)
setShowAddModal(false)
fetchCategories()
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to create category')
}
}
const renderCategory = (category: Category, level: number = 0) => {
const isSelected = selectedIds.includes(category.id)
const isExpanded = expandedIds.has(category.id)
const hasChildren = category.children && category.children.length > 0
const matchesSearch = searchTerm === '' ||
category.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(category.nameAr && category.nameAr.includes(searchTerm))
if (!matchesSearch && searchTerm !== '') return null
return (
<div key={category.id}>
<div
className={`flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-colors ${
isSelected ? 'bg-blue-50 border border-blue-200' : 'hover:bg-gray-50'
}`}
style={{ paddingLeft: `${level * 1.5 + 0.75}rem` }}
>
{/* Expand/Collapse */}
{hasChildren ? (
<button
onClick={(e) => {
e.stopPropagation()
toggleExpand(category.id)
}}
className="p-1 hover:bg-gray-200 rounded"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-gray-600" />
) : (
<ChevronRight className="h-4 w-4 text-gray-600" />
)}
</button>
) : (
<div className="w-6" />
)}
{/* Folder Icon */}
{isExpanded ? (
<FolderOpen className="h-4 w-4 text-blue-600" />
) : (
<Folder className="h-4 w-4 text-gray-600" />
)}
{/* Category Name */}
<button
onClick={() => toggleSelect(category.id)}
className="flex-1 text-left flex items-center gap-2"
>
<span className="text-sm font-medium text-gray-900">{category.name}</span>
{category.nameAr && (
<span className="text-xs text-gray-500" dir="rtl">({category.nameAr})</span>
)}
{category._count && category._count.contacts > 0 && (
<span className="text-xs text-gray-400">({category._count.contacts})</span>
)}
</button>
{/* Selection Checkbox */}
<button
type="button"
onClick={(e) => {
e.stopPropagation()
toggleSelect(category.id)
}}
className={`w-5 h-5 rounded border-2 flex items-center justify-center cursor-pointer transition-colors ${
isSelected
? 'bg-blue-600 border-blue-600'
: 'border-gray-300 bg-white hover:border-blue-400'
}`}
>
{isSelected && <Check className="h-3 w-3 text-white" />}
</button>
</div>
{/* Children */}
{hasChildren && isExpanded && (
<div>
{category.children!.map(child => renderCategory(child, level + 1))}
</div>
)}
</div>
)
}
const getSelectedCategories = (): Category[] => {
const findCategory = (cats: Category[], id: string): Category | null => {
for (const cat of cats) {
if (cat.id === id) return cat
if (cat.children) {
const found = findCategory(cat.children, id)
if (found) return found
}
}
return null
}
return selectedIds
.map(id => findCategory(categories, id))
.filter(cat => cat !== null) as Category[]
}
const removeSelected = (id: string) => {
onChange(selectedIds.filter(sid => sid !== id))
}
if (loading) {
return <div className="text-center py-4 text-gray-500">Loading categories...</div>
}
return (
<div className="space-y-3">
{/* Search and Add */}
<div className="flex gap-2">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search categories..."
className="flex-1 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"
/>
<button
onClick={() => setShowAddModal(true)}
className="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
title="Add Category"
>
<Plus className="h-5 w-5" />
</button>
</div>
{/* Selected Categories */}
{selectedIds.length > 0 && (
<div className="flex flex-wrap gap-2 p-3 bg-gray-50 rounded-lg">
{getSelectedCategories().map(category => (
<span
key={category.id}
className="inline-flex items-center gap-2 px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm"
>
{category.name}
<button
onClick={() => removeSelected(category.id)}
className="hover:text-blue-900"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
{/* Category Tree */}
<div className="border border-gray-200 rounded-lg p-3 max-h-96 overflow-y-auto bg-white">
{categories.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Folder className="h-12 w-12 mx-auto mb-2 text-gray-300" />
<p>No categories found</p>
<button
onClick={() => setShowAddModal(true)}
className="mt-2 text-blue-600 hover:text-blue-700 text-sm"
>
Create your first category
</button>
</div>
) : (
categories.map(category => renderCategory(category))
)}
</div>
{/* Add Category Modal */}
{showAddModal && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowAddModal(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">
<h3 className="text-lg font-bold text-gray-900 mb-4">Add Category</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Category Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
placeholder="Enter category name"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Arabic Name
</label>
<input
type="text"
value={newCategoryNameAr}
onChange={(e) => setNewCategoryNameAr(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>
<label className="block text-sm font-medium text-gray-700 mb-1">
Parent Category (Optional)
</label>
<select
value={newCategoryParentId || ''}
onChange={(e) => setNewCategoryParentId(e.target.value || undefined)}
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="">None (Root Category)</option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
</div>
</div>
<div className="flex items-center justify-end gap-3 mt-6">
<button
onClick={() => {
setShowAddModal(false)
setNewCategoryName('')
setNewCategoryNameAr('')
setNewCategoryParentId(undefined)
}}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={handleAddCategory}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Add Category
</button>
</div>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,595 @@
'use client'
import { useState, useEffect } from 'react'
import { Star, X, Plus, Loader2 } from 'lucide-react'
import { Contact, CreateContactData, UpdateContactData } from '@/lib/api/contacts'
import { categoriesAPI, Category } from '@/lib/api/categories'
import { employeesAPI, Employee } from '@/lib/api/employees'
import CategorySelector from './CategorySelector'
import DuplicateAlert from './DuplicateAlert'
interface ContactFormProps {
contact?: Contact
onSubmit: (data: CreateContactData | UpdateContactData) => Promise<void>
onCancel: () => void
submitting?: boolean
}
export default function ContactForm({ contact, onSubmit, onCancel, submitting = false }: ContactFormProps) {
const isEdit = !!contact
// Form state
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 || 'Saudi Arabia',
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 [newTag, setNewTag] = useState('')
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
const [categories, setCategories] = useState<Category[]>([])
const [employees, setEmployees] = useState<Employee[]>([])
useEffect(() => {
categoriesAPI.getTree().then(setCategories).catch(() => {})
}, [])
useEffect(() => {
employeesAPI.getAll({ status: 'ACTIVE', pageSize: 500 }).then((r) => setEmployees(r.employees)).catch(() => {})
}, [])
const companyEmployeeCategoryId = (() => {
const flatten = (cats: Category[]): Category[] => {
const out: Category[] = []
const walk = (c: Category) => {
out.push(c)
c.children?.forEach(walk)
}
cats.forEach(walk)
return out
}
return flatten(categories).find((c) => c.name === 'Company Employee')?.id
})()
const isCompanyEmployeeSelected = companyEmployeeCategoryId && (formData.categories || []).includes(companyEmployeeCategoryId)
// 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'
}
if (!formData.source) {
errors.source = 'Source is required'
}
setFormErrors(errors)
return Object.keys(errors).length === 0
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validateForm()) return
// Clean up empty strings to undefined for optional fields
const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, value]) => {
// Keep the value if it's not an empty string, or if it's a required field
if (value !== '' || ['type', 'name', 'source', 'country'].includes(key)) {
acc[key] = value
}
return acc
}, {} as any)
// Remove parentId if it's empty or undefined
if (!cleanData.parentId) {
delete cleanData.parentId
}
// Remove categories if empty array
if (cleanData.categories && cleanData.categories.length === 0) {
delete cleanData.categories
}
// Remove employeeId if empty
if (!cleanData.employeeId) {
delete cleanData.employeeId
}
const submitData = isEdit
? cleanData as UpdateContactData
: cleanData as CreateContactData
await onSubmit(submitData)
}
const addTag = () => {
if (newTag.trim() && !formData.tags?.includes(newTag.trim())) {
setFormData({
...formData,
tags: [...(formData.tags || []), newTag.trim()]
})
setNewTag('')
}
}
const removeTag = (tagToRemove: string) => {
setFormData({
...formData,
tags: formData.tags?.filter(tag => tag !== tagToRemove) || []
})
}
const showCompanyFields = ['COMPANY', 'HOLDING', 'GOVERNMENT'].includes(formData.type)
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Information Section */}
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Basic Information</h3>
<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 bg-white text-gray-900"
>
<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 bg-white text-gray-900"
>
<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>
{formErrors.source && <p className="text-red-500 text-xs mt-1">{formErrors.source}</p>}
</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 bg-white text-gray-900"
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 bg-white text-gray-900"
placeholder="أدخل الاسم بالعربية"
dir="rtl"
/>
</div>
{/* Rating */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Rating
</label>
<div className="flex items-center gap-2">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => setRating(star)}
className="focus:outline-none transition-colors"
>
<Star
className={`h-8 w-8 ${
star <= rating
? 'fill-yellow-400 text-yellow-400'
: 'text-gray-300 hover:text-yellow-200'
}`}
/>
</button>
))}
{rating > 0 && (
<button
type="button"
onClick={() => setRating(0)}
className="ml-2 text-sm text-gray-500 hover:text-gray-700"
>
Clear
</button>
)}
</div>
</div>
</div>
</div>
{/* Contact Methods Section */}
<div className="pt-6 border-t">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Contact Methods</h3>
<div className="space-y-4">
<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 bg-white text-gray-900"
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 bg-white text-gray-900"
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 bg-white text-gray-900"
placeholder="+966 55 123 4567"
/>
</div>
{/* Website */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Website
</label>
<input
type="text"
value={formData.website || ''}
onChange={(e) => setFormData({ ...formData, website: 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="www.example.com"
/>
</div>
</div>
</div>
</div>
{/* Company Information Section (conditional) */}
{showCompanyFields && (
<div className="pt-6 border-t">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Company Information</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 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 bg-white text-gray-900"
placeholder="Company name"
/>
</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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Tax Number */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tax Number
</label>
<input
type="text"
value={formData.taxNumber || ''}
onChange={(e) => setFormData({ ...formData, taxNumber: 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 font-mono"
placeholder="Tax registration number"
/>
</div>
{/* Commercial Register */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Commercial Register
</label>
<input
type="text"
value={formData.commercialRegister || ''}
onChange={(e) => setFormData({ ...formData, commercialRegister: 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 font-mono"
placeholder="Commercial register number"
/>
</div>
</div>
</div>
</div>
)}
{/* Address Section */}
<div className="pt-6 border-t">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Address Information</h3>
<div className="space-y-4">
{/* Address */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Street 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 bg-white text-gray-900"
placeholder="Street address"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 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 bg-white text-gray-900"
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 bg-white text-gray-900"
placeholder="Country"
/>
</div>
{/* Postal Code */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Postal Code
</label>
<input
type="text"
value={formData.postalCode || ''}
onChange={(e) => setFormData({ ...formData, postalCode: 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="Postal code"
/>
</div>
</div>
</div>
</div>
{/* Categories Section */}
<div className="pt-6 border-t">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Categories</h3>
<CategorySelector
selectedIds={formData.categories || []}
onChange={(categories) => setFormData({ ...formData, categories })}
multiSelect={true}
/>
</div>
{/* Employee Link - when Company Employee category is selected */}
{isCompanyEmployeeSelected && (
<div className="pt-6 border-t">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Link to Employee (Optional)</h3>
<p className="text-sm text-gray-600 mb-2">
Link this contact to an HR employee record for sync and unified views.
</p>
<select
value={formData.employeeId || ''}
onChange={(e) => setFormData({ ...formData, employeeId: e.target.value || undefined })}
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="">None (No link)</option>
{employees.map((emp) => (
<option key={emp.id} value={emp.id}>
{emp.firstName} {emp.lastName} ({emp.email}){emp.uniqueEmployeeId ? ` - ${emp.uniqueEmployeeId}` : ''}
</option>
))}
</select>
</div>
)}
{/* Tags Section */}
<div className="pt-6 border-t">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Tags</h3>
<div className="space-y-3">
{/* Tag input */}
<div className="flex gap-2">
<input
type="text"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
className="flex-1 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="Add a tag (press Enter)"
/>
<button
type="button"
onClick={addTag}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="h-5 w-5" />
</button>
</div>
{/* Tags display */}
{formData.tags && formData.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{formData.tags.map((tag, index) => (
<span
key={index}
className="inline-flex items-center gap-2 px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm"
>
#{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="hover:text-red-600 transition-colors"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
</div>
</div>
{/* Duplicate Detection */}
<DuplicateAlert
email={formData.email}
phone={formData.phone}
mobile={formData.mobile}
taxNumber={formData.taxNumber}
commercialRegister={formData.commercialRegister}
excludeId={contact?.id}
onMerge={(contactId) => {
// Navigate to merge page with pre-selected contacts
if (typeof window !== 'undefined') {
window.location.href = `/contacts/merge?sourceId=${contactId}`
}
}}
/>
{/* Form Actions */}
<div className="flex items-center justify-end gap-3 pt-6 border-t">
<button
type="button"
onClick={onCancel}
className="px-6 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-6 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>
</form>
)
}

View File

@@ -0,0 +1,239 @@
'use client'
import { useState, useEffect } from 'react'
import { Clock, User, Edit, Archive, Trash2, GitMerge, Users as UsersIcon, Loader2 } from 'lucide-react'
import { contactsAPI } from '@/lib/api/contacts'
import { toast } from 'react-hot-toast'
interface HistoryEntry {
id: string
action: string
entityType: string
entityId: string
userId: string
user?: {
name: string
email: string
}
changes?: any
metadata?: any
reason?: string
createdAt: string
}
interface ContactHistoryProps {
contactId: string
}
export default function ContactHistory({ contactId }: ContactHistoryProps) {
const [history, setHistory] = useState<HistoryEntry[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetchHistory()
}, [contactId])
const fetchHistory = async () => {
setLoading(true)
setError(null)
try {
const data = await contactsAPI.getHistory(contactId)
setHistory(data)
} catch (err: any) {
const message = err.response?.data?.message || 'Failed to load history'
setError(message)
toast.error(message)
} finally {
setLoading(false)
}
}
const getActionIcon = (action: string) => {
switch (action.toLowerCase()) {
case 'create':
case 'created':
return <User className="h-5 w-5 text-green-600" />
case 'update':
case 'updated':
return <Edit className="h-5 w-5 text-blue-600" />
case 'archive':
case 'archived':
return <Archive className="h-5 w-5 text-orange-600" />
case 'delete':
case 'deleted':
return <Trash2 className="h-5 w-5 text-red-600" />
case 'merge':
case 'merged':
return <GitMerge className="h-5 w-5 text-purple-600" />
case 'relationship':
case 'add_relationship':
return <UsersIcon className="h-5 w-5 text-indigo-600" />
default:
return <Clock className="h-5 w-5 text-gray-600" />
}
}
const getActionColor = (action: string) => {
switch (action.toLowerCase()) {
case 'create':
case 'created':
return 'bg-green-50 border-green-200'
case 'update':
case 'updated':
return 'bg-blue-50 border-blue-200'
case 'archive':
case 'archived':
return 'bg-orange-50 border-orange-200'
case 'delete':
case 'deleted':
return 'bg-red-50 border-red-200'
case 'merge':
case 'merged':
return 'bg-purple-50 border-purple-200'
case 'relationship':
case 'add_relationship':
return 'bg-indigo-50 border-indigo-200'
default:
return 'bg-gray-50 border-gray-200'
}
}
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(date)
}
const renderChanges = (entry: HistoryEntry) => {
if (!entry.changes) return null
const changes = entry.changes
const changedFields = Object.keys(changes).filter(key => key !== 'updatedAt')
if (changedFields.length === 0) return null
return (
<div className="mt-3 space-y-2">
<p className="text-xs font-semibold text-gray-700">Changes:</p>
<div className="space-y-1">
{changedFields.map(field => (
<div key={field} className="text-xs bg-white p-2 rounded border border-gray-200">
<span className="font-medium text-gray-700">{field}:</span>
<div className="flex items-center gap-2 mt-1">
<span className="text-red-600 line-through">
{changes[field].old !== null && changes[field].old !== undefined
? String(changes[field].old)
: '(empty)'}
</span>
<span className="text-gray-400"></span>
<span className="text-green-600">
{changes[field].new !== null && changes[field].new !== undefined
? String(changes[field].new)
: '(empty)'}
</span>
</div>
</div>
))}
</div>
</div>
)
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
</div>
)
}
if (error) {
return (
<div className="text-center py-8 text-red-600">
<p>{error}</p>
<button
onClick={fetchHistory}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Retry
</button>
</div>
)
}
if (history.length === 0) {
return (
<div className="text-center py-12 text-gray-500">
<Clock className="h-12 w-12 mx-auto mb-2 text-gray-300" />
<p>No history records found</p>
</div>
)
}
return (
<div className="space-y-4">
{/* Timeline */}
<div className="relative">
{/* Vertical line */}
<div className="absolute left-6 top-0 bottom-0 w-0.5 bg-gray-200" />
{/* History entries */}
<div className="space-y-6">
{history.map((entry, index) => (
<div key={entry.id} className="relative flex gap-4">
{/* Icon */}
<div className={`flex-shrink-0 w-12 h-12 rounded-full border-2 flex items-center justify-center z-10 ${getActionColor(entry.action)}`}>
{getActionIcon(entry.action)}
</div>
{/* Content */}
<div className="flex-1 pb-6">
<div className={`border rounded-lg p-4 ${getActionColor(entry.action)}`}>
{/* Header */}
<div className="flex items-start justify-between mb-2">
<div>
<h4 className="font-semibold text-gray-900 capitalize">
{entry.action.replace('_', ' ')}
</h4>
{entry.user && (
<p className="text-sm text-gray-600">
by {entry.user.name}
</p>
)}
</div>
<span className="text-xs text-gray-500">
{formatDate(entry.createdAt)}
</span>
</div>
{/* Reason */}
{entry.reason && (
<p className="text-sm text-gray-700 mb-2">
<span className="font-medium">Reason:</span> {entry.reason}
</p>
)}
{/* Metadata */}
{entry.metadata && (
<div className="text-sm text-gray-700">
{JSON.stringify(entry.metadata, null, 2)}
</div>
)}
{/* Changes */}
{renderChanges(entry)}
</div>
</div>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,423 @@
'use client'
import { useState, useCallback } from 'react'
import { useDropzone } from 'react-dropzone'
import { toast } from 'react-hot-toast'
import { Upload, FileSpreadsheet, CheckCircle, XCircle, AlertTriangle, Download, X } from 'lucide-react'
import { contactsAPI } from '@/lib/api/contacts'
import * as XLSX from 'xlsx'
interface ImportError {
row: number
field: string
message: string
data?: any
}
interface ImportResult {
success: number
failed: number
duplicates: number
errors: ImportError[]
}
interface ContactImportProps {
onClose: () => void
onSuccess: () => void
}
export default function ContactImport({ onClose, onSuccess }: ContactImportProps) {
const [step, setStep] = useState(1)
const [file, setFile] = useState<File | null>(null)
const [preview, setPreview] = useState<any[]>([])
const [importing, setImporting] = useState(false)
const [result, setResult] = useState<ImportResult | null>(null)
// Step 1: File Upload
const onDrop = useCallback((acceptedFiles: File[]) => {
const uploadedFile = acceptedFiles[0]
if (!uploadedFile) return
const fileExtension = uploadedFile.name.split('.').pop()?.toLowerCase()
if (!['xlsx', 'xls', 'csv'].includes(fileExtension || '')) {
toast.error('يرجى تحميل ملف Excel أو CSV - Please upload an Excel or CSV file')
return
}
setFile(uploadedFile)
// Read and preview file
const reader = new FileReader()
reader.onload = (e) => {
try {
const data = e.target?.result
const workbook = XLSX.read(data, { type: 'binary' })
const sheetName = workbook.SheetNames[0]
const worksheet = workbook.Sheets[sheetName]
const jsonData = XLSX.utils.sheet_to_json(worksheet)
setPreview(jsonData.slice(0, 5)) // Preview first 5 rows
setStep(2)
} catch (error) {
toast.error('خطأ في قراءة الملف - Error reading file')
}
}
reader.readAsBinaryString(uploadedFile)
}, [])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
'application/vnd.ms-excel': ['.xls'],
'text/csv': ['.csv']
},
maxFiles: 1
})
// Step 2: Preview and Confirm
const handleImport = async () => {
if (!file) return
setImporting(true)
setStep(3)
try {
const importResult = await contactsAPI.import(file)
setResult(importResult)
setStep(4)
if (importResult.success > 0) {
toast.success(`تم استيراد ${importResult.success} جهة اتصال بنجاح - Imported ${importResult.success} contacts successfully`)
onSuccess()
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'فشل الاستيراد - Import failed')
setStep(2)
} finally {
setImporting(false)
}
}
// Download error report
const downloadErrorReport = () => {
if (!result || result.errors.length === 0) return
const errorData = result.errors.map(err => ({
'Row': err.row,
'Field': err.field,
'Error': err.message,
'Data': JSON.stringify(err.data || {})
}))
const worksheet = XLSX.utils.json_to_sheet(errorData)
const workbook = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(workbook, worksheet, 'Errors')
XLSX.writeFile(workbook, `import-errors-${Date.now()}.xlsx`)
}
// Download template
const downloadTemplate = () => {
const template = [
{
type: 'INDIVIDUAL',
name: 'John Doe',
nameAr: 'جون دو',
email: 'john@example.com',
phone: '+966501234567',
mobile: '+966501234567',
website: 'https://example.com',
companyName: 'Acme Corp',
companyNameAr: 'شركة أكمي',
taxNumber: '123456789',
commercialRegister: 'CR123456',
address: '123 Main St',
city: 'Riyadh',
country: 'Saudi Arabia',
postalCode: '12345',
source: 'WEBSITE',
tags: 'vip,partner'
}
]
const worksheet = XLSX.utils.json_to_sheet(template)
const workbook = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(workbook, worksheet, 'Template')
XLSX.writeFile(workbook, 'contacts-import-template.xlsx')
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b">
<div>
<h2 className="text-2xl font-bold text-gray-900">
استيراد جهات الاتصال - Import Contacts
</h2>
<p className="text-sm text-gray-600 mt-1">
Step {step} of 4
</p>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X size={24} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{/* Step 1: Upload File */}
{step === 1 && (
<div className="space-y-6">
<div className="text-center mb-6">
<button
onClick={downloadTemplate}
className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-700 font-medium"
>
<Download size={18} />
تحميل قالب Excel - Download Excel Template
</button>
</div>
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-12 text-center cursor-pointer transition-colors ${
isDragActive
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 hover:border-gray-400'
}`}
>
<input {...getInputProps()} />
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
{isDragActive ? (
<p className="text-lg text-blue-600">
أفلت الملف هنا - Drop the file here
</p>
) : (
<>
<p className="text-lg text-gray-700 mb-2">
اسحب وأفلت ملف Excel أو CSV هنا - Drag & drop an Excel or CSV file here
</p>
<p className="text-sm text-gray-500">
أو انقر لتحديد ملف - or click to select a file
</p>
</>
)}
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 className="font-semibold text-blue-900 mb-2">
متطلبات الملف - File Requirements:
</h3>
<ul className="text-sm text-blue-800 space-y-1 list-disc list-inside">
<li>يجب أن يحتوي الملف على الأعمدة التالية: type, name, source</li>
<li>الأنواع المسموح بها: INDIVIDUAL, COMPANY, HOLDING, GOVERNMENT</li>
<li>سيتم تخطي جهات الاتصال المكررة (البريد الإلكتروني، الهاتف، الرقم الضريبي)</li>
<li>الحد الأقصى: 10,000 صف</li>
</ul>
</div>
</div>
)}
{/* Step 2: Preview */}
{step === 2 && (
<div className="space-y-6">
<div className="flex items-center gap-3 mb-4">
<FileSpreadsheet className="text-green-600" size={24} />
<div>
<p className="font-semibold text-gray-900">{file?.name}</p>
<p className="text-sm text-gray-600">
{preview.length} صفوف للمعاينة - rows to preview
</p>
</div>
</div>
<div className="border rounded-lg overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Type
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Name
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Email
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Phone
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Source
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{preview.map((row, idx) => (
<tr key={idx} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm text-gray-900">{row.type}</td>
<td className="px-4 py-3 text-sm text-gray-900">{row.name}</td>
<td className="px-4 py-3 text-sm text-gray-600">{row.email || '-'}</td>
<td className="px-4 py-3 text-sm text-gray-600">{row.phone || '-'}</td>
<td className="px-4 py-3 text-sm text-gray-600">{row.source}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="flex gap-2">
<AlertTriangle className="text-yellow-600 flex-shrink-0" size={20} />
<div className="text-sm text-yellow-800">
<p className="font-semibold mb-1">تنبيه - Warning:</p>
<p>
سيتم فحص جميع جهات الاتصال بحثاً عن التكرارات. سيتم تخطي جهات الاتصال المكررة وتسجيلها في تقرير الأخطاء.
</p>
</div>
</div>
</div>
</div>
)}
{/* Step 3: Importing */}
{step === 3 && (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-lg font-semibold text-gray-900 mb-2">
جاري الاستيراد - Importing...
</p>
<p className="text-sm text-gray-600">
يرجى الانتظار، قد تستغرق هذه العملية بضع دقائق - Please wait, this may take a few minutes
</p>
</div>
)}
{/* Step 4: Results */}
{step === 4 && result && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center gap-3">
<CheckCircle className="text-green-600" size={24} />
<div>
<p className="text-2xl font-bold text-green-900">{result.success}</p>
<p className="text-sm text-green-700">نجح - Successful</p>
</div>
</div>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="flex items-center gap-3">
<AlertTriangle className="text-yellow-600" size={24} />
<div>
<p className="text-2xl font-bold text-yellow-900">{result.duplicates}</p>
<p className="text-sm text-yellow-700">مكرر - Duplicates</p>
</div>
</div>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center gap-3">
<XCircle className="text-red-600" size={24} />
<div>
<p className="text-2xl font-bold text-red-900">{result.failed}</p>
<p className="text-sm text-red-700">فشل - Failed</p>
</div>
</div>
</div>
</div>
{result.errors.length > 0 && (
<div className="border rounded-lg overflow-hidden">
<div className="bg-gray-50 px-4 py-3 border-b flex items-center justify-between">
<h3 className="font-semibold text-gray-900">
الأخطاء ({result.errors.length}) - Errors ({result.errors.length})
</h3>
<button
onClick={downloadErrorReport}
className="inline-flex items-center gap-2 text-sm text-blue-600 hover:text-blue-700 font-medium"
>
<Download size={16} />
تحميل تقرير الأخطاء - Download Error Report
</button>
</div>
<div className="max-h-64 overflow-y-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Row
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Field
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Error
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{result.errors.slice(0, 50).map((error, idx) => (
<tr key={idx} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm text-gray-900">{error.row}</td>
<td className="px-4 py-3 text-sm text-gray-600">{error.field}</td>
<td className="px-4 py-3 text-sm text-red-600">{error.message}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="border-t p-6 flex justify-between">
{step === 1 && (
<button
onClick={onClose}
className="px-4 py-2 text-gray-700 hover:text-gray-900"
>
إلغاء - Cancel
</button>
)}
{step === 2 && (
<>
<button
onClick={() => {
setStep(1)
setFile(null)
setPreview([])
}}
className="px-4 py-2 text-gray-700 hover:text-gray-900"
>
رجوع - Back
</button>
<button
onClick={handleImport}
disabled={importing}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
ابدأ الاستيراد - Start Import
</button>
</>
)}
{step === 4 && (
<button
onClick={onClose}
className="ml-auto px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
إغلاق - Close
</button>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,189 @@
'use client'
import { useState, useEffect } from 'react'
import { AlertTriangle, ChevronDown, ChevronUp, ExternalLink } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { Contact, contactsAPI } from '@/lib/api/contacts'
import { format } from 'date-fns'
interface DuplicateAlertProps {
email?: string
phone?: string
mobile?: string
taxNumber?: string
commercialRegister?: string
excludeId?: string
onMerge?: (contactId: string) => void
}
export default function DuplicateAlert({
email,
phone,
mobile,
taxNumber,
commercialRegister,
excludeId,
onMerge
}: DuplicateAlertProps) {
const router = useRouter()
const [duplicates, setDuplicates] = useState<Contact[]>([])
const [loading, setLoading] = useState(false)
const [expanded, setExpanded] = useState(false)
const [dismissed, setDismissed] = useState(false)
useEffect(() => {
const checkDuplicates = async () => {
// Only check if we have at least one field to check
if (!email && !phone && !mobile && !taxNumber && !commercialRegister) {
setDuplicates([])
return
}
setLoading(true)
try {
const results = await contactsAPI.checkDuplicates({
email,
phone,
mobile,
taxNumber,
commercialRegister,
excludeId
})
setDuplicates(results)
setDismissed(false)
} catch (error) {
console.error('Error checking duplicates:', error)
setDuplicates([])
} finally {
setLoading(false)
}
}
// Debounce the duplicate check
const debounce = setTimeout(checkDuplicates, 800)
return () => clearTimeout(debounce)
}, [email, phone, mobile, taxNumber, commercialRegister, excludeId])
if (loading || duplicates.length === 0 || dismissed) {
return null
}
const getMatchingFields = (contact: Contact) => {
const matches: string[] = []
if (email && contact.email === email) matches.push('Email')
if (phone && contact.phone === phone) matches.push('Phone')
if (mobile && contact.mobile === mobile) matches.push('Mobile')
if (taxNumber && contact.taxNumber === taxNumber) matches.push('Tax Number')
if (commercialRegister && contact.commercialRegister === commercialRegister) matches.push('Commercial Register')
return matches
}
return (
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 rounded-r-lg">
<div className="flex items-start">
<AlertTriangle className="h-5 w-5 text-yellow-400 mt-0.5 mr-3 flex-shrink-0" />
<div className="flex-1">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-yellow-800">
تم العثور على جهات اتصال مشابهة - Potential Duplicates Found
</h3>
<p className="mt-1 text-sm text-yellow-700">
تم العثور على {duplicates.length} جهة اتصال مشابهة. يرجى المراجعة قبل المتابعة.
</p>
</div>
<button
onClick={() => setExpanded(!expanded)}
className="ml-4 text-yellow-700 hover:text-yellow-900"
>
{expanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
</button>
</div>
{expanded && (
<div className="mt-4 space-y-3">
{duplicates.map(contact => {
const matchingFields = getMatchingFields(contact)
return (
<div
key={contact.id}
className="bg-white border border-yellow-200 rounded-lg p-3"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold text-gray-900">
{contact.name}
</h4>
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded">
{contact.type}
</span>
</div>
<div className="mt-1 text-xs text-gray-600 space-y-0.5">
{contact.email && (
<p>
Email: <span className="font-medium">{contact.email}</span>
</p>
)}
{contact.phone && (
<p>
Phone: <span className="font-medium">{contact.phone}</span>
</p>
)}
{contact.mobile && (
<p>
Mobile: <span className="font-medium">{contact.mobile}</span>
</p>
)}
{contact.taxNumber && (
<p>
Tax Number: <span className="font-medium">{contact.taxNumber}</span>
</p>
)}
</div>
<div className="mt-2 flex items-center gap-2 flex-wrap">
<span className="text-xs text-yellow-700 font-medium">
Matching: {matchingFields.join(', ')}
</span>
<span className="text-xs text-gray-500">
Created {format(new Date(contact.createdAt), 'MMM d, yyyy')}
</span>
</div>
</div>
<button
onClick={() => {
window.open(`/contacts/${contact.id}`, '_blank')
}}
className="ml-2 text-blue-600 hover:text-blue-800"
title="View contact"
>
<ExternalLink size={16} />
</button>
</div>
</div>
)
})}
<div className="flex items-center gap-3 pt-2 border-t border-yellow-200">
{onMerge && duplicates.length > 0 && (
<button
onClick={() => onMerge(duplicates[0].id)}
className="text-sm text-blue-600 hover:text-blue-800 font-medium"
>
دمج بدلاً من ذلك - Merge Instead
</button>
)}
<button
onClick={() => setDismissed(true)}
className="text-sm text-gray-600 hover:text-gray-800 font-medium"
>
متابعة على أي حال - Continue Anyway
</button>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,245 @@
'use client'
import { useState, useEffect } from 'react'
import { toast } from 'react-hot-toast'
import { ChevronDown, ChevronRight, Building2, User, Plus, ExternalLink, Loader2 } from 'lucide-react'
import { contactsAPI, Contact } from '@/lib/api/contacts'
import Link from 'next/link'
interface HierarchyNode extends Contact {
children?: HierarchyNode[]
expanded?: boolean
}
interface HierarchyTreeProps {
rootContactId: string
}
export default function HierarchyTree({ rootContactId }: HierarchyTreeProps) {
const [root, setRoot] = useState<HierarchyNode | null>(null)
const [loading, setLoading] = useState(true)
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set([rootContactId]))
useEffect(() => {
fetchHierarchy()
}, [rootContactId])
const fetchHierarchy = async () => {
setLoading(true)
try {
// Fetch root contact
const rootContact = await contactsAPI.getById(rootContactId)
// Fetch all contacts to build hierarchy
const allContacts = await contactsAPI.getAll({ pageSize: 1000 })
// Build tree structure
const tree = buildTree(rootContact, allContacts.contacts)
setRoot(tree)
} catch (error: any) {
toast.error('Failed to load hierarchy')
} finally {
setLoading(false)
}
}
const buildTree = (rootContact: Contact, allContacts: Contact[]): HierarchyNode => {
const findChildren = (parentId: string): HierarchyNode[] => {
return allContacts
.filter(c => c.parentId === parentId)
.map(child => ({
...child,
children: findChildren(child.id),
expanded: expandedNodes.has(child.id)
}))
}
return {
...rootContact,
children: findChildren(rootContact.id),
expanded: expandedNodes.has(rootContact.id)
}
}
const toggleNode = (nodeId: string) => {
setExpandedNodes(prev => {
const newSet = new Set(prev)
if (newSet.has(nodeId)) {
newSet.delete(nodeId)
} else {
newSet.add(nodeId)
}
return newSet
})
}
const renderNode = (node: HierarchyNode, level: number = 0) => {
const hasChildren = node.children && node.children.length > 0
const isExpanded = expandedNodes.has(node.id)
const Icon = node.type === 'INDIVIDUAL' ? User : Building2
return (
<div key={node.id} className="select-none">
<div
className={`flex items-center gap-2 py-2 px-3 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors ${
level > 0 ? 'ml-8' : ''
}`}
style={{ paddingLeft: `${level * 32 + 12}px` }}
>
{/* Expand/Collapse Button */}
{hasChildren ? (
<button
onClick={() => toggleNode(node.id)}
className="flex-shrink-0 w-6 h-6 flex items-center justify-center text-gray-500 hover:text-gray-700"
>
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
) : (
<div className="w-6" />
)}
{/* Contact Icon */}
<Icon className="flex-shrink-0 text-blue-600" size={20} />
{/* Contact Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Link
href={`/contacts/${node.id}`}
className="font-medium text-gray-900 hover:text-blue-600 truncate"
>
{node.name}
</Link>
<span className="flex-shrink-0 text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded">
{node.type}
</span>
{node.id === rootContactId && (
<span className="flex-shrink-0 text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded font-medium">
Root
</span>
)}
</div>
{(node.email || node.phone) && (
<p className="text-sm text-gray-600 truncate">
{node.email || node.phone}
</p>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
<Link
href={`/contacts/${node.id}`}
target="_blank"
className="p-1 text-gray-400 hover:text-blue-600"
title="Open in new tab"
>
<ExternalLink size={16} />
</Link>
<Link
href={`/contacts?create=true&parentId=${node.id}`}
className="p-1 text-gray-400 hover:text-green-600"
title="Add child"
>
<Plus size={16} />
</Link>
</div>
</div>
{/* Render Children */}
{hasChildren && isExpanded && (
<div className="border-l-2 border-gray-200 ml-4">
{node.children!.map(child => renderNode(child, level + 1))}
</div>
)}
</div>
)
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="animate-spin text-blue-600" size={32} />
</div>
)
}
if (!root) {
return (
<div className="text-center py-12 text-gray-500">
<Building2 className="h-12 w-12 mx-auto mb-2 text-gray-300" />
<p>Failed to load hierarchy</p>
</div>
)
}
const totalNodes = root.children ? countNodes(root) : 1
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">
الهيكل التنظيمي - Company Hierarchy
</h3>
<p className="text-sm text-gray-600">
{totalNodes} contact{totalNodes !== 1 ? 's' : ''} in hierarchy
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setExpandedNodes(new Set(getAllNodeIds(root)))}
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
>
Expand All
</button>
<span className="text-gray-300">|</span>
<button
onClick={() => setExpandedNodes(new Set([rootContactId]))}
className="text-sm text-gray-600 hover:text-gray-700 font-medium"
>
Collapse All
</button>
</div>
</div>
<div className="bg-white border rounded-lg p-4">
{renderNode(root, 0)}
</div>
{root.children && root.children.length === 0 && (
<div className="text-center py-8 text-gray-500">
<p>No child contacts in this hierarchy</p>
<Link
href={`/contacts?create=true&parentId=${rootContactId}`}
className="mt-4 inline-flex items-center gap-2 text-blue-600 hover:text-blue-700 font-medium"
>
<Plus size={16} />
Add First Child Contact
</Link>
</div>
)}
</div>
)
}
// Helper functions
function countNodes(node: HierarchyNode): number {
let count = 1
if (node.children) {
node.children.forEach(child => {
count += countNodes(child)
})
}
return count
}
function getAllNodeIds(node: HierarchyNode): string[] {
let ids = [node.id]
if (node.children) {
node.children.forEach(child => {
ids = ids.concat(getAllNodeIds(child))
})
}
return ids
}

View File

@@ -0,0 +1,122 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Briefcase, FolderKanban, Calendar, Mail, Megaphone, Plus } from 'lucide-react'
import { Contact } from '@/lib/api/contacts'
import { toast } from 'react-hot-toast'
interface QuickActionsProps {
contact: Contact
}
export default function QuickActions({ contact }: QuickActionsProps) {
const router = useRouter()
const [loading, setLoading] = useState<string | null>(null)
const actions = [
{
id: 'deal',
label: 'Create Deal',
icon: Briefcase,
color: 'blue',
action: () => {
// Navigate to CRM deals page with contact pre-filled
router.push(`/crm?action=create-deal&contactId=${contact.id}`)
}
},
{
id: 'project',
label: 'Create Project',
icon: FolderKanban,
color: 'green',
action: () => {
// Navigate to Projects page with client pre-filled
router.push(`/projects?action=create&clientId=${contact.id}`)
}
},
{
id: 'activity',
label: 'Schedule Activity',
icon: Calendar,
color: 'purple',
action: () => {
// TODO: Open activity creation modal/page
toast.success('Activity scheduling coming soon')
}
},
{
id: 'email',
label: 'Send Email',
icon: Mail,
color: 'orange',
action: () => {
if (contact.email) {
// Open email client
window.location.href = `mailto:${contact.email}`
} else {
toast.error('Contact has no email address')
}
}
},
{
id: 'campaign',
label: 'Add to Campaign',
icon: Megaphone,
color: 'pink',
action: () => {
// Navigate to marketing campaigns
router.push(`/marketing?action=add-to-campaign&contactId=${contact.id}`)
}
}
]
const getColorClasses = (color: string) => {
const colors: Record<string, string> = {
blue: 'bg-blue-50 text-blue-700 hover:bg-blue-100 border-blue-200',
green: 'bg-green-50 text-green-700 hover:bg-green-100 border-green-200',
purple: 'bg-purple-50 text-purple-700 hover:bg-purple-100 border-purple-200',
orange: 'bg-orange-50 text-orange-700 hover:bg-orange-100 border-orange-200',
pink: 'bg-pink-50 text-pink-700 hover:bg-pink-100 border-pink-200'
}
return colors[color] || colors.blue
}
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center gap-2 mb-4">
<Plus className="h-5 w-5 text-gray-600" />
<h3 className="text-lg font-semibold text-gray-900">Quick Actions</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{actions.map((action) => {
const Icon = action.icon
const isLoading = loading === action.id
return (
<button
key={action.id}
onClick={() => {
setLoading(action.id)
action.action()
setTimeout(() => setLoading(null), 1000)
}}
disabled={isLoading}
className={`flex items-center gap-3 p-4 border rounded-lg transition-colors ${getColorClasses(action.color)} disabled:opacity-50`}
>
<Icon className="h-5 w-5" />
<span className="font-medium text-sm">{action.label}</span>
</button>
)
})}
</div>
<div className="mt-4 pt-4 border-t border-gray-200">
<p className="text-xs text-gray-500 text-center">
Quick actions allow you to create related records with this contact pre-filled
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,608 @@
'use client'
import { useState, useEffect } from 'react'
import { toast } from 'react-hot-toast'
import {
Plus,
Edit,
Trash2,
Search,
X,
Calendar,
User,
Building2,
Loader2,
ExternalLink
} from 'lucide-react'
import { contactsAPI } from '@/lib/api/contacts'
import { format } from 'date-fns'
import Link from 'next/link'
interface Relationship {
id: string
fromContactId: string
toContactId: string
type: string
startDate: string
endDate?: string
notes?: string
isActive: boolean
fromContact: {
id: string
uniqueContactId: string
type: string
name: string
email?: string
phone?: string
status: string
}
toContact: {
id: string
uniqueContactId: string
type: string
name: string
email?: string
phone?: string
status: string
}
}
interface RelationshipManagerProps {
contactId: string
}
const RELATIONSHIP_TYPES = [
{ value: 'REPRESENTATIVE', label: 'Representative - ممثل' },
{ value: 'PARTNER', label: 'Partner - شريك' },
{ value: 'SUPPLIER', label: 'Supplier - مورد' },
{ value: 'EMPLOYEE', label: 'Employee - موظف' },
{ value: 'SUBSIDIARY', label: 'Subsidiary - فرع' },
{ value: 'BRANCH', label: 'Branch - فرع' },
{ value: 'PARENT_COMPANY', label: 'Parent Company - شركة أم' },
{ value: 'CUSTOMER', label: 'Customer - عميل' },
{ value: 'VENDOR', label: 'Vendor - بائع' },
{ value: 'OTHER', label: 'Other - أخرى' },
]
export default function RelationshipManager({ contactId }: RelationshipManagerProps) {
const [relationships, setRelationships] = useState<Relationship[]>([])
const [loading, setLoading] = useState(true)
const [showAddModal, setShowAddModal] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
const [selectedRelationship, setSelectedRelationship] = useState<Relationship | null>(null)
const [submitting, setSubmitting] = useState(false)
// Form state
const [searchTerm, setSearchTerm] = useState('')
const [searchResults, setSearchResults] = useState<any[]>([])
const [searching, setSearching] = useState(false)
const [formData, setFormData] = useState({
toContactId: '',
type: 'REPRESENTATIVE',
startDate: new Date().toISOString().split('T')[0],
endDate: '',
notes: ''
})
useEffect(() => {
fetchRelationships()
}, [contactId])
const fetchRelationships = async () => {
setLoading(true)
try {
const data = await contactsAPI.getRelationships(contactId)
setRelationships(data)
} catch (error: any) {
toast.error('Failed to load relationships')
} finally {
setLoading(false)
}
}
// Search contacts with debouncing
useEffect(() => {
if (!searchTerm || searchTerm.length < 2) {
setSearchResults([])
return
}
const debounce = setTimeout(async () => {
setSearching(true)
try {
const data = await contactsAPI.getAll({ search: searchTerm, pageSize: 10 })
setSearchResults(data.contacts.filter(c => c.id !== contactId))
} catch (error) {
console.error('Search error:', error)
} finally {
setSearching(false)
}
}, 500)
return () => clearTimeout(debounce)
}, [searchTerm, contactId])
const resetForm = () => {
setFormData({
toContactId: '',
type: 'REPRESENTATIVE',
startDate: new Date().toISOString().split('T')[0],
endDate: '',
notes: ''
})
setSearchTerm('')
setSearchResults([])
setSelectedRelationship(null)
}
const handleAdd = async () => {
if (!formData.toContactId) {
toast.error('Please select a contact')
return
}
setSubmitting(true)
try {
await contactsAPI.addRelationship(contactId, {
toContactId: formData.toContactId,
type: formData.type,
startDate: formData.startDate,
endDate: formData.endDate || undefined,
notes: formData.notes || undefined
})
toast.success('Relationship added successfully')
setShowAddModal(false)
resetForm()
fetchRelationships()
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to add relationship')
} finally {
setSubmitting(false)
}
}
const handleEdit = async () => {
if (!selectedRelationship) return
setSubmitting(true)
try {
await contactsAPI.updateRelationship(
contactId,
selectedRelationship.id,
{
type: formData.type,
startDate: formData.startDate,
endDate: formData.endDate || undefined,
notes: formData.notes || undefined
}
)
toast.success('Relationship updated successfully')
setShowEditModal(false)
resetForm()
fetchRelationships()
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to update relationship')
} finally {
setSubmitting(false)
}
}
const handleDelete = async (relationship: Relationship) => {
if (!confirm('Are you sure you want to delete this relationship?')) return
try {
await contactsAPI.deleteRelationship(contactId, relationship.id)
toast.success('Relationship deleted successfully')
fetchRelationships()
} catch (error: any) {
toast.error('Failed to delete relationship')
}
}
const openEditModal = (relationship: Relationship) => {
setSelectedRelationship(relationship)
setFormData({
toContactId: relationship.toContactId,
type: relationship.type,
startDate: relationship.startDate.split('T')[0],
endDate: relationship.endDate ? relationship.endDate.split('T')[0] : '',
notes: relationship.notes || ''
})
setShowEditModal(true)
}
const getRelatedContact = (relationship: Relationship) => {
return relationship.fromContactId === contactId
? relationship.toContact
: relationship.fromContact
}
const getRelationshipDirection = (relationship: Relationship) => {
return relationship.fromContactId === contactId ? '→' : '←'
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="animate-spin text-blue-600" size={32} />
</div>
)
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">
العلاقات - Relationships ({relationships.length})
</h3>
<button
onClick={() => {
resetForm()
setShowAddModal(true)
}}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus size={18} />
Add Relationship
</button>
</div>
{/* Relationships List */}
{relationships.length === 0 ? (
<div className="text-center py-12 bg-gray-50 rounded-lg">
<User className="mx-auto h-12 w-12 text-gray-400 mb-4" />
<p className="text-gray-600">No relationships found</p>
<button
onClick={() => setShowAddModal(true)}
className="mt-4 text-blue-600 hover:text-blue-700 font-medium"
>
Add your first relationship
</button>
</div>
) : (
<div className="bg-white border rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Contact
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Start Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
End Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Status
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{relationships.map(relationship => {
const relatedContact = getRelatedContact(relationship)
return (
<tr key={relationship.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<span className="text-gray-500">
{getRelationshipDirection(relationship)}
</span>
{relatedContact.type === 'INDIVIDUAL' ? (
<User size={18} className="text-gray-400" />
) : (
<Building2 size={18} className="text-gray-400" />
)}
<div>
<Link
href={`/contacts/${relatedContact.id}`}
className="font-medium text-gray-900 hover:text-blue-600 flex items-center gap-1"
>
{relatedContact.name}
<ExternalLink size={14} />
</Link>
<p className="text-sm text-gray-600">{relatedContact.email || relatedContact.phone}</p>
</div>
</div>
</td>
<td className="px-6 py-4">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{relationship.type.replace(/_/g, ' ')}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{format(new Date(relationship.startDate), 'MMM d, yyyy')}
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{relationship.endDate ? format(new Date(relationship.endDate), 'MMM d, yyyy') : '-'}
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
relationship.isActive
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{relationship.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => openEditModal(relationship)}
className="text-blue-600 hover:text-blue-900"
>
<Edit size={18} />
</button>
<button
onClick={() => handleDelete(relationship)}
className="text-red-600 hover:text-red-900"
>
<Trash2 size={18} />
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{/* Add Modal */}
{showAddModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-xl font-bold text-gray-900">
إضافة علاقة - Add Relationship
</h2>
<button onClick={() => { setShowAddModal(false); resetForm(); }} className="text-gray-400 hover:text-gray-600">
<X size={24} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{/* Contact Search */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Select Contact <span className="text-red-500">*</span>
</label>
<div className="relative">
<Search className="absolute left-3 top-3 text-gray-400" size={18} />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search contacts..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900"
/>
</div>
{searching && (
<p className="text-sm text-gray-600 mt-2">Searching...</p>
)}
{searchResults.length > 0 && (
<div className="mt-2 border rounded-lg max-h-48 overflow-y-auto">
{searchResults.map(contact => (
<button
key={contact.id}
onClick={() => {
setFormData({ ...formData, toContactId: contact.id })
setSearchTerm(contact.name)
setSearchResults([])
}}
className="w-full text-left p-3 hover:bg-gray-50 border-b last:border-b-0"
>
<div className="flex items-center gap-2">
{contact.type === 'INDIVIDUAL' ? <User size={16} /> : <Building2 size={16} />}
<div>
<p className="font-medium text-gray-900">{contact.name}</p>
<p className="text-sm text-gray-600">{contact.email || contact.phone}</p>
</div>
</div>
</button>
))}
</div>
)}
</div>
{/* Relationship Type */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Relationship 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 bg-white text-gray-900"
>
{RELATIONSHIP_TYPES.map(type => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</div>
{/* Start Date */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Start Date <span className="text-red-500">*</span>
</label>
<input
type="date"
value={formData.startDate}
onChange={(e) => setFormData({ ...formData, startDate: 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 text-gray-900"
/>
</div>
{/* End Date */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
End Date (Optional)
</label>
<input
type="date"
value={formData.endDate}
onChange={(e) => setFormData({ ...formData, endDate: 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 text-gray-900"
/>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notes (Optional)
</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900"
placeholder="Add any notes about this relationship..."
/>
</div>
</div>
<div className="border-t p-6 flex justify-end gap-3">
<button
onClick={() => { setShowAddModal(false); resetForm(); }}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
disabled={submitting}
>
Cancel
</button>
<button
onClick={handleAdd}
disabled={submitting || !formData.toContactId}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{submitting ? (
<>
<Loader2 className="animate-spin" size={18} />
Adding...
</>
) : (
'Add Relationship'
)}
</button>
</div>
</div>
</div>
)}
{/* Edit Modal */}
{showEditModal && selectedRelationship && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl">
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-xl font-bold text-gray-900">
تعديل العلاقة - Edit Relationship
</h2>
<button onClick={() => { setShowEditModal(false); resetForm(); }} className="text-gray-400 hover:text-gray-600">
<X size={24} />
</button>
</div>
<div className="p-6 space-y-4">
{/* Contact (Read-only) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Contact
</label>
<div className="px-3 py-2 bg-gray-50 border border-gray-300 rounded-lg text-gray-600">
{getRelatedContact(selectedRelationship).name}
</div>
</div>
{/* Relationship Type */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Relationship Type
</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 bg-white text-gray-900"
>
{RELATIONSHIP_TYPES.map(type => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</div>
{/* Start Date */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Start Date
</label>
<input
type="date"
value={formData.startDate}
onChange={(e) => setFormData({ ...formData, startDate: 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 text-gray-900"
/>
</div>
{/* End Date */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
End Date
</label>
<input
type="date"
value={formData.endDate}
onChange={(e) => setFormData({ ...formData, endDate: 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 text-gray-900"
/>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notes
</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900"
/>
</div>
</div>
<div className="border-t p-6 flex justify-end gap-3">
<button
onClick={() => { setShowEditModal(false); resetForm(); }}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
disabled={submitting}
>
Cancel
</button>
<button
onClick={handleEdit}
disabled={submitting}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{submitting ? (
<>
<Loader2 className="animate-spin" size={18} />
Updating...
</>
) : (
'Update Relationship'
)}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,541 @@
'use client'
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
type Language = 'en' | 'ar'
interface LanguageContextType {
language: Language
setLanguage: (lang: Language) => void
t: (key: string) => string
dir: 'ltr' | 'rtl'
}
const LanguageContext = createContext<LanguageContextType | undefined>(undefined)
export function LanguageProvider({ children }: { children: ReactNode }) {
const [language, setLanguageState] = useState<Language>('en')
useEffect(() => {
// Load language from localStorage
const savedLang = localStorage.getItem('language') as Language
if (savedLang && (savedLang === 'en' || savedLang === 'ar')) {
setLanguageState(savedLang)
}
}, [])
useEffect(() => {
// Update document direction and lang attribute
document.documentElement.lang = language
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
}, [language])
const setLanguage = (lang: Language) => {
setLanguageState(lang)
localStorage.setItem('language', lang)
}
const t = (key: string): string => {
const keys = key.split('.')
let value: any = translations[language]
for (const k of keys) {
value = value?.[k]
}
return value || key
}
return (
<LanguageContext.Provider
value={{
language,
setLanguage,
t,
dir: language === 'ar' ? 'rtl' : 'ltr'
}}
>
{children}
</LanguageContext.Provider>
)
}
export function useLanguage() {
const context = useContext(LanguageContext)
if (!context) {
throw new Error('useLanguage must be used within a LanguageProvider')
}
return context
}
// Translation dictionary
const translations = {
en: {
common: {
save: 'Save',
cancel: 'Cancel',
delete: 'Delete',
edit: 'Edit',
add: 'Add',
search: 'Search',
filter: 'Filter',
export: 'Export',
import: 'Import',
loading: 'Loading...',
noData: 'No data available',
error: 'An error occurred',
success: 'Success',
confirm: 'Confirm',
back: 'Back',
next: 'Next',
finish: 'Finish',
close: 'Close',
yes: 'Yes',
no: 'No',
required: 'Required',
optional: 'Optional',
actions: 'Actions',
status: 'Status',
active: 'Active',
inactive: 'Inactive',
archived: 'Archived',
deleted: 'Deleted'
},
nav: {
dashboard: 'Dashboard',
contacts: 'Contacts',
crm: 'CRM',
projects: 'Projects',
inventory: 'Inventory',
hr: 'HR',
marketing: 'Marketing',
settings: 'Settings',
logout: 'Logout'
},
contacts: {
title: 'Contacts',
addContact: 'Add Contact',
editContact: 'Edit Contact',
deleteContact: 'Delete Contact',
viewContact: 'View Contact',
mergeContacts: 'Merge Contacts',
importContacts: 'Import Contacts',
exportContacts: 'Export Contacts',
totalContacts: 'Total Contacts',
searchPlaceholder: 'Search by name, email, or phone...',
noContactsFound: 'No contacts found',
contactDetails: 'Contact Details',
contactInfo: 'Contact Information',
companyInfo: 'Company Information',
address: 'Address',
categories: 'Categories & Tags',
relationships: 'Relationships',
hierarchy: 'Hierarchy',
activities: 'Activities',
history: 'History',
type: 'Type',
name: 'Name',
nameAr: 'Name (Arabic)',
email: 'Email',
phone: 'Phone',
mobile: 'Mobile',
website: 'Website',
companyName: 'Company Name',
companyNameAr: 'Company Name (Arabic)',
taxNumber: 'Tax Number',
commercialRegister: 'Commercial Register',
city: 'City',
country: 'Country',
postalCode: 'Postal Code',
source: 'Source',
rating: 'Rating',
tags: 'Tags',
individual: 'Individual',
company: 'Company',
holding: 'Holding',
government: 'Government',
addRelationship: 'Add Relationship',
relationshipType: 'Relationship Type',
startDate: 'Start Date',
endDate: 'End Date',
notes: 'Notes',
representative: 'Representative',
partner: 'Partner',
supplier: 'Supplier',
employee: 'Employee',
subsidiary: 'Subsidiary',
branch: 'Branch',
parentCompany: 'Parent Company',
customer: 'Customer',
vendor: 'Vendor',
companyEmployee: 'Company Employee',
other: 'Other',
duplicateFound: 'Potential Duplicates Found',
duplicateWarning: 'Similar contacts found. Please review before continuing.',
mergeInstead: 'Merge Instead',
continueAnyway: 'Continue Anyway',
sourceContact: 'Source Contact',
targetContact: 'Target Contact',
compareFields: 'Compare Fields',
preview: 'Preview',
mergeWarning: 'This action cannot be undone!',
mergeReason: 'Reason for Merge',
mergeSuccess: 'Contacts merged successfully!',
importSuccess: 'Contacts imported successfully',
exportSuccess: 'Contacts exported successfully',
deleteConfirm: 'Are you sure you want to delete this contact?',
deleteSuccess: 'Contact deleted successfully',
createSuccess: 'Contact created successfully',
updateSuccess: 'Contact updated successfully'
},
crm: {
title: 'CRM',
subtitle: 'CRM & Sales Pipeline',
addDeal: 'Add Deal',
editDeal: 'Edit Deal',
dealName: 'Deal Name',
contact: 'Contact',
structure: 'Deal Structure',
pipeline: 'Pipeline',
stage: 'Stage',
probability: 'Probability',
estimatedValue: 'Estimated Value (SAR)',
expectedCloseDate: 'Expected Close Date',
searchPlaceholder: 'Search deals...',
filterStructure: 'Structure',
filterStage: 'Stage',
filterStatus: 'Status',
all: 'All',
view: 'View',
win: 'Win',
lose: 'Lose',
archive: 'Archive',
deleteDeal: 'Delete Deal',
markWon: 'Mark as Won',
markLost: 'Mark as Lost',
actualValue: 'Actual Value (SAR)',
wonReason: 'Reason Won',
lostReason: 'Reason Lost',
noDealsFound: 'No deals found',
createSuccess: 'Deal created successfully',
updateSuccess: 'Deal updated successfully',
winSuccess: 'Deal won successfully',
loseSuccess: 'Deal marked as lost',
deleteSuccess: 'Deal archived successfully',
fixFormErrors: 'Please fix form errors',
pipelineRequired: 'Pipeline is required',
dealNameMin: 'Deal name must be at least 3 characters',
contactRequired: 'Contact is required',
structureRequired: 'Deal structure is required',
stageRequired: 'Stage is required',
valueRequired: 'Estimated value must be greater than 0',
selectPipeline: 'Select Pipeline',
selectContact: 'Select Contact',
enterDealName: 'Enter deal name',
structureB2B: 'B2B - شركة لشركة',
structureB2C: 'B2C - شركة لفرد',
structureB2G: 'B2G - شركة لحكومة',
structurePartnership: 'Partnership - شراكة',
dealDetail: 'Deal Details',
quotes: 'Quotes',
history: 'History',
dealInfo: 'Deal Info',
quickActions: 'Quick Actions',
totalValue: 'Total Value',
expectedValue: 'Expected Value',
activeDeals: 'Active Deals',
wonDeals: 'Won Deals',
inPipeline: 'In pipeline',
winRate: 'win rate',
conversion: 'conversion',
retry: 'Retry',
createFirstDeal: 'Create First Deal',
loadingDeals: 'Loading deals...',
creating: 'Creating...',
updating: 'Updating...',
updateDeal: 'Update Deal',
createDeal: 'Create Deal',
newDeal: 'New Deal',
allStructures: 'All Structures',
allStages: 'All Stages',
allStatus: 'All Status',
deal: 'Deal',
value: 'Value',
owner: 'Owner',
markDealWon: 'Mark Deal as Won',
markDealLost: 'Mark Deal as Lost',
reasonForWinning: 'Reason for Winning',
reasonForLosing: 'Reason for Losing',
winPlaceholder: 'Why did we win this deal?',
losePlaceholder: 'Why did we lose this deal?',
createNewDeal: 'Create New Deal',
paginationPrevious: 'Previous',
paginationNext: 'Next',
processing: 'Processing...',
deleting: 'Deleting...',
deleteDealConfirm: 'Are you sure you want to delete',
deleteDealDesc: 'This will mark the deal as lost'
},
import: {
title: 'Import Contacts',
downloadTemplate: 'Download Excel Template',
dragDrop: 'Drag & drop an Excel or CSV file here',
orClick: 'or click to select a file',
fileRequirements: 'File Requirements:',
step: 'Step',
uploading: 'Uploading...',
importing: 'Importing...',
rowsPreview: 'rows to preview',
warning: 'Warning',
duplicateHandling: 'Duplicate contacts will be skipped and logged in the error report.',
results: 'Results',
successful: 'Successful',
duplicates: 'Duplicates',
failed: 'Failed',
errors: 'Errors',
downloadErrorReport: 'Download Error Report',
importComplete: 'Import completed'
},
messages: {
loginSuccess: 'Login successful',
loginError: 'Invalid credentials',
networkError: 'Network error. Please check your connection.',
permissionDenied: 'Permission denied',
sessionExpired: 'Session expired. Please login again.'
}
},
ar: {
common: {
save: 'حفظ',
cancel: 'إلغاء',
delete: 'حذف',
edit: 'تعديل',
add: 'إضافة',
search: 'بحث',
filter: 'تصفية',
export: 'تصدير',
import: 'استيراد',
loading: 'جاري التحميل...',
noData: 'لا توجد بيانات',
error: 'حدث خطأ',
success: 'نجح',
confirm: 'تأكيد',
back: 'رجوع',
next: 'التالي',
finish: 'إنهاء',
close: 'إغلاق',
yes: 'نعم',
no: 'لا',
required: 'مطلوب',
optional: 'اختياري',
actions: 'إجراءات',
status: 'الحالة',
active: 'نشط',
inactive: 'غير نشط',
archived: 'مؤرشف',
deleted: 'محذوف'
},
nav: {
dashboard: 'لوحة التحكم',
contacts: 'جهات الاتصال',
crm: 'إدارة العملاء',
projects: 'المشاريع',
inventory: 'المخزون',
hr: 'الموارد البشرية',
marketing: 'التسويق',
settings: 'الإعدادات',
logout: 'تسجيل الخروج'
},
contacts: {
title: 'جهات الاتصال',
addContact: 'إضافة جهة اتصال',
editContact: 'تعديل جهة الاتصال',
deleteContact: 'حذف جهة الاتصال',
viewContact: 'عرض جهة الاتصال',
mergeContacts: 'دمج جهات الاتصال',
importContacts: 'استيراد جهات الاتصال',
exportContacts: 'تصدير جهات الاتصال',
totalContacts: 'إجمالي جهات الاتصال',
searchPlaceholder: 'البحث بالاسم أو البريد الإلكتروني أو الهاتف...',
noContactsFound: 'لم يتم العثور على جهات اتصال',
contactDetails: 'تفاصيل جهة الاتصال',
contactInfo: 'معلومات الاتصال',
companyInfo: 'معلومات الشركة',
address: 'العنوان',
categories: 'الفئات والعلامات',
relationships: 'العلاقات',
hierarchy: 'الهيكل التنظيمي',
activities: 'الأنشطة',
history: 'السجل',
type: 'النوع',
name: 'الاسم',
nameAr: 'الاسم (بالعربية)',
email: 'البريد الإلكتروني',
phone: 'الهاتف',
mobile: 'الجوال',
website: 'الموقع الإلكتروني',
companyName: 'اسم الشركة',
companyNameAr: 'اسم الشركة (بالعربية)',
taxNumber: 'الرقم الضريبي',
commercialRegister: 'السجل التجاري',
city: 'المدينة',
country: 'الدولة',
postalCode: 'الرمز البريدي',
source: 'المصدر',
rating: 'التقييم',
tags: 'العلامات',
individual: 'فرد',
company: 'شركة',
holding: 'مجموعة',
government: 'حكومي',
addRelationship: 'إضافة علاقة',
relationshipType: 'نوع العلاقة',
startDate: 'تاريخ البداية',
endDate: 'تاريخ النهاية',
notes: 'ملاحظات',
representative: 'ممثل',
partner: 'شريك',
supplier: 'مورد',
employee: 'موظف',
subsidiary: 'فرع تابع',
branch: 'فرع',
parentCompany: 'الشركة الأم',
customer: 'عميل',
vendor: 'بائع',
companyEmployee: 'موظف الشركة',
other: 'أخرى',
duplicateFound: 'تم العثور على جهات اتصال مشابهة',
duplicateWarning: 'تم العثور على جهات اتصال مشابهة. يرجى المراجعة قبل المتابعة.',
mergeInstead: 'دمج بدلاً من ذلك',
continueAnyway: 'متابعة على أي حال',
sourceContact: 'جهة الاتصال المصدر',
targetContact: 'جهة الاتصال الهدف',
compareFields: 'مقارنة الحقول',
preview: 'معاينة',
mergeWarning: 'لا يمكن التراجع عن هذا الإجراء!',
mergeReason: 'سبب الدمج',
mergeSuccess: 'تم دمج جهات الاتصال بنجاح!',
importSuccess: 'تم استيراد جهات الاتصال بنجاح',
exportSuccess: 'تم تصدير جهات الاتصال بنجاح',
deleteConfirm: 'هل أنت متأكد من حذف جهة الاتصال هذه؟',
deleteSuccess: 'تم حذف جهة الاتصال بنجاح',
createSuccess: 'تم إنشاء جهة الاتصال بنجاح',
updateSuccess: 'تم تحديث جهة الاتصال بنجاح'
},
crm: {
title: 'إدارة العملاء',
subtitle: 'إدارة العلاقات والمبيعات',
addDeal: 'إضافة صفقة',
editDeal: 'تعديل الصفقة',
dealName: 'اسم الصفقة',
contact: 'جهة الاتصال',
structure: 'هيكل الصفقة',
pipeline: 'مسار المبيعات',
stage: 'المرحلة',
probability: 'احتمالية الفوز',
estimatedValue: 'القيمة المقدرة (ر.س)',
expectedCloseDate: 'تاريخ الإغلاق المتوقع',
searchPlaceholder: 'البحث في الصفقات...',
filterStructure: 'الهيكل',
filterStage: 'المرحلة',
filterStatus: 'الحالة',
all: 'الكل',
view: 'عرض',
win: 'فوز',
lose: 'خسارة',
archive: 'أرشفة',
deleteDeal: 'حذف الصفقة',
markWon: 'تحديد كفائز',
markLost: 'تحديد كخاسر',
actualValue: 'القيمة الفعلية (ر.س)',
wonReason: 'سبب الفوز',
lostReason: 'سبب الخسارة',
noDealsFound: 'لم يتم العثور على صفقات',
createSuccess: 'تم إنشاء الصفقة بنجاح',
updateSuccess: 'تم تحديث الصفقة بنجاح',
winSuccess: 'تم الفوز بالصفقة بنجاح',
loseSuccess: 'تم تحديد الصفقة كخاسرة',
deleteSuccess: 'تم أرشفة الصفقة بنجاح',
fixFormErrors: 'يرجى إصلاح أخطاء النموذج',
pipelineRequired: 'مسار المبيعات مطلوب',
dealNameMin: 'اسم الصفقة يجب أن يكون 3 أحرف على الأقل',
contactRequired: 'جهة الاتصال مطلوبة',
structureRequired: 'هيكل الصفقة مطلوب',
stageRequired: 'المرحلة مطلوبة',
valueRequired: 'القيمة المقدرة يجب أن تكون أكبر من 0',
selectPipeline: 'اختر المسار',
selectContact: 'اختر جهة الاتصال',
enterDealName: 'أدخل اسم الصفقة',
structureB2B: 'B2B - شركة لشركة',
structureB2C: 'B2C - شركة لفرد',
structureB2G: 'B2G - شركة لحكومة',
structurePartnership: 'شراكة - Partnership',
dealDetail: 'تفاصيل الصفقة',
quotes: 'عروض الأسعار',
history: 'السجل',
dealInfo: 'معلومات الصفقة',
quickActions: 'إجراءات سريعة',
totalValue: 'إجمالي القيمة',
expectedValue: 'القيمة المتوقعة',
activeDeals: 'الصفقات النشطة',
wonDeals: 'الصفقات الرابحة',
inPipeline: 'في المسار',
winRate: 'معدل الفوز',
conversion: 'التحويل',
retry: 'إعادة المحاولة',
createFirstDeal: 'إنشاء أول صفقة',
loadingDeals: 'جاري تحميل الصفقات...',
creating: 'جاري الإنشاء...',
updating: 'جاري التحديث...',
updateDeal: 'تحديث الصفقة',
createDeal: 'إنشاء الصفقة',
newDeal: 'صفقة جديدة',
allStructures: 'جميع الهياكل',
allStages: 'جميع المراحل',
allStatus: 'جميع الحالات',
deal: 'الصفقة',
value: 'القيمة',
owner: 'المالك',
markDealWon: 'تحديد الصفقة كرابحة',
markDealLost: 'تحديد الصفقة كخاسرة',
reasonForWinning: 'سبب الفوز',
reasonForLosing: 'سبب الخسارة',
winPlaceholder: 'لماذا ربحنا هذه الصفقة؟',
losePlaceholder: 'لماذا خسرنا هذه الصفقة؟',
createNewDeal: 'إنشاء صفقة جديدة',
paginationPrevious: 'السابق',
paginationNext: 'التالي',
processing: 'جاري المعالجة...',
deleting: 'جاري الحذف...',
deleteDealConfirm: 'هل أنت متأكد من حذف',
deleteDealDesc: 'سيتم تحديد الصفقة كخاسرة'
},
import: {
title: 'استيراد جهات الاتصال',
downloadTemplate: 'تحميل قالب Excel',
dragDrop: 'اسحب وأفلت ملف Excel أو CSV هنا',
orClick: 'أو انقر لتحديد ملف',
fileRequirements: 'متطلبات الملف:',
step: 'خطوة',
uploading: 'جاري الرفع...',
importing: 'جاري الاستيراد...',
rowsPreview: 'صفوف للمعاينة',
warning: 'تنبيه',
duplicateHandling: 'سيتم تخطي جهات الاتصال المكررة وتسجيلها في تقرير الأخطاء.',
results: 'النتائج',
successful: 'ناجح',
duplicates: 'مكرر',
failed: 'فشل',
errors: 'أخطاء',
downloadErrorReport: 'تحميل تقرير الأخطاء',
importComplete: 'اكتمل الاستيراد'
},
messages: {
loginSuccess: 'تم تسجيل الدخول بنجاح',
loginError: 'بيانات الدخول غير صحيحة',
networkError: 'خطأ في الشبكة. يرجى التحقق من الاتصال.',
permissionDenied: 'غير مصرح',
sessionExpired: 'انتهت الجلسة. يرجى تسجيل الدخول مرة أخرى.'
}
}
}

View File

@@ -0,0 +1,65 @@
import { api } from '../api'
export interface Category {
id: string
name: string
nameAr?: string
parentId?: string
parent?: Category
children?: Category[]
description?: string
isActive: boolean
createdAt: string
updatedAt: string
_count?: {
contacts: number
}
}
export interface CreateCategoryData {
name: string
nameAr?: string
parentId?: string
description?: string
}
export interface UpdateCategoryData extends Partial<CreateCategoryData> {
isActive?: boolean
}
export const categoriesAPI = {
// Get all categories (flat list)
getAll: async (): Promise<Category[]> => {
const response = await api.get('/contacts/categories')
return response.data.data
},
// Get category tree (hierarchical)
getTree: async (): Promise<Category[]> => {
const response = await api.get('/contacts/categories/tree')
return response.data.data
},
// Get single category by ID
getById: async (id: string): Promise<Category> => {
const response = await api.get(`/contacts/categories/${id}`)
return response.data.data
},
// Create new category
create: async (data: CreateCategoryData): Promise<Category> => {
const response = await api.post('/contacts/categories', data)
return response.data.data
},
// Update existing category
update: async (id: string, data: UpdateCategoryData): Promise<Category> => {
const response = await api.put(`/contacts/categories/${id}`, data)
return response.data.data
},
// Delete category
delete: async (id: string): Promise<void> => {
await api.delete(`/contacts/categories/${id}`)
}
}

View File

@@ -25,6 +25,9 @@ export interface Contact {
customFields?: any
categories?: any[]
parent?: any
parentId?: string
employeeId?: string | null
employee?: { id: string; firstName: string; lastName: string; email: string; uniqueEmployeeId?: string }
createdAt: string
updatedAt: string
createdBy?: any
@@ -49,6 +52,7 @@ export interface CreateContactData {
categories?: string[]
tags?: string[]
parentId?: string
employeeId?: string | null
source: string
customFields?: any
}
@@ -143,11 +147,13 @@ export const contactsAPI = {
},
// Export contacts
export: async (filters: ContactFilters = {}): Promise<Blob> => {
export: async (filters: ContactFilters & { excludeCompanyEmployees?: boolean } = {}): Promise<Blob> => {
const params = new URLSearchParams()
if (filters.search) params.append('search', filters.search)
if (filters.type) params.append('type', filters.type)
if (filters.status) params.append('status', filters.status)
if (filters.category) params.append('category', filters.category)
if (filters.excludeCompanyEmployees) params.append('excludeCompanyEmployees', 'true')
const response = await api.get(`/contacts/export?${params.toString()}`, {
responseType: 'blob'
@@ -156,7 +162,12 @@ export const contactsAPI = {
},
// Import contacts
import: async (file: File): Promise<{ success: number; errors: any[] }> => {
import: async (file: File): Promise<{
success: number
failed: number
duplicates: number
errors: Array<{ row: number; field: string; message: string; data?: any }>
}> => {
const formData = new FormData()
formData.append('file', file)
@@ -166,6 +177,51 @@ export const contactsAPI = {
}
})
return response.data.data
},
// Check for duplicates
checkDuplicates: async (data: {
email?: string
phone?: string
mobile?: string
taxNumber?: string
commercialRegister?: string
excludeId?: string
}): Promise<Contact[]> => {
const response = await api.post('/contacts/check-duplicates', data)
return response.data.data
},
// Relationship management
getRelationships: async (contactId: string): Promise<any[]> => {
const response = await api.get(`/contacts/${contactId}/relationships`)
return response.data.data
},
addRelationship: async (contactId: string, data: {
toContactId: string
type: string
startDate: string
endDate?: string
notes?: string
}): Promise<any> => {
const response = await api.post(`/contacts/${contactId}/relationships`, data)
return response.data.data
},
updateRelationship: async (contactId: string, relationshipId: string, data: {
type?: string
startDate?: string
endDate?: string
notes?: string
isActive?: boolean
}): Promise<any> => {
const response = await api.put(`/contacts/${contactId}/relationships/${relationshipId}`, data)
return response.data.data
},
deleteRelationship: async (contactId: string, relationshipId: string): Promise<void> => {
await api.delete(`/contacts/${contactId}/relationships/${relationshipId}`)
}
}

View File

@@ -0,0 +1,30 @@
import { api } from '../api'
export interface PipelineStage {
name: string
nameAr?: string
order: number
}
export interface Pipeline {
id: string
name: string
nameAr?: string
structure: string
stages: PipelineStage[]
isActive: boolean
}
export const pipelinesAPI = {
getAll: async (structure?: string): Promise<Pipeline[]> => {
const params = new URLSearchParams()
if (structure) params.append('structure', structure)
const response = await api.get(`/crm/pipelines?${params.toString()}`)
return response.data.data
},
getById: async (id: string): Promise<Pipeline> => {
const response = await api.get(`/crm/pipelines/${id}`)
return response.data.data
}
}

View File

@@ -0,0 +1,74 @@
import { api } from '../api'
export interface QuoteItem {
description: string
quantity: number
unitPrice: number
total: number
}
export interface Quote {
id: string
quoteNumber: string
dealId: string
deal?: any
version: number
items: QuoteItem[] | any
subtotal: number
discountType?: string
discountValue?: number
taxRate: number
taxAmount: number
total: number
validUntil: string
paymentTerms?: string
deliveryTerms?: string
notes?: string
status: string
sentAt?: string
viewedAt?: string
approvedBy?: string
approvedAt?: string
createdAt: string
updatedAt: string
}
export interface CreateQuoteData {
dealId: string
items: QuoteItem[] | any[]
subtotal: number
taxRate: number
taxAmount: number
total: number
validUntil: string
paymentTerms?: string
deliveryTerms?: string
notes?: string
}
export const quotesAPI = {
getByDeal: async (dealId: string): Promise<Quote[]> => {
const response = await api.get(`/crm/deals/${dealId}/quotes`)
return response.data.data || []
},
getById: async (id: string): Promise<Quote> => {
const response = await api.get(`/crm/quotes/${id}`)
return response.data.data
},
create: async (data: CreateQuoteData): Promise<Quote> => {
const response = await api.post('/crm/quotes', data)
return response.data.data
},
approve: async (id: string): Promise<Quote> => {
const response = await api.post(`/crm/quotes/${id}/approve`)
return response.data.data
},
send: async (id: string): Promise<Quote> => {
const response = await api.post(`/crm/quotes/${id}/send`)
return response.data.data
}
}