/** * — API-driven CRM interface. * * Tabbed view: Pipeline | Contacts | Companies | Graph * Fetches from per-space API endpoints on the rNetwork module. */ interface Opportunity { id: string; name: string; stage: string; amount?: { amountMicros: number; currencyCode: string }; company?: { id: string; name: string }; pointOfContact?: { id: string; name: { firstName: string; lastName: string } }; createdAt?: string; closeDate?: string; } interface Person { id: string; name: { firstName: string; lastName: string }; email?: { primaryEmail: string }; phone?: { primaryPhoneNumber: string }; city?: string; company?: { id: string; name: { firstName: string; lastName: string } }; createdAt?: string; } interface Company { id: string; name: string; domainName?: { primaryLinkUrl: string }; employees?: number; address?: { addressCity: string; addressCountry: string }; 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 = [ "INCOMING", "MEETING", "PROPOSAL", "CLOSED_WON", "CLOSED_LOST", ]; const STAGE_LABELS: Record = { INCOMING: "Incoming", MEETING: "Meeting", PROPOSAL: "Proposal", CLOSED_WON: "Won", CLOSED_LOST: "Lost", }; import { TourEngine } from "../../../shared/tour-engine"; class FolkCrmView extends HTMLElement { private shadow: ShadowRoot; private space = ""; private activeTab: Tab = "pipeline"; private searchQuery = ""; private sortColumn = ""; private sortAsc = true; private opportunities: Opportunity[] = []; private people: Person[] = []; private companies: Company[] = []; 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 = [ { target: '[data-tab="pipeline"]', title: "Pipeline", message: "Track deals through stages — from incoming leads to closed-won. Drag cards to update their stage.", advanceOnClick: true }, { target: '[data-tab="contacts"]', title: "Contacts", message: "View and search your contact directory. Click a contact to see their details.", advanceOnClick: true }, { target: '#crm-search', title: "Search", message: "Search across contacts and companies by name, email, or city. Results filter in real time.", advanceOnClick: false }, { target: '[data-tab="graph"]', title: "Relationship Graph", message: "Visualise connections between people and companies as an interactive network graph.", advanceOnClick: true }, ]; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); this._tour = new TourEngine( this.shadow, FolkCrmView.TOUR_STEPS, "rnetwork_tour_done", () => this.shadow.host as HTMLElement, ); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; this.render(); this.loadData(); // Auto-start tour on first visit if (!localStorage.getItem("rnetwork_tour_done")) { setTimeout(() => this._tour.start(), 1200); } } private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^(\/[^/]+)?\/rnetwork/); return match ? match[0] : ""; } private async loadData() { const base = this.getApiBase(); try { const [oppRes, peopleRes, compRes] = await Promise.all([ fetch(`${base}/api/opportunities`), fetch(`${base}/api/people`), fetch(`${base}/api/companies`), ]); if (oppRes.ok) { const d = await oppRes.json(); this.opportunities = d.opportunities || []; } if (peopleRes.ok) { const d = await peopleRes.json(); this.people = d.people || []; } if (compRes.ok) { const d = await compRes.json(); this.companies = d.companies || []; } } catch (e) { this.error = "Failed to load CRM data"; } this.graphLayoutDirty = true; this.loading = false; this.render(); } private formatAmount(amount?: { amountMicros: number; currencyCode: string }): string { if (!amount || !amount.amountMicros) return "-"; const value = amount.amountMicros / 1_000_000; const currency = amount.currencyCode || "USD"; try { return new Intl.NumberFormat("en-US", { style: "currency", currency, maximumFractionDigits: 0 }).format(value); } catch { return `${currency} ${value.toLocaleString()}`; } } private personName(p?: { firstName: string; lastName: string }): string { if (!p) return "-"; return [p.firstName, p.lastName].filter(Boolean).join(" ") || "-"; } private companyName(c?: { id: string; name: string | { firstName: string; lastName: string } }): string { if (!c) return "-"; if (typeof c.name === "string") return c.name; return [c.name?.firstName, c.name?.lastName].filter(Boolean).join(" ") || "-"; } private formatDate(d?: string): string { if (!d) return "-"; try { return new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); } catch { return "-"; } } // ── Pipeline view ── private renderPipeline(): string { if (this.opportunities.length === 0) { return `
No opportunities found${this.space === "demo" ? " — connect a Twenty workspace to see pipeline data" : ""}.
`; } const byStage = new Map(); for (const stage of PIPELINE_STAGES) byStage.set(stage, []); for (const opp of this.opportunities) { const stage = opp.stage || "INCOMING"; const list = byStage.get(stage) || byStage.get("INCOMING")!; list.push(opp); } return `
${PIPELINE_STAGES.map(stage => { const opps = byStage.get(stage) || []; const total = opps.reduce((s, o) => s + (o.amount?.amountMicros || 0), 0) / 1_000_000; const stageClass = stage.toLowerCase().replace(/_/g, "-"); return `
${STAGE_LABELS[stage] || stage} ${opps.length}
${total > 0 ? `
${this.formatAmount({ amountMicros: total * 1_000_000, currencyCode: "USD" })}
` : ""}
${opps.map(opp => `
${this.esc(opp.name)}
${this.formatAmount(opp.amount)}
${opp.company?.name ? `
${this.esc(opp.company.name)}
` : ""} ${opp.pointOfContact ? `
${this.esc(this.personName(opp.pointOfContact.name))}
` : ""} ${opp.closeDate ? `
Close: ${this.formatDate(opp.closeDate)}
` : ""}
`).join("")}
`; }).join("")}
`; } // ── Contacts table ── private renderContacts(): string { let filtered = this.people; if (this.searchQuery.trim()) { const q = this.searchQuery.toLowerCase(); filtered = filtered.filter(p => this.personName(p.name).toLowerCase().includes(q) || (p.email?.primaryEmail || "").toLowerCase().includes(q) || this.companyName(p.company).toLowerCase().includes(q) || (p.city || "").toLowerCase().includes(q) ); } if (this.sortColumn) { filtered = [...filtered].sort((a, b) => { let va = "", vb = ""; switch (this.sortColumn) { case "name": va = this.personName(a.name); vb = this.personName(b.name); break; case "email": va = a.email?.primaryEmail || ""; vb = b.email?.primaryEmail || ""; break; case "company": va = this.companyName(a.company); vb = this.companyName(b.company); break; case "city": va = a.city || ""; vb = b.city || ""; break; } const cmp = va.localeCompare(vb); return this.sortAsc ? cmp : -cmp; }); } if (filtered.length === 0) { return `
${this.searchQuery ? "No contacts match your search." : "No contacts found."}
`; } const sortIcon = (col: string) => this.sortColumn === col ? (this.sortAsc ? " \u25B2" : " \u25BC") : ""; return ` ${filtered.map(p => ``).join("")}
Name${sortIcon("name")} Email${sortIcon("email")} Company${sortIcon("company")} City${sortIcon("city")} Phone
${this.esc(this.personName(p.name))} ${this.esc(this.companyName(p.company))} ${this.esc(p.city || "-")} ${this.esc(p.phone?.primaryPhoneNumber || "-")}
`; } // ── Companies table ── private renderCompanies(): string { let filtered = this.companies; if (this.searchQuery.trim()) { const q = this.searchQuery.toLowerCase(); filtered = filtered.filter(c => (c.name || "").toLowerCase().includes(q) || (c.domainName?.primaryLinkUrl || "").toLowerCase().includes(q) || (c.address?.addressCity || "").toLowerCase().includes(q) ); } if (this.sortColumn) { filtered = [...filtered].sort((a, b) => { let va = "", vb = ""; switch (this.sortColumn) { case "name": va = a.name || ""; vb = b.name || ""; break; case "domain": va = a.domainName?.primaryLinkUrl || ""; vb = b.domainName?.primaryLinkUrl || ""; break; case "city": va = a.address?.addressCity || ""; vb = b.address?.addressCity || ""; break; case "employees": return this.sortAsc ? (a.employees || 0) - (b.employees || 0) : (b.employees || 0) - (a.employees || 0); } const cmp = va.localeCompare(vb); return this.sortAsc ? cmp : -cmp; }); } if (filtered.length === 0) { return `
${this.searchQuery ? "No companies match your search." : "No companies found."}
`; } const sortIcon = (col: string) => this.sortColumn === col ? (this.sortAsc ? " \u25B2" : " \u25BC") : ""; return ` ${filtered.map(c => ``).join("")}
Name${sortIcon("name")} Domain${sortIcon("domain")} Employees${sortIcon("employees")} City${sortIcon("city")} Country
${this.esc(c.name || "-")} ${c.domainName?.primaryLinkUrl ? `${this.esc(c.domainName.primaryLinkUrl)}` : "-"} ${c.employees ?? "-"} ${this.esc(c.address?.addressCity || "-")} ${this.esc(c.address?.addressCountry || "-")}
`; } // ── 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 { this.ensureGraphLayout(); const nodes = this.graphNodes; const edges = this.graphEdges; if (nodes.length === 0) { return `
No data to graph. Add contacts and companies to see the relationship network.
`; } const positions = this.graphPositions; // Color palette for companies const palette = ["#6366f1", "#22c55e", "#f59e0b", "#ec4899", "#14b8a6", "#f97316", "#8b5cf6", "#06b6d4"]; const companyNodes = nodes.filter(n => n.type === "company"); const orgColors: Record = {}; companyNodes.forEach((org, i) => { orgColors[org.id] = palette[i % palette.length]; }); // Cluster background circles const clustersSvg = companyNodes.map(org => { const pos = positions[org.id]; if (!pos) return ""; const color = orgColors[org.id] || "#333"; return ``; }).join(""); // Edges const edgesSvg = edges.map(edge => { const sp = positions[edge.source], tp = positions[edge.target]; if (!sp || !tp) return ""; if (edge.type === "works_at") { return ``; } else { return ``; } }).join(""); // Nodes const nodesSvg = nodes.map(node => { const pos = positions[node.id]; if (!pos) return ""; const isOrg = node.type === "company"; const color = isOrg ? (orgColors[node.id] || "#22c55e") : "#3b82f6"; const radius = isOrg ? 22 : 12; const isSelected = this.graphSelectedId === node.id; const label = this.esc(node.name); const truncLabel = label.length > 14 ? label.slice(0, 12) + "\u2026" : label; return ` ${isSelected ? `` : ""} ${isOrg ? `${truncLabel}` : ""} ${label} `; }).join(""); return `
${clustersSvg} ${edgesSvg} ${nodesSvg}
${Math.round(this.graphZoom * 100)}%
People
Companies
Works at
Cross-org link
`; } // ── Main render ── private render() { const tabs: { id: Tab; label: string; count: number }[] = [ { id: "pipeline", label: "Pipeline", count: this.opportunities.length }, { id: "contacts", label: "Contacts", count: this.people.length }, { id: "companies", label: "Companies", count: this.companies.length }, { id: "graph", label: "Graph", count: 0 }, ]; let content = ""; if (this.loading) { content = `
Loading CRM data...
`; } else if (this.error) { content = `
${this.esc(this.error)}
`; } else { switch (this.activeTab) { case "pipeline": content = this.renderPipeline(); break; case "contacts": content = this.renderContacts(); break; case "companies": content = this.renderCompanies(); break; case "graph": content = this.renderGraphTab(); break; } } const showSearch = this.activeTab === "contacts" || this.activeTab === "companies"; this.shadow.innerHTML = `
CRM
${tabs.map(t => ``).join("")}
${showSearch ? `
` : ""}
${content}
`; this.attachListeners(); this._tour.renderOverlay(); } startTour() { this._tour.start(); } private attachListeners() { // Tour button this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour()); // Tab switching this.shadow.querySelectorAll("[data-tab]").forEach(el => { el.addEventListener("click", () => { this.activeTab = (el as HTMLElement).dataset.tab as Tab; this.searchQuery = ""; this.sortColumn = ""; this.sortAsc = true; this.render(); }); }); // Search let searchTimeout: any; this.shadow.getElementById("crm-search")?.addEventListener("input", (e) => { this.searchQuery = (e.target as HTMLInputElement).value; clearTimeout(searchTimeout); searchTimeout = setTimeout(() => this.render(), 200); }); // Sort columns this.shadow.querySelectorAll("[data-sort]").forEach(el => { el.addEventListener("click", () => { const col = (el as HTMLElement).dataset.sort!; if (this.sortColumn === col) { this.sortAsc = !this.sortAsc; } else { this.sortColumn = col; this.sortAsc = true; } this.render(); }); }); // ── Graph interactions ── if (this.activeTab === "graph") { const container = this.shadow.getElementById("graph-container"); const svg = this.shadow.getElementById("graph-svg") as SVGSVGElement | null; if (svg && container) { // Zoom controls this.shadow.getElementById("graph-zoom-in")?.addEventListener("click", () => { const rect = svg.getBoundingClientRect(); this.zoomGraphAt(rect.width / 2, rect.height / 2, 1.25); }); this.shadow.getElementById("graph-zoom-out")?.addEventListener("click", () => { const rect = svg.getBoundingClientRect(); this.zoomGraphAt(rect.width / 2, rect.height / 2, 0.8); }); this.shadow.getElementById("graph-zoom-fit")?.addEventListener("click", () => this.fitGraphView()); // Wheel zoom svg.addEventListener("wheel", (e: WheelEvent) => { e.preventDefault(); const rect = svg.getBoundingClientRect(); this.zoomGraphAt(e.clientX - rect.left, e.clientY - rect.top, 1 - e.deltaY * 0.003); }, { passive: false }); // Pointer down — node drag or canvas pan svg.addEventListener("pointerdown", (e: PointerEvent) => { if (e.button !== 0) return; const target = (e.target as Element).closest("[data-node-id]") as HTMLElement | null; if (target) { const nodeId = target.dataset.nodeId!; this.graphDraggingId = nodeId; this.graphDragStartX = e.clientX; this.graphDragStartY = e.clientY; const pos = this.graphPositions[nodeId]; if (pos) { this.graphDragNodeStartX = pos.x; this.graphDragNodeStartY = pos.y; } svg.setPointerCapture(e.pointerId); e.preventDefault(); } else { this.graphIsPanning = true; this.graphPanStartX = e.clientX; this.graphPanStartY = e.clientY; this.graphPanStartPanX = this.graphPanX; this.graphPanStartPanY = this.graphPanY; container.classList.add("grabbing"); svg.setPointerCapture(e.pointerId); e.preventDefault(); } }); svg.addEventListener("pointermove", (e: PointerEvent) => { if (this.graphDraggingId) { const dx = (e.clientX - this.graphDragStartX) / this.graphZoom; const dy = (e.clientY - this.graphDragStartY) / this.graphZoom; const pos = this.graphPositions[this.graphDraggingId]; if (pos) { pos.x = this.graphDragNodeStartX + dx; pos.y = this.graphDragNodeStartY + dy; this.updateGraphNodePosition(this.graphDraggingId); } } else if (this.graphIsPanning) { this.graphPanX = this.graphPanStartPanX + (e.clientX - this.graphPanStartX); this.graphPanY = this.graphPanStartPanY + (e.clientY - this.graphPanStartY); this.updateGraphTransform(); } }); svg.addEventListener("pointerup", (e: PointerEvent) => { if (this.graphDraggingId) { const dx = Math.abs(e.clientX - this.graphDragStartX); const dy = Math.abs(e.clientY - this.graphDragStartY); if (dx < 4 && dy < 4) { // Click — toggle selection const id = this.graphDraggingId; this.graphSelectedId = this.graphSelectedId === id ? null : id; this.graphDraggingId = null; this.render(); return; } this.graphDraggingId = null; } if (this.graphIsPanning) { this.graphIsPanning = false; container.classList.remove("grabbing"); } }); } // Fit view on first render requestAnimationFrame(() => this.fitGraphView()); } } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-crm-view", FolkCrmView);