From 299e1e38acd5505130802612e537e560a3492b99 Mon Sep 17 00:00:00 2001 From: admin-valentin Date: Sun, 2 Aug 2026 13:05:43 +0000 Subject: [PATCH] feat(CC-081): add Relationship Map page (SVG graph with contacts/orgs/tags in concentric circles) --- src/app/dashboard/network-map/page.tsx | 191 +++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 src/app/dashboard/network-map/page.tsx diff --git a/src/app/dashboard/network-map/page.tsx b/src/app/dashboard/network-map/page.tsx new file mode 100644 index 0000000..c7faa7e --- /dev/null +++ b/src/app/dashboard/network-map/page.tsx @@ -0,0 +1,191 @@ +'use client'; + +import { useMemo, useRef, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { apiFetch } from '../../../lib/api'; +import { useSession } from '../../../components/session-provider'; + +interface Contact { id: string; fullName: string; email: string | null; role: string | null; tags: string[]; } +interface Organization { id: string; name: string; industry: string | null; } + +interface GraphNode { + id: string; label: string; type: 'contact' | 'org' | 'tag'; + x: number; y: number; color: string; +} +interface GraphEdge { from: string; to: string; } + +const TYPE_COLOR = { contact: '#6366f1', org: '#f59e0b', tag: '#10b981' }; + +function layoutCircle(nodes: GraphNode[], cx = 400, cy = 300, r = 220) { + return nodes.map((n, i) => ({ + ...n, + x: cx + r * Math.cos((2 * Math.PI * i) / nodes.length - Math.PI / 2), + y: cy + r * Math.sin((2 * Math.PI * i) / nodes.length - Math.PI / 2), + })); +} + +export default function NetworkMapPage() { + const { activeTenant } = useSession(); + const tenantId = activeTenant?.tenantId ?? ''; + const [showTags, setShowTags] = useState(false); + const [selected, setSelected] = useState(null); + const svgRef = useRef(null); + + const { data: contacts = [], isLoading: cL } = useQuery({ + queryKey: ['netmap-contacts', tenantId], + queryFn: () => apiFetch('/v1/contacts?limit=200', { tenantId }), + enabled: Boolean(tenantId), staleTime: 120_000, + }); + const { data: orgs = [], isLoading: oL } = useQuery({ + queryKey: ['netmap-orgs', tenantId], + queryFn: () => apiFetch('/v1/organizations?limit=100', { tenantId }), + enabled: Boolean(tenantId), staleTime: 120_000, + }); + + const { nodes, edges } = useMemo(() => { + const limit = 40; + const topContacts = contacts.slice(0, Math.min(limit, contacts.length)); + const topOrgs = orgs.slice(0, Math.min(10, orgs.length)); + + const nodeList: GraphNode[] = [ + ...topContacts.map((c) => ({ id: `c-${c.id}`, label: c.fullName.split(' ')[0], type: 'contact' as const, x: 0, y: 0, color: TYPE_COLOR.contact })), + ...topOrgs.map((o) => ({ id: `o-${o.id}`, label: o.name.split(' ')[0], type: 'org' as const, x: 0, y: 0, color: TYPE_COLOR.org })), + ]; + + // Tag nodes + if (showTags) { + const allTags = new Set(); + topContacts.forEach((c) => c.tags.slice(0, 2).forEach((t) => allTags.add(t))); + Array.from(allTags).slice(0, 8).forEach((tag) => { + nodeList.push({ id: `t-${tag}`, label: tag, type: 'tag', x: 0, y: 0, color: TYPE_COLOR.tag }); + }); + } + + // Layout: contacts in outer ring, orgs in inner ring + const contactNodes = nodeList.filter((n) => n.type === 'contact'); + const orgNodes = nodeList.filter((n) => n.type === 'org'); + const tagNodes = nodeList.filter((n) => n.type === 'tag'); + + const laid = [ + ...layoutCircle(contactNodes, 400, 300, 230), + ...layoutCircle(orgNodes, 400, 300, 130), + ...layoutCircle(tagNodes, 400, 300, 60), + ]; + + // Edges: contacts to orgs by tag match + const edgeList: GraphEdge[] = []; + topContacts.forEach((c) => { + topOrgs.forEach((o) => { + const orgName = o.name.toLowerCase().split(' ')[0]; + if (c.tags.some((t) => t.toLowerCase().includes(orgName))) { + edgeList.push({ from: `c-${c.id}`, to: `o-${o.id}` }); + } + }); + if (showTags) { + c.tags.slice(0, 2).forEach((tag) => { + if (nodeList.some((n) => n.id === `t-${tag}`)) { + edgeList.push({ from: `c-${c.id}`, to: `t-${tag}` }); + } + }); + } + }); + + return { nodes: laid, edges: edgeList }; + }, [contacts, orgs, showTags]); + + const nodeMap = useMemo(() => { + const m: Record = {}; + nodes.forEach((n) => { m[n.id] = n; }); + return m; + }, [nodes]); + + const isLoading = cL || oL; + + return ( +
+
+
+

Hartă Relații

+

+ {isLoading ? 'Se construiește graful…' : `${contacts.length} contacte · ${orgs.length} organizații · ${edges.length} conexiuni`} +

+
+
+ +
+ {Object.entries(TYPE_COLOR).map(([type, color]) => ( + + + {type} + + ))} +
+
+
+ +
+ {isLoading ? ( +
Se construiește graful…
+ ) : nodes.length === 0 ? ( +
+

🕸️

+

Nicio conexiune. Adaugă contacte și organizații.

+
+ ) : ( + + {/* Edges */} + {edges.map((edge, i) => { + const from = nodeMap[edge.from]; + const to = nodeMap[edge.to]; + if (!from || !to) return null; + return ( + + ); + })} + {/* Nodes */} + {nodes.map((node) => { + const isSelected = selected?.id === node.id; + const r = node.type === 'org' ? 10 : node.type === 'tag' ? 7 : 8; + return ( + setSelected(isSelected ? null : node)} className="cursor-pointer"> + + {nodes.length <= 40 && ( + + {node.label.slice(0, 10)} + + )} + + ); + })} + + )} +
+ + {/* Selected node info */} + {selected && ( +
+ +
+

{selected.label}

+

{selected.type}

+
+
+ {edges.filter((e) => e.from === selected.id || e.to === selected.id).length} conexiuni +
+
+ )} + +

+ Regulă: graful nu clasifică persoanele ca valoroase/nevaloroase. Conexiunile sunt bazate pe taguri comune. Graph complet disponibil când Apache AGE este conectat. +

+
+ ); +}