Deploy rule, CRM enhancements, Company Employee category, bilingual, contacts module completion
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user