diff --git a/modules/rnetwork/components/folk-crm-view.ts b/modules/rnetwork/components/folk-crm-view.ts index 709ed81..9ed42e0 100644 --- a/modules/rnetwork/components/folk-crm-view.ts +++ b/modules/rnetwork/components/folk-crm-view.ts @@ -35,6 +35,19 @@ interface Company { createdAt?: string; } +interface CrmGraphNode { + id: string; + name: string; + type: "person" | "company"; + companyId?: string; +} + +interface CrmGraphEdge { + source: string; + target: string; + type: "works_at" | "point_of_contact"; +} + type Tab = "pipeline" | "contacts" | "companies" | "graph"; const PIPELINE_STAGES = [ @@ -69,6 +82,26 @@ class FolkCrmView extends HTMLElement { private loading = true; private error = ""; + // Graph state + private graphNodes: CrmGraphNode[] = []; + private graphEdges: CrmGraphEdge[] = []; + private graphPositions: Record = {}; + private graphLayoutDirty = true; + private graphZoom = 1; + private graphPanX = 0; + private graphPanY = 0; + private graphDraggingId: string | null = null; + private graphDragStartX = 0; + private graphDragStartY = 0; + private graphDragNodeStartX = 0; + private graphDragNodeStartY = 0; + private graphIsPanning = false; + private graphPanStartX = 0; + private graphPanStartY = 0; + private graphPanStartPanX = 0; + private graphPanStartPanY = 0; + private graphSelectedId: string | null = null; + // Guided tour private _tour!: TourEngine; private static readonly TOUR_STEPS = [ @@ -128,6 +161,7 @@ class FolkCrmView extends HTMLElement { } catch (e) { this.error = "Failed to load CRM data"; } + this.graphLayoutDirty = true; this.loading = false; this.render(); } @@ -306,12 +340,314 @@ class FolkCrmView extends HTMLElement { `; } - // ── Graph link ── + // ── Inline Graph ── + + private buildGraphData(): { nodes: CrmGraphNode[]; edges: CrmGraphEdge[] } { + const nodes: CrmGraphNode[] = []; + const edges: CrmGraphEdge[] = []; + const seenEdges = new Set(); + + for (const c of this.companies) { + nodes.push({ id: "co:" + c.id, name: c.name || "Unknown", type: "company" }); + } + + for (const p of this.people) { + const name = this.personName(p.name); + const companyId = p.company?.id ? "co:" + p.company.id : undefined; + nodes.push({ id: "p:" + p.id, name, type: "person", companyId }); + if (companyId) { + edges.push({ source: "p:" + p.id, target: companyId, type: "works_at" }); + } + } + + // Cross-org edges from opportunities + for (const opp of this.opportunities) { + if (!opp.pointOfContact?.id || !opp.company?.id) continue; + const person = this.people.find(p => p.id === opp.pointOfContact!.id); + if (!person?.company?.id || person.company.id === opp.company.id) continue; + const key = `p:${person.id}->co:${opp.company.id}`; + if (seenEdges.has(key)) continue; + seenEdges.add(key); + edges.push({ source: "p:" + person.id, target: "co:" + opp.company.id, type: "point_of_contact" }); + } + + return { nodes, edges }; + } + + private computeGraphLayout(nodes: CrmGraphNode[], edges: CrmGraphEdge[]): Record { + const pos: Record = {}; + const W = 800, H = 600; + const cx = W / 2, cy = H / 2; + + const companies = nodes.filter(n => n.type === "company"); + const orbitR = Math.min(W, H) * 0.3; + const orgCenters: Record = {}; + + companies.forEach((org, i) => { + const angle = -Math.PI / 2 + (2 * Math.PI * i) / Math.max(companies.length, 1); + const p = { x: cx + orbitR * Math.cos(angle), y: cy + orbitR * Math.sin(angle) }; + orgCenters[org.id] = p; + pos[org.id] = { ...p }; + }); + + // Fan people around their company + const peopleByOrg: Record = {}; + for (const n of nodes) { + if (n.type === "person" && n.companyId && orgCenters[n.companyId]) { + (peopleByOrg[n.companyId] ??= []).push(n); + } + } + for (const [oid, people] of Object.entries(peopleByOrg)) { + const c = orgCenters[oid]; + const base = Math.atan2(c.y - cy, c.x - cx); + const spread = Math.PI * 0.8; + people.forEach((p, i) => { + const angle = base - spread / 2 + (spread * i) / Math.max(people.length - 1, 1); + pos[p.id] = { x: c.x + 110 * Math.cos(angle), y: c.y + 110 * Math.sin(angle) }; + }); + } + + // Unlinked nodes near center + for (const n of nodes) { + if (!pos[n.id]) { + pos[n.id] = { x: cx + (Math.random() - 0.5) * W * 0.4, y: cy + (Math.random() - 0.5) * H * 0.4 }; + } + } + + // Force iterations + const allIds = nodes.map(n => n.id).filter(id => pos[id]); + for (let iter = 0; iter < 70; iter++) { + const force: Record = {}; + for (const id of allIds) force[id] = { fx: 0, fy: 0 }; + + // Repulsion + for (let i = 0; i < allIds.length; i++) { + for (let j = i + 1; j < allIds.length; j++) { + const a = pos[allIds[i]], b = pos[allIds[j]]; + let dx = b.x - a.x, dy = b.y - a.y; + const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1); + const repel = 3000 / (dist * dist); + dx /= dist; dy /= dist; + force[allIds[i]].fx -= dx * repel; + force[allIds[i]].fy -= dy * repel; + force[allIds[j]].fx += dx * repel; + force[allIds[j]].fy += dy * repel; + } + } + + // Edge attraction + for (const edge of edges) { + const a = pos[edge.source], b = pos[edge.target]; + if (!a || !b) continue; + const dx = b.x - a.x, dy = b.y - a.y; + const dist = Math.sqrt(dx * dx + dy * dy); + const idealLen = edge.type === "works_at" ? 100 : 200; + const attract = (dist - idealLen) * 0.01; + const ux = dx / Math.max(dist, 1), uy = dy / Math.max(dist, 1); + if (force[edge.source]) { force[edge.source].fx += ux * attract; force[edge.source].fy += uy * attract; } + if (force[edge.target]) { force[edge.target].fx -= ux * attract; force[edge.target].fy -= uy * attract; } + } + + // Center gravity + for (const id of allIds) { + force[id].fx += (W / 2 - pos[id].x) * 0.002; + force[id].fy += (H / 2 - pos[id].y) * 0.002; + } + + // Apply with damping + const damping = 0.4 * (1 - iter / 70); + for (const id of allIds) { + pos[id].x += force[id].fx * damping; + pos[id].y += force[id].fy * damping; + } + } + return pos; + } + + private ensureGraphLayout() { + if (!this.graphLayoutDirty && Object.keys(this.graphPositions).length > 0) return; + const { nodes, edges } = this.buildGraphData(); + this.graphNodes = nodes; + this.graphEdges = edges; + this.graphPositions = this.computeGraphLayout(nodes, edges); + this.graphLayoutDirty = false; + } + + private fitGraphView() { + const svg = this.shadow.getElementById("graph-svg") as SVGSVGElement | null; + if (!svg) return; + this.ensureGraphLayout(); + const positions = Object.values(this.graphPositions); + if (positions.length === 0) return; + + const pad = 60; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const p of positions) { + if (p.x < minX) minX = p.x; + if (p.y < minY) minY = p.y; + if (p.x > maxX) maxX = p.x; + if (p.y > maxY) maxY = p.y; + } + minX -= 40; minY -= 40; maxX += 40; maxY += 40; + + const rect = svg.getBoundingClientRect(); + const svgW = rect.width || 800; + const svgH = rect.height || 600; + const zoom = Math.min((svgW - pad * 2) / Math.max(maxX - minX, 1), (svgH - pad * 2) / Math.max(maxY - minY, 1), 2); + this.graphZoom = Math.max(0.1, Math.min(zoom, 4)); + this.graphPanX = (svgW / 2) - ((minX + maxX) / 2) * this.graphZoom; + this.graphPanY = (svgH / 2) - ((minY + maxY) / 2) * this.graphZoom; + this.updateGraphTransform(); + this.updateGraphZoomDisplay(); + } + + private updateGraphTransform() { + const g = this.shadow.getElementById("graph-transform"); + if (g) g.setAttribute("transform", `translate(${this.graphPanX},${this.graphPanY}) scale(${this.graphZoom})`); + } + + private updateGraphZoomDisplay() { + const el = this.shadow.getElementById("graph-zoom-level"); + if (el) el.textContent = `${Math.round(this.graphZoom * 100)}%`; + } + + private zoomGraphAt(screenX: number, screenY: number, factor: number) { + const oldZoom = this.graphZoom; + const newZoom = Math.max(0.1, Math.min(4, oldZoom * factor)); + this.graphPanX = screenX - (screenX - this.graphPanX) * (newZoom / oldZoom); + this.graphPanY = screenY - (screenY - this.graphPanY) * (newZoom / oldZoom); + this.graphZoom = newZoom; + this.updateGraphTransform(); + this.updateGraphZoomDisplay(); + } + + private updateGraphNodePosition(nodeId: string) { + const pos = this.graphPositions[nodeId]; + if (!pos) return; + + const g = this.shadow.querySelector(`[data-node-id="${nodeId}"]`) as SVGGElement | null; + if (!g) return; + + const node = this.graphNodes.find(n => n.id === nodeId); + if (!node) return; + const isOrg = node.type === "company"; + const radius = isOrg ? 22 : 12; + + // Update circles (includes selection ring if present) + g.querySelectorAll("circle").forEach(circle => { + circle.setAttribute("cx", String(pos.x)); + circle.setAttribute("cy", String(pos.y)); + }); + + // Update text + const texts = g.querySelectorAll("text"); + if (isOrg) { + if (texts[0]) { texts[0].setAttribute("x", String(pos.x)); texts[0].setAttribute("y", String(pos.y + 4)); } + if (texts[1]) { texts[1].setAttribute("x", String(pos.x)); texts[1].setAttribute("y", String(pos.y + radius + 13)); } + } else { + if (texts[0]) { texts[0].setAttribute("x", String(pos.x)); texts[0].setAttribute("y", String(pos.y + radius + 13)); } + } + + // Update cluster circle for company nodes + if (isOrg) { + const cluster = this.shadow.querySelector(`[data-cluster="${nodeId}"]`) as SVGCircleElement | null; + if (cluster) { + cluster.setAttribute("cx", String(pos.x)); + cluster.setAttribute("cy", String(pos.y)); + } + } + + // Update connected edges + for (const edge of this.graphEdges) { + if (edge.source !== nodeId && edge.target !== nodeId) continue; + const sp = this.graphPositions[edge.source]; + const tp = this.graphPositions[edge.target]; + if (!sp || !tp) continue; + const line = this.shadow.querySelector(`[data-edge="${edge.source}:${edge.target}"]`) as SVGLineElement | null; + if (line) { + line.setAttribute("x1", String(sp.x)); + line.setAttribute("y1", String(sp.y)); + line.setAttribute("x2", String(tp.x)); + line.setAttribute("y2", String(tp.y)); + } + } + } + private renderGraphTab(): string { - const base = this.getApiBase().replace(/\/crm$/, ""); - return `