feat: add multi-workspace Twenty CRM support & CRM view component
Enable multi-workspace mode in Twenty docker-compose with per-space API token routing. Add folk-crm-view component with Pipeline, Contacts, Companies, and Graph tabs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5396c52c75
commit
7e5499d087
|
|
@ -38,6 +38,11 @@ services:
|
|||
- SIGN_IN_PREFILLED=false
|
||||
- IS_BILLING_ENABLED=false
|
||||
- TELEMETRY_ENABLED=false
|
||||
# ── Multi-workspace ──
|
||||
- IS_MULTIWORKSPACE_ENABLED=true
|
||||
- IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS=true
|
||||
- DEFAULT_SUBDOMAIN=admin-crm
|
||||
- FRONT_DOMAIN=rspace.online
|
||||
volumes:
|
||||
- twenty-ch-server-data:/app/.local-storage
|
||||
labels:
|
||||
|
|
@ -76,6 +81,11 @@ services:
|
|||
- STORAGE_LOCAL_PATH=.local-storage
|
||||
- SERVER_URL=https://crm.rspace.online
|
||||
- TELEMETRY_ENABLED=false
|
||||
# ── Multi-workspace ──
|
||||
- IS_MULTIWORKSPACE_ENABLED=true
|
||||
- IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS=true
|
||||
- DEFAULT_SUBDOMAIN=admin-crm
|
||||
- FRONT_DOMAIN=rspace.online
|
||||
volumes:
|
||||
- twenty-ch-server-data:/app/.local-storage
|
||||
networks:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,495 @@
|
|||
/**
|
||||
* <folk-crm-view> — 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<string, string> = {
|
||||
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 `<div class="empty-state">No opportunities found${this.space === "demo" ? " — connect a Twenty workspace to see pipeline data" : ""}.</div>`;
|
||||
}
|
||||
|
||||
const byStage = new Map<string, Opportunity[]>();
|
||||
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 `<div class="pipeline-grid">
|
||||
${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 `<div class="pipeline-column">
|
||||
<div class="pipeline-header stage-${stageClass}">
|
||||
<span class="pipeline-stage-name">${STAGE_LABELS[stage] || stage}</span>
|
||||
<span class="pipeline-count">${opps.length}</span>
|
||||
</div>
|
||||
${total > 0 ? `<div class="pipeline-total">${this.formatAmount({ amountMicros: total * 1_000_000, currencyCode: "USD" })}</div>` : ""}
|
||||
<div class="pipeline-cards">
|
||||
${opps.map(opp => `<div class="pipeline-card">
|
||||
<div class="card-name">${this.esc(opp.name)}</div>
|
||||
<div class="card-amount">${this.formatAmount(opp.amount)}</div>
|
||||
${opp.company?.name ? `<div class="card-company">${this.esc(opp.company.name)}</div>` : ""}
|
||||
${opp.pointOfContact ? `<div class="card-contact">${this.esc(this.personName(opp.pointOfContact.name))}</div>` : ""}
|
||||
${opp.closeDate ? `<div class="card-date">Close: ${this.formatDate(opp.closeDate)}</div>` : ""}
|
||||
</div>`).join("")}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── 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 `<div class="empty-state">${this.searchQuery ? "No contacts match your search." : "No contacts found."}</div>`;
|
||||
}
|
||||
|
||||
const sortIcon = (col: string) => this.sortColumn === col ? (this.sortAsc ? " \u25B2" : " \u25BC") : "";
|
||||
|
||||
return `<table class="data-table">
|
||||
<thead><tr>
|
||||
<th data-sort="name">Name${sortIcon("name")}</th>
|
||||
<th data-sort="email">Email${sortIcon("email")}</th>
|
||||
<th data-sort="company">Company${sortIcon("company")}</th>
|
||||
<th data-sort="city">City${sortIcon("city")}</th>
|
||||
<th>Phone</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${filtered.map(p => `<tr>
|
||||
<td class="cell-name">${this.esc(this.personName(p.name))}</td>
|
||||
<td class="cell-email">${p.email?.primaryEmail ? this.esc(p.email.primaryEmail) : "-"}</td>
|
||||
<td>${this.esc(this.companyName(p.company))}</td>
|
||||
<td>${this.esc(p.city || "-")}</td>
|
||||
<td>${this.esc(p.phone?.primaryPhoneNumber || "-")}</td>
|
||||
</tr>`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-footer">${filtered.length} contact${filtered.length !== 1 ? "s" : ""}</div>`;
|
||||
}
|
||||
|
||||
// ── 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 `<div class="empty-state">${this.searchQuery ? "No companies match your search." : "No companies found."}</div>`;
|
||||
}
|
||||
|
||||
const sortIcon = (col: string) => this.sortColumn === col ? (this.sortAsc ? " \u25B2" : " \u25BC") : "";
|
||||
|
||||
return `<table class="data-table">
|
||||
<thead><tr>
|
||||
<th data-sort="name">Name${sortIcon("name")}</th>
|
||||
<th data-sort="domain">Domain${sortIcon("domain")}</th>
|
||||
<th data-sort="employees">Employees${sortIcon("employees")}</th>
|
||||
<th data-sort="city">City${sortIcon("city")}</th>
|
||||
<th>Country</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${filtered.map(c => `<tr>
|
||||
<td class="cell-name">${this.esc(c.name || "-")}</td>
|
||||
<td class="cell-domain">${c.domainName?.primaryLinkUrl ? `<a href="${this.esc(c.domainName.primaryLinkUrl)}" target="_blank" rel="noopener">${this.esc(c.domainName.primaryLinkUrl)}</a>` : "-"}</td>
|
||||
<td>${c.employees ?? "-"}</td>
|
||||
<td>${this.esc(c.address?.addressCity || "-")}</td>
|
||||
<td>${this.esc(c.address?.addressCountry || "-")}</td>
|
||||
</tr>`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-footer">${filtered.length} compan${filtered.length !== 1 ? "ies" : "y"}</div>`;
|
||||
}
|
||||
|
||||
// ── Graph link ──
|
||||
private renderGraphTab(): string {
|
||||
const base = this.getApiBase().replace(/\/crm$/, "");
|
||||
return `<div class="graph-link-section">
|
||||
<p>View the interactive force-directed relationship graph for this space.</p>
|
||||
<a href="${base || `/${this.space}/rnetwork`}" class="graph-link-btn">Open Network Graph</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── 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 = `<div class="loading">Loading CRM data...</div>`;
|
||||
} else if (this.error) {
|
||||
content = `<div class="error">${this.esc(this.error)}</div>`;
|
||||
} 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 = `
|
||||
<style>
|
||||
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
.crm-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.crm-title { font-size: 15px; font-weight: 600; color: #e2e8f0; }
|
||||
|
||||
.tabs { display: flex; gap: 2px; background: #16161e; border-radius: 10px; padding: 3px; margin-bottom: 16px; }
|
||||
.tab {
|
||||
padding: 8px 16px; border-radius: 8px; border: none;
|
||||
background: transparent; color: #888; cursor: pointer; font-size: 13px;
|
||||
font-weight: 500; transition: all 0.15s;
|
||||
}
|
||||
.tab:hover { color: #ccc; }
|
||||
.tab.active { background: #1e1e2e; color: #e2e8f0; }
|
||||
.tab-count { font-size: 11px; color: #555; margin-left: 4px; }
|
||||
.tab.active .tab-count { color: #6366f1; }
|
||||
|
||||
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; }
|
||||
.search-input {
|
||||
border: 1px solid #333; border-radius: 8px; padding: 8px 12px;
|
||||
background: #16161e; color: #e0e0e0; font-size: 13px; width: 260px; outline: none;
|
||||
}
|
||||
.search-input:focus { border-color: #6366f1; }
|
||||
|
||||
.loading { text-align: center; color: #888; padding: 60px 20px; font-size: 14px; }
|
||||
.error { text-align: center; color: #ef5350; padding: 40px 20px; }
|
||||
.empty-state { text-align: center; color: #666; padding: 60px 20px; font-size: 14px; }
|
||||
|
||||
/* ── Pipeline grid ── */
|
||||
.pipeline-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(${PIPELINE_STAGES.length}, 1fr);
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.pipeline-column { min-width: 180px; }
|
||||
.pipeline-header {
|
||||
padding: 8px 12px; border-radius: 8px 8px 0 0;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.stage-incoming { background: #1e293b; color: #93c5fd; }
|
||||
.stage-meeting { background: #1e2a1e; color: #86efac; }
|
||||
.stage-proposal { background: #2a2a1e; color: #fde68a; }
|
||||
.stage-closed-won { background: #1a2e1a; color: #4ade80; }
|
||||
.stage-closed-lost { background: #2e1a1a; color: #f87171; }
|
||||
.pipeline-count {
|
||||
background: rgba(255,255,255,0.1); padding: 1px 7px; border-radius: 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.pipeline-total { font-size: 12px; color: #888; padding: 4px 12px; }
|
||||
.pipeline-cards { display: flex; flex-direction: column; gap: 8px; padding-top: 8px; }
|
||||
.pipeline-card {
|
||||
background: #1e1e2e; border: 1px solid #2a2a3a; border-radius: 8px;
|
||||
padding: 12px; transition: border-color 0.15s;
|
||||
}
|
||||
.pipeline-card:hover { border-color: #444; }
|
||||
.card-name { font-size: 13px; font-weight: 600; color: #e2e8f0; margin-bottom: 4px; }
|
||||
.card-amount { font-size: 14px; font-weight: 700; color: #6366f1; margin-bottom: 6px; }
|
||||
.card-company { font-size: 12px; color: #94a3b8; }
|
||||
.card-contact { font-size: 11px; color: #64748b; margin-top: 2px; }
|
||||
.card-date { font-size: 11px; color: #555; margin-top: 4px; }
|
||||
|
||||
/* ── Data tables ── */
|
||||
.data-table {
|
||||
width: 100%; border-collapse: collapse;
|
||||
background: #16161e; border-radius: 10px; overflow: hidden;
|
||||
}
|
||||
.data-table th {
|
||||
text-align: left; padding: 10px 14px; font-size: 11px; font-weight: 600;
|
||||
color: #64748b; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
border-bottom: 1px solid #222; cursor: pointer; user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.data-table th:hover { color: #94a3b8; }
|
||||
.data-table td {
|
||||
padding: 10px 14px; font-size: 13px; border-bottom: 1px solid #1a1a2a;
|
||||
color: #c8c8d8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
max-width: 250px;
|
||||
}
|
||||
.data-table tr:hover td { background: #1a1a2a; }
|
||||
.cell-name { font-weight: 500; color: #e2e8f0; }
|
||||
.cell-email { color: #93c5fd; }
|
||||
.cell-domain a { color: #93c5fd; text-decoration: none; }
|
||||
.cell-domain a:hover { text-decoration: underline; }
|
||||
.table-footer { font-size: 12px; color: #555; padding: 10px 14px; }
|
||||
|
||||
/* ── Graph tab ── */
|
||||
.graph-link-section { text-align: center; padding: 60px 20px; }
|
||||
.graph-link-section p { color: #888; font-size: 14px; margin-bottom: 16px; }
|
||||
.graph-link-btn {
|
||||
display: inline-block; padding: 10px 24px; border-radius: 8px;
|
||||
background: #6366f1; color: #fff; text-decoration: none;
|
||||
font-size: 14px; font-weight: 500; transition: background 0.15s;
|
||||
}
|
||||
.graph-link-btn:hover { background: #5558e6; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.pipeline-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.pipeline-grid { grid-template-columns: 1fr; }
|
||||
.tabs { flex-wrap: wrap; }
|
||||
.toolbar { flex-direction: column; align-items: stretch; }
|
||||
.search-input { width: 100%; }
|
||||
.data-table td, .data-table th { padding: 8px 10px; font-size: 12px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="crm-header">
|
||||
<span class="crm-title">CRM</span>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
${tabs.map(t => `<button class="tab ${this.activeTab === t.id ? "active" : ""}" data-tab="${t.id}">
|
||||
${t.label}${t.count > 0 ? `<span class="tab-count">${t.count}</span>` : ""}
|
||||
</button>`).join("")}
|
||||
</div>
|
||||
|
||||
${showSearch ? `<div class="toolbar">
|
||||
<input class="search-input" type="text" placeholder="Search..." id="crm-search" value="${this.esc(this.searchQuery)}">
|
||||
</div>` : ""}
|
||||
|
||||
<div class="crm-content">${content}</div>
|
||||
`;
|
||||
|
||||
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);
|
||||
|
|
@ -7,24 +7,38 @@
|
|||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { renderShell, renderExternalAppShell } from "../../server/shell";
|
||||
import { renderShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
import { renderLanding } from "./landing";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
const TWENTY_API_URL = process.env.TWENTY_API_URL || "https://rnetwork.online";
|
||||
const TWENTY_API_TOKEN = process.env.TWENTY_API_TOKEN || "";
|
||||
const TWENTY_API_URL = process.env.TWENTY_API_URL || "https://crm.rspace.online";
|
||||
const TWENTY_DEFAULT_TOKEN = process.env.TWENTY_API_TOKEN || "";
|
||||
|
||||
// Build token map from env vars: TWENTY_TOKEN_COMMONS_HUB -> "commons-hub"
|
||||
const twentyTokens = new Map<string, string>();
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (key.startsWith("TWENTY_TOKEN_") && value) {
|
||||
const slug = key.replace("TWENTY_TOKEN_", "").toLowerCase().replace(/_/g, "-");
|
||||
twentyTokens.set(slug, value);
|
||||
}
|
||||
}
|
||||
|
||||
function getTokenForSpace(space: string): string {
|
||||
return twentyTokens.get(space) || TWENTY_DEFAULT_TOKEN;
|
||||
}
|
||||
|
||||
// ── GraphQL helper ──
|
||||
async function twentyQuery(query: string, variables?: Record<string, unknown>) {
|
||||
if (!TWENTY_API_TOKEN) return null;
|
||||
async function twentyQuery(query: string, variables?: Record<string, unknown>, space?: string) {
|
||||
const token = space ? getTokenForSpace(space) : TWENTY_DEFAULT_TOKEN;
|
||||
if (!token) return null;
|
||||
const res = await fetch(`${TWENTY_API_URL}/api`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${TWENTY_API_TOKEN}`,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
|
|
@ -34,28 +48,35 @@ async function twentyQuery(query: string, variables?: Record<string, unknown>) {
|
|||
return json.data ?? null;
|
||||
}
|
||||
|
||||
// ── Cache layer (60s TTL) ──
|
||||
let graphCache: { data: unknown; ts: number } | null = null;
|
||||
// ── Per-space cache layer (60s TTL) ──
|
||||
const graphCaches = new Map<string, { data: unknown; ts: number }>();
|
||||
const CACHE_TTL = 60_000;
|
||||
|
||||
// ── API: Health ──
|
||||
routes.get("/api/health", (c) => {
|
||||
return c.json({ ok: true, module: "network", twentyConfigured: !!TWENTY_API_TOKEN });
|
||||
const space = c.req.param("space") || "demo";
|
||||
const token = getTokenForSpace(space);
|
||||
return c.json({ ok: true, module: "network", space, twentyConfigured: !!token });
|
||||
});
|
||||
|
||||
// ── API: Info ──
|
||||
routes.get("/api/info", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const token = getTokenForSpace(space);
|
||||
return c.json({
|
||||
module: "network",
|
||||
description: "Community relationship graph visualization",
|
||||
entityTypes: ["person", "company", "opportunity"],
|
||||
features: ["force-directed layout", "CRM sync", "real-time collaboration"],
|
||||
twentyConfigured: !!TWENTY_API_TOKEN,
|
||||
space,
|
||||
twentyConfigured: !!token,
|
||||
});
|
||||
});
|
||||
|
||||
// ── API: People ──
|
||||
routes.get("/api/people", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const token = getTokenForSpace(space);
|
||||
const data = await twentyQuery(`{
|
||||
people(first: 200) {
|
||||
edges {
|
||||
|
|
@ -70,8 +91,8 @@ routes.get("/api/people", async (c) => {
|
|||
}
|
||||
}
|
||||
}
|
||||
}`);
|
||||
if (!data) return c.json({ people: [], error: TWENTY_API_TOKEN ? "Twenty API error" : "Twenty not configured" });
|
||||
}`, undefined, space);
|
||||
if (!data) return c.json({ people: [], error: token ? "Twenty API error" : "Twenty not configured" });
|
||||
const people = ((data as any).people?.edges || []).map((e: any) => e.node);
|
||||
c.header("Cache-Control", "public, max-age=60");
|
||||
return c.json({ people });
|
||||
|
|
@ -79,6 +100,8 @@ routes.get("/api/people", async (c) => {
|
|||
|
||||
// ── API: Companies ──
|
||||
routes.get("/api/companies", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const token = getTokenForSpace(space);
|
||||
const data = await twentyQuery(`{
|
||||
companies(first: 200) {
|
||||
edges {
|
||||
|
|
@ -92,8 +115,8 @@ routes.get("/api/companies", async (c) => {
|
|||
}
|
||||
}
|
||||
}
|
||||
}`);
|
||||
if (!data) return c.json({ companies: [], error: TWENTY_API_TOKEN ? "Twenty API error" : "Twenty not configured" });
|
||||
}`, undefined, space);
|
||||
if (!data) return c.json({ companies: [], error: token ? "Twenty API error" : "Twenty not configured" });
|
||||
const companies = ((data as any).companies?.edges || []).map((e: any) => e.node);
|
||||
c.header("Cache-Control", "public, max-age=60");
|
||||
return c.json({ companies });
|
||||
|
|
@ -101,13 +124,17 @@ routes.get("/api/companies", async (c) => {
|
|||
|
||||
// ── API: Graph — transform entities to node/edge format ──
|
||||
routes.get("/api/graph", async (c) => {
|
||||
// Check cache
|
||||
if (graphCache && Date.now() - graphCache.ts < CACHE_TTL) {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const token = getTokenForSpace(space);
|
||||
|
||||
// Check per-space cache
|
||||
const cached = graphCaches.get(space);
|
||||
if (cached && Date.now() - cached.ts < CACHE_TTL) {
|
||||
c.header("Cache-Control", "public, max-age=60");
|
||||
return c.json(graphCache.data);
|
||||
return c.json(cached.data);
|
||||
}
|
||||
|
||||
if (!TWENTY_API_TOKEN) {
|
||||
if (!token) {
|
||||
return c.json({
|
||||
nodes: [
|
||||
{ id: "demo-1", label: "Alice", type: "person", data: {} },
|
||||
|
|
@ -157,7 +184,7 @@ routes.get("/api/graph", async (c) => {
|
|||
}
|
||||
}
|
||||
}
|
||||
}`);
|
||||
}`, undefined, space);
|
||||
|
||||
if (!data) return c.json({ nodes: [], edges: [], error: "Twenty API error" });
|
||||
|
||||
|
|
@ -198,7 +225,7 @@ routes.get("/api/graph", async (c) => {
|
|||
}
|
||||
|
||||
const result = { nodes, edges, demo: false };
|
||||
graphCache = { data: result, ts: Date.now() };
|
||||
graphCaches.set(space, { data: result, ts: Date.now() });
|
||||
c.header("Cache-Control", "public, max-age=60");
|
||||
return c.json(result);
|
||||
} catch (e) {
|
||||
|
|
@ -214,17 +241,44 @@ routes.get("/api/workspaces", (c) => {
|
|||
]);
|
||||
});
|
||||
|
||||
// ── CRM sub-route — dedicated iframe to Twenty CRM ──
|
||||
// ── API: Opportunities ──
|
||||
routes.get("/api/opportunities", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const token = getTokenForSpace(space);
|
||||
const data = await twentyQuery(`{
|
||||
opportunities(first: 200) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
stage
|
||||
amount { amountMicros currencyCode }
|
||||
company { id name }
|
||||
pointOfContact { id name { firstName lastName } }
|
||||
createdAt
|
||||
closeDate
|
||||
}
|
||||
}
|
||||
}
|
||||
}`, undefined, space);
|
||||
if (!data) return c.json({ opportunities: [], error: token ? "Twenty API error" : "Twenty not configured" });
|
||||
const opportunities = ((data as any).opportunities?.edges || []).map((e: any) => e.node);
|
||||
c.header("Cache-Control", "public, max-age=60");
|
||||
return c.json({ opportunities });
|
||||
});
|
||||
|
||||
// ── CRM sub-route — API-driven CRM view ──
|
||||
routes.get("/crm", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
return c.html(renderExternalAppShell({
|
||||
return c.html(renderShell({
|
||||
title: `${space} — CRM | rSpace`,
|
||||
moduleId: "rnetwork",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
appUrl: "https://crm.rspace.online",
|
||||
appName: "Twenty CRM",
|
||||
theme: "dark",
|
||||
body: `<folk-crm-view space="${space}"></folk-crm-view>`,
|
||||
scripts: `<script type="module" src="/modules/rnetwork/folk-crm-view.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -234,14 +288,15 @@ routes.get("/", (c) => {
|
|||
const view = c.req.query("view");
|
||||
|
||||
if (view === "app") {
|
||||
return c.html(renderExternalAppShell({
|
||||
title: `${space} — Twenty CRM | rSpace`,
|
||||
return c.html(renderShell({
|
||||
title: `${space} — CRM | rSpace`,
|
||||
moduleId: "rnetwork",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
appUrl: "https://demo.rnetwork.online",
|
||||
appName: "Twenty CRM",
|
||||
theme: "dark",
|
||||
body: `<folk-crm-view space="${space}"></folk-crm-view>`,
|
||||
scripts: `<script type="module" src="/modules/rnetwork/folk-crm-view.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue