Deploy rule, CRM enhancements, Company Employee category, bilingual, contacts module completion
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
673
frontend/src/app/contacts/[id]/page.tsx
Normal file
673
frontend/src/app/contacts/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
638
frontend/src/app/contacts/merge/page.tsx
Normal file
638
frontend/src/app/contacts/merge/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
540
frontend/src/app/crm/deals/[id]/page.tsx
Normal file
540
frontend/src/app/crm/deals/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
36
frontend/src/components/LanguageSwitcher.tsx
Normal file
36
frontend/src/components/LanguageSwitcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
331
frontend/src/components/contacts/CategorySelector.tsx
Normal file
331
frontend/src/components/contacts/CategorySelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
595
frontend/src/components/contacts/ContactForm.tsx
Normal file
595
frontend/src/components/contacts/ContactForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
239
frontend/src/components/contacts/ContactHistory.tsx
Normal file
239
frontend/src/components/contacts/ContactHistory.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
423
frontend/src/components/contacts/ContactImport.tsx
Normal file
423
frontend/src/components/contacts/ContactImport.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
189
frontend/src/components/contacts/DuplicateAlert.tsx
Normal file
189
frontend/src/components/contacts/DuplicateAlert.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
245
frontend/src/components/contacts/HierarchyTree.tsx
Normal file
245
frontend/src/components/contacts/HierarchyTree.tsx
Normal 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
|
||||
}
|
||||
122
frontend/src/components/contacts/QuickActions.tsx
Normal file
122
frontend/src/components/contacts/QuickActions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
608
frontend/src/components/contacts/RelationshipManager.tsx
Normal file
608
frontend/src/components/contacts/RelationshipManager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
541
frontend/src/contexts/LanguageContext.tsx
Normal file
541
frontend/src/contexts/LanguageContext.tsx
Normal 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: 'انتهت الجلسة. يرجى تسجيل الدخول مرة أخرى.'
|
||||
}
|
||||
}
|
||||
}
|
||||
65
frontend/src/lib/api/categories.ts
Normal file
65
frontend/src/lib/api/categories.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
30
frontend/src/lib/api/pipelines.ts
Normal file
30
frontend/src/lib/api/pipelines.ts
Normal 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
|
||||
}
|
||||
}
|
||||
74
frontend/src/lib/api/quotes.ts
Normal file
74
frontend/src/lib/api/quotes.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user