feat(CC-081): add Relationship Map page (SVG graph with contacts/orgs/tags in concentric circles)
This commit is contained in:
parent
b6589a7091
commit
299e1e38ac
1 changed files with 191 additions and 0 deletions
191
src/app/dashboard/network-map/page.tsx
Normal file
191
src/app/dashboard/network-map/page.tsx
Normal file
|
|
@ -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<GraphNode | null>(null);
|
||||||
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
|
|
||||||
|
const { data: contacts = [], isLoading: cL } = useQuery({
|
||||||
|
queryKey: ['netmap-contacts', tenantId],
|
||||||
|
queryFn: () => apiFetch<Contact[]>('/v1/contacts?limit=200', { tenantId }),
|
||||||
|
enabled: Boolean(tenantId), staleTime: 120_000,
|
||||||
|
});
|
||||||
|
const { data: orgs = [], isLoading: oL } = useQuery({
|
||||||
|
queryKey: ['netmap-orgs', tenantId],
|
||||||
|
queryFn: () => apiFetch<Organization[]>('/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<string>();
|
||||||
|
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<string, GraphNode> = {};
|
||||||
|
nodes.forEach((n) => { m[n.id] = n; });
|
||||||
|
return m;
|
||||||
|
}, [nodes]);
|
||||||
|
|
||||||
|
const isLoading = cL || oL;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl space-y-4 p-6">
|
||||||
|
<div className="flex items-start justify-between flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-2xl font-semibold text-ink">Hartă Relații</h1>
|
||||||
|
<p className="text-sm text-ink-faint mt-1">
|
||||||
|
{isLoading ? 'Se construiește graful…' : `${contacts.length} contacte · ${orgs.length} organizații · ${edges.length} conexiuni`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="flex items-center gap-2 text-xs text-ink-faint cursor-pointer">
|
||||||
|
<input type="checkbox" checked={showTags} onChange={(e) => setShowTags(e.target.checked)} className="accent-primary" />
|
||||||
|
Arată taguri
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3 text-[10px] text-ink-faint">
|
||||||
|
{Object.entries(TYPE_COLOR).map(([type, color]) => (
|
||||||
|
<span key={type} className="flex items-center gap-1">
|
||||||
|
<span className="w-2 h-2 rounded-full" style={{ background: color }} />
|
||||||
|
{type}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-1 overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-[500px] text-sm text-ink-faint">Se construiește graful…</div>
|
||||||
|
) : nodes.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-[500px] gap-2">
|
||||||
|
<p className="text-2xl">🕸️</p>
|
||||||
|
<p className="text-sm text-ink-faint">Nicio conexiune. Adaugă contacte și organizații.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<svg ref={svgRef} viewBox="0 0 800 600" className="w-full h-[500px]" style={{ background: 'transparent' }}>
|
||||||
|
{/* Edges */}
|
||||||
|
{edges.map((edge, i) => {
|
||||||
|
const from = nodeMap[edge.from];
|
||||||
|
const to = nodeMap[edge.to];
|
||||||
|
if (!from || !to) return null;
|
||||||
|
return (
|
||||||
|
<line key={i}
|
||||||
|
x1={from.x} y1={from.y} x2={to.x} y2={to.y}
|
||||||
|
stroke="currentColor" strokeOpacity="0.1" strokeWidth="1" />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Nodes */}
|
||||||
|
{nodes.map((node) => {
|
||||||
|
const isSelected = selected?.id === node.id;
|
||||||
|
const r = node.type === 'org' ? 10 : node.type === 'tag' ? 7 : 8;
|
||||||
|
return (
|
||||||
|
<g key={node.id} onClick={() => setSelected(isSelected ? null : node)} className="cursor-pointer">
|
||||||
|
<circle cx={node.x} cy={node.y} r={isSelected ? r + 3 : r}
|
||||||
|
fill={node.color} opacity={isSelected ? 1 : 0.8}
|
||||||
|
stroke={isSelected ? 'white' : 'transparent'} strokeWidth="2" />
|
||||||
|
{nodes.length <= 40 && (
|
||||||
|
<text x={node.x} y={node.y - r - 4} textAnchor="middle"
|
||||||
|
fontSize="8" fill="currentColor" opacity="0.7">
|
||||||
|
{node.label.slice(0, 10)}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected node info */}
|
||||||
|
{selected && (
|
||||||
|
<div className="card p-4 flex items-center gap-3">
|
||||||
|
<span className="w-3 h-3 rounded-full" style={{ background: selected.color }} />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-ink">{selected.label}</p>
|
||||||
|
<p className="text-[10px] text-ink-faint capitalize">{selected.type}</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto text-[10px] text-ink-faint">
|
||||||
|
{edges.filter((e) => e.from === selected.id || e.to === selected.id).length} conexiuni
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-[10px] text-ink-faint">
|
||||||
|
Regulă: graful nu clasifică persoanele ca valoroase/nevaloroase. Conexiunile sunt bazate pe taguri comune. Graph complet disponibil când Apache AGE este conectat.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue