/** * — 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; } 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", }; 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 = ""; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; this.render(); this.loadData(); } 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.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 || "-")}
`; } // ── Graph link ── private renderGraphTab(): string { const base = this.getApiBase().replace(/\/crm$/, ""); return ``; } // ── 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(); } private attachListeners() { // 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(); }); }); } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-crm-view", FolkCrmView);