Menu
Public repository / main
AkurAI-CRM / site/frontend/databinder.js
Latest change 95d0fbb40c7a5cfcad35aff736d002228c61a48c - AKURAI-CRM-4 render dashboard from live data by Ólafur Búi Ólafsson
// databinder.js — Client-side data binding for CRM pages
// Fetches from API and renders into DOM
import { client } from './crm.js';
export async function loadTable(entity, tableBodyId) {
try {
const data = await client.list(entity);
const tbody = document.getElementById(tableBodyId);
if (!tbody || !data || !data.length) {
if (tbody) tbody.innerHTML = '<tr><td colspan="10" class="empty-state">No records found</td></tr>';
return data || [];
}
return data;
} catch (e) {
console.warn(`Failed to load ${entity}:`, e);
const tbody = document.getElementById(tableBodyId);
if (tbody) tbody.innerHTML = `<tr><td colspan="10" class="empty-state">Error loading ${entity}</td></tr>`;
return [];
}
}
function dashboardState(id, message, type = 'loading') {
const target = document.getElementById(id);
if (!target) return;
const baseClass = target.dataset.baseClass || target.className;
target.dataset.baseClass = baseClass;
target.className = type === 'loading' ? baseClass : `${baseClass} empty-state dashboard-state-${type}`;
target.textContent = message;
}
function renderDashboardPipeline(opportunities) {
const target = document.getElementById('dashboard-pipeline');
if (!target) return;
const stages = ['new', 'screening', 'meeting', 'proposal', 'negotiation', 'won', 'lost'];
const labels = { new: 'New', screening: 'Screening', meeting: 'Meeting', proposal: 'Proposal', negotiation: 'Negotiation', won: 'Won', lost: 'Lost' };
const totals = Object.fromEntries(stages.map(stage => [stage, opportunities.filter(item => (item.stage || 'new') === stage).reduce((sum, item) => sum + (item.amount || 0), 0)]));
const max = Math.max(...Object.values(totals), 1);
if (!opportunities.length) {
dashboardState('dashboard-pipeline', 'No opportunities recorded.', 'empty');
return;
}
target.innerHTML = stages.map(stage => `
<div class="pipeline-bar">
<div class="bar" style="height:${Math.max(8, Math.round(totals[stage] / max * 100))}px;background:${getStageColor(stage)};"></div>
<span class="bar-value">${totals[stage] ? '$' + (totals[stage] / 100).toLocaleString() : '—'}</span>
<span class="bar-label">${labels[stage]}</span>
</div>`).join('');
}
function renderDashboardActivity(people, companies, opportunities, tasks) {
const target = document.getElementById('dashboard-activity');
if (!target) return;
const events = [
...people.map(item => ({ date: item.createdAt, text: `${item.name || `${item.firstName || ''} ${item.lastName || ''}`.trim() || 'Person'} was added as a contact` })),
...companies.map(item => ({ date: item.createdAt, text: `${item.name || 'Company'} was added as a company` })),
...opportunities.map(item => ({ date: item.updatedAt || item.createdAt, text: `${item.name || 'Opportunity'} is in ${item.stage || 'new'}` })),
...tasks.filter(item => item.status === 'done').map(item => ({ date: item.updatedAt || item.createdAt, text: `Task ${item.title || 'Untitled'} was completed` })),
].filter(event => event.date).sort((a, b) => b.date - a.date).slice(0, 8);
if (!events.length) {
dashboardState('dashboard-activity', 'No activity recorded.', 'empty');
return;
}
target.innerHTML = events.map(event => `<div class="feed-item"><div class="feed-content"><div class="feed-text">${escapeHtml(event.text)}</div><div class="feed-meta">${escapeHtml(formatDetailDate(event.date))}</div></div></div>`).join('');
}
function renderDashboardTasks(tasks) {
const target = document.getElementById('dashboard-tasks');
if (!target) return;
const upcoming = tasks.filter(item => item.status !== 'done').sort((a, b) => (a.dueAt || Infinity) - (b.dueAt || Infinity)).slice(0, 6);
if (!upcoming.length) {
dashboardState('dashboard-tasks', 'No upcoming tasks.', 'empty');
return;
}
target.innerHTML = upcoming.map(task => `<div class="task-item"><span class="task-title">${escapeHtml(task.title || 'Untitled task')}</span><div class="task-meta"><span class="task-due">${escapeHtml(task.dueAt ? formatDetailDate(task.dueAt) : '—')}</span><span class="badge badge-blue">${escapeHtml(task.status || 'todo')}</span></div></div>`).join('');
}
function renderDashboardQuickStats(opportunities, people) {
const target = document.getElementById('dashboard-quick-stats');
if (!target) return;
const average = opportunities.length ? opportunities.reduce((sum, item) => sum + (item.amount || 0), 0) / opportunities.length : 0;
const decided = opportunities.filter(item => ['won', 'lost'].includes(item.stage));
const winRate = decided.length ? (decided.filter(item => item.stage === 'won').length / decided.length) * 100 : 0;
const cycles = opportunities.filter(item => item.createdAt && item.closeDate).map(item => item.closeDate - item.createdAt).filter(days => days >= 0);
const averageCycle = cycles.length ? cycles.reduce((sum, days) => sum + days, 0) / cycles.length / 86400 : 0;
target.innerHTML = `<div style="display:flex;flex-direction:column;gap:var(--space-4);">
<div style="display:flex;justify-content:space-between;align-items:center;"><span class="cell-muted">Avg. Deal Size</span><strong>${average ? '$' + (average / 100).toLocaleString() : '—'}</strong></div>
<div style="display:flex;justify-content:space-between;align-items:center;"><span class="cell-muted">Win Rate</span><strong>${decided.length ? Math.round(winRate) + '%' : '—'}</strong></div>
<div style="display:flex;justify-content:space-between;align-items:center;"><span class="cell-muted">Avg. Sales Cycle</span><strong>${averageCycle ? Math.round(averageCycle) + ' days' : '—'}</strong></div>
<div style="display:flex;justify-content:space-between;align-items:center;"><span class="cell-muted">Active Contacts</span><strong>${people.length}</strong></div>
</div>`;
}
export async function loadStats() {
dashboardState('dashboard-pipeline', 'Loading pipeline…');
dashboardState('dashboard-activity', 'Loading activity…');
dashboardState('dashboard-tasks', 'Loading tasks…');
dashboardState('dashboard-quick-stats', 'Loading stats…');
try {
const [people, companies, opportunities, tasks] = await Promise.all([
client.list('people'),
client.list('companies'),
client.list('opportunities'),
client.list('tasks'),
]);
const activeDeals = opportunities.filter(item => !['won', 'lost'].includes(item.stage));
const revenue = opportunities.filter(item => item.stage !== 'lost').reduce((sum, item) => sum + (item.amount || 0), 0);
setText('stat-contacts', (people.length + companies.length).toString());
setText('stat-deals', activeDeals.length.toString());
setText('stat-revenue', '$' + (revenue / 100).toLocaleString());
setText('stat-tasks', tasks.filter(item => item.status !== 'done').length.toString());
setText('stat-contacts-side', people.length.toString());
renderDashboardPipeline(opportunities);
renderDashboardActivity(people, companies, opportunities, tasks);
renderDashboardTasks(tasks);
renderDashboardQuickStats(opportunities, people);
} catch (error) {
console.warn('Failed to load dashboard:', error);
['dashboard-pipeline', 'dashboard-activity', 'dashboard-tasks', 'dashboard-quick-stats'].forEach(id => dashboardState(id, 'Unable to load dashboard data.', 'error'));
['stat-contacts', 'stat-deals', 'stat-revenue', 'stat-tasks'].forEach(id => setText(id, '—'));
}
}
export function renderPeopleTable(data, tbodyId) {
const tbody = document.getElementById(tbodyId);
if (!tbody) return;
tbody.innerHTML = data.map(p => `
<tr>
<td><a href="/person-detail.html?id=${p.id}" class="person-cell">
<div class="avatar avatar-sm">${(p.firstName?.[0]||'')}${(p.lastName?.[0]||'')}</div>
${p.firstName || ''} ${p.lastName || ''}
</a></td>
<td class="cell-muted">${p.email || '—'}</td>
<td class="cell-muted">${p.phone || '—'}</td>
<td class="cell-muted">${p.jobTitle || '—'}</td>
<td class="cell-muted">${p.companyId ? `<a href="/company-detail.html?id=${p.companyId}">Company #${p.companyId}</a>` : '—'}</td>
</tr>
`).join('');
updatePagination(tbodyId, data.length);
}
export function renderCompaniesTable(data, tbodyId) {
const tbody = document.getElementById(tbodyId);
if (!tbody) return;
tbody.innerHTML = data.map(c => `
<tr>
<td><a href="/company-detail.html?id=${c.id}" class="person-cell">${c.name || 'Unnamed'}</a></td>
<td class="cell-muted">${c.domainName || '—'}</td>
<td class="cell-muted">${c.annualRevenue ? '$' + (c.annualRevenue / 100).toLocaleString() : '—'}</td>
<td class="cell-muted">${c.employeeCount || '—'}</td>
<td class="cell-muted">${c.websiteUrl ? `<a href="${c.websiteUrl}" target="_blank">${c.websiteUrl}</a>` : '—'}</td>
</tr>
`).join('');
updatePagination(tbodyId, data.length);
}
export function renderOpportunitiesTable(data, tbodyId) {
const tbody = document.getElementById(tbodyId);
if (!tbody) return;
tbody.innerHTML = data.map(o => {
const stageClass = `badge badge-${o.stage || 'new'}`;
return `<tr>
<td><a href="/opportunity-detail.html?id=${o.id}">${o.name || 'Unnamed'}</a></td>
<td class="cell-muted">${o.amount ? '$' + (o.amount / 100).toLocaleString() : '—'}</td>
<td><span class="${stageClass}">${o.stage || 'new'}</span></td>
<td class="cell-muted">${o.probability != null ? o.probability + '%' : '—'}</td>
<td class="cell-muted">${o.personId ? `Person #${o.personId}` : '—'}</td>
</tr>`;
}).join('');
updatePagination(tbodyId, data.length);
}
export function renderTasksList(data, listId) {
const list = document.getElementById(listId);
if (!list) return;
list.innerHTML = data.map(t => {
const isDone = t.status === 'done';
return `<div class="task-item${isDone ? ' done' : ''}" data-status="${t.status || 'todo'}">
<input type="checkbox" class="task-checkbox"${isDone ? ' checked' : ''}>
<span class="task-title">${t.title || 'Untitled'}</span>
<div class="task-meta">
<span class="task-due">${t.dueDate || '—'}</span>
<span class="badge badge-${isDone ? 'green' : 'blue'}">${t.relatedTo || t.status || 'todo'}</span>
</div>
</div>`;
}).join('');
updatePagination(listId, data.length);
}
function setText(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
}
function formatDetailDate(value) {
if (!value) return '—';
const date = new Date(Number(value) * 1000);
return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString();
}
function setState(message, type = 'loading') {
const state = document.getElementById('detail-state');
if (!state) return;
state.textContent = message;
state.className = `empty-state detail-state-${type}`;
state.hidden = !message;
}
function setLink(id, text, href) {
const link = document.getElementById(id);
if (!link) return;
link.textContent = text || '—';
if (href) {
link.href = href;
link.hidden = false;
} else {
link.removeAttribute('href');
link.hidden = true;
}
}
function related(record, field, id) {
return Array.isArray(record?.[field]) && record[field].some(value => String(value) === String(id));
}
function renderTimeline(id, timeline) {
const target = document.getElementById(id);
if (!target) return;
if (!timeline?.length) {
target.innerHTML = '<div class="empty-state">No activity recorded.</div>';
return;
}
target.innerHTML = timeline.map(event => `
<div class="timeline-item">
<div class="timeline-dot"></div>
<div class="timeline-content">
<div class="timeline-title">${escapeHtml(event.action || 'Activity')}</div>
<div class="timeline-meta">${escapeHtml(formatDetailDate(event.createdAt))}</div>
</div>
</div>`).join('');
}
function renderRelatedTasks(id, tasks) {
const target = document.getElementById(id);
if (!target) return;
if (!tasks?.length) {
target.innerHTML = '<div class="empty-state">No tasks recorded.</div>';
return;
}
target.innerHTML = tasks.map(task => `
<div class="task-item${task.status === 'done' ? ' done' : ''}">
<span class="task-title">${escapeHtml(task.title || 'Untitled task')}</span>
<div class="task-meta">
<span class="task-due">${escapeHtml(task.dueAt ? formatDetailDate(task.dueAt) : '—')}</span>
<span class="badge badge-${task.status === 'done' ? 'green' : 'blue'}">${escapeHtml(task.status || 'todo')}</span>
</div>
</div>`).join('');
}
function renderRelatedNotes(id, notes) {
const target = document.getElementById(id);
if (!target) return;
if (!notes?.length) {
target.innerHTML = '<div class="empty-state">No notes recorded.</div>';
return;
}
target.innerHTML = notes.map(note => `
<article class="card">
<div class="note-card-header">
<h3 class="note-card-title">${escapeHtml(note.title || 'Untitled note')}</h3>
<span class="text-xs text-muted">${escapeHtml(formatDetailDate(note.createdAt))}</span>
</div>
<p class="note-card-preview">${escapeHtml(note.body || '')}</p>
</article>`).join('');
}
function renderRelatedOpportunities(id, opportunities) {
const target = document.getElementById(id);
if (!target) return;
const open = (opportunities || []).filter(opportunity => !['won', 'lost'].includes(opportunity.stage));
if (!open.length) {
target.innerHTML = '<div class="empty-state">No open opportunities.</div>';
return;
}
target.className = '';
target.innerHTML = open.map(opportunity => `
<div style="display:flex;align-items:center;justify-content:space-between;padding:var(--space-2) 0;border-top:1px solid var(--color-border-light);">
<a href="/opportunity-detail.html?id=${encodeURIComponent(opportunity.id)}">${escapeHtml(opportunity.name || 'Unnamed opportunity')}</a>
<span class="badge badge-${escapeHtml(opportunity.stage || 'new')}">${escapeHtml(opportunity.stage || 'new')}</span>
</div>`).join('');
}
function updatePagination(tbodyId, total) {
const parent = document.getElementById(tbodyId)?.closest('.card') || document;
const pagination = parent.querySelector('.pagination');
if (pagination) {
const info = pagination.querySelector('.pagination-info');
if (info) info.textContent = `Showing all ${total} records`;
}
}
// Render person detail page
export async function loadPersonDetail(personId) {
setState('Loading person…');
if (!/^\d+$/.test(String(personId || ''))) {
setState('Select a person to view.', 'error');
return;
}
try {
const [person, companies, opportunities, tasks, notes, timeline] = await Promise.all([
client.get('people', personId),
client.list('companies'),
client.list('opportunities'),
client.list('tasks'),
client.list('notes'),
client.timeline('person', personId),
]);
const company = companies.find(item => String(item.id) === String(person.companyId));
const name = person.name || `${person.firstName || ''} ${person.lastName || ''}`.trim() || 'Unnamed person';
setText('detail-name', name);
setText('breadcrumb-name', name);
document.title = `${name} — AkurAI-CRM`;
setText('detail-jobtitle', person.jobTitle || '—');
setText('detail-jobtitle-secondary', person.jobTitle || '—');
setText('detail-email', person.email || '—');
setText('detail-phone', person.phone || '—');
setText('detail-address', person.address || '—');
setText('detail-linkedin', person.linkedinUrl || '—');
setText('detail-company', company?.name || '—');
setLink('detail-company-link', company?.name || '—', company ? `/company-detail.html?id=${company.id}` : '');
setText('detail-department', person.department || '—');
setText('detail-reports-to', person.reportsTo || '—');
setText('detail-status', 'Person');
setText('detail-avatar', `${person.firstName?.[0] || ''}${person.lastName?.[0] || ''}` || '—');
renderRelatedOpportunities('person-opportunities', opportunities.filter(item => String(item.personId) === String(personId)));
renderRelatedTasks('person-tasks', tasks.filter(item => related(item, 'personIds', personId)));
renderRelatedNotes('person-notes', notes.filter(item => related(item, 'personIds', personId)));
renderTimeline('person-timeline', timeline.timeline || []);
setState('');
} catch (error) {
console.warn('Failed to load person detail:', error);
setState('Unable to load this person. Try again later.', 'error');
}
}
// Render company detail page
export async function loadCompanyDetail(companyId) {
setState('Loading company…');
if (!/^\d+$/.test(String(companyId || ''))) {
setState('Select a company to view.', 'error');
return;
}
try {
const [company, people, opportunities, timeline] = await Promise.all([
client.get('companies', companyId),
client.list('people'),
client.list('opportunities'),
client.timeline('company', companyId),
]);
const name = company.name || 'Unnamed company';
setText('detail-company-name', name);
setText('breadcrumb-name', name);
document.title = `${name} — AkurAI-CRM`;
setText('detail-domain', company.domainName || '—');
setText('detail-domain-info', company.domainName || '—');
setText('detail-revenue', company.annualRevenue ? '$' + (company.annualRevenue / 100).toLocaleString() : '—');
setText('detail-employees', company.employeeCount?.toString() || '—');
setText('detail-address', company.address || '—');
setText('detail-phone', company.phone || '—');
setText('detail-industry', company.industry || '—');
setText('detail-description', company.description || 'No description provided.');
setText('detail-status', 'Company');
setLink('detail-website', company.websiteUrl || '—', company.websiteUrl);
const employees = people.filter(person => String(person.companyId) === String(companyId));
const tbody = document.getElementById('company-people-tbody');
if (tbody) {
tbody.innerHTML = employees.length
? employees.map(person => `<tr><td><a href="/person-detail.html?id=${encodeURIComponent(person.id)}">${escapeHtml(person.name || `${person.firstName || ''} ${person.lastName || ''}`)}</a></td><td class="cell-muted">${escapeHtml(person.email || '—')}</td><td class="cell-muted">${escapeHtml(person.jobTitle || '—')}</td></tr>`).join('')
: '<tr><td colspan="3" class="empty-state">No contacts at this company.</td></tr>';
}
renderRelatedOpportunities('company-opportunities', opportunities.filter(item => String(item.companyId) === String(companyId)));
renderTimeline('company-timeline', timeline.timeline || []);
setText('tab-people-count', employees.length);
setText('tab-opportunities-count', opportunities.filter(item => String(item.companyId) === String(companyId)).length);
setState('');
} catch (error) {
console.warn('Failed to load company detail:', error);
setState('Unable to load this company. Try again later.', 'error');
}
}
// Render opportunity detail page
export async function loadOpportunityDetail(oppId) {
setState('Loading opportunity…');
if (!/^\d+$/.test(String(oppId || ''))) {
setState('Select an opportunity to view.', 'error');
return;
}
try {
const [opportunity, people, companies, tasks, timeline] = await Promise.all([
client.get('opportunities', oppId),
client.list('people'),
client.list('companies'),
client.list('tasks'),
client.timeline('opportunity', oppId),
]);
const stage = opportunity.stage || 'new';
const stageLabels = { new: 'New', screening: 'Screening', meeting: 'Meeting', proposal: 'Proposal', negotiation: 'Negotiation', won: 'Won', lost: 'Lost' };
const stages = Object.keys(stageLabels);
const stageIndex = stages.indexOf(stage);
const closeDate = opportunity.closeDate ? formatDetailDate(opportunity.closeDate) : '—';
const person = people.find(item => String(item.id) === String(opportunity.personId));
const company = companies.find(item => String(item.id) === String(opportunity.companyId));
setText('detail-deal-name', opportunity.name || 'Unnamed opportunity');
setText('breadcrumb-name', opportunity.name || 'Opportunity');
document.title = `${opportunity.name || 'Opportunity'} — AkurAI-CRM`;
setText('detail-deal-stage', stageLabels[stage] || stage);
setText('detail-deal-close-date', closeDate);
setText('detail-deal-close-date-info', closeDate);
setText('detail-deal-amount', opportunity.amount ? '$' + (opportunity.amount / 100).toLocaleString() : '—');
setText('detail-deal-probability', opportunity.probability != null ? `${opportunity.probability}%` : '—');
setText('detail-deal-expected-value', opportunity.amount && opportunity.probability != null ? '$' + ((opportunity.amount * opportunity.probability / 100) / 100).toLocaleString() : '—');
setLink('detail-deal-person', person?.name || '—', person ? `/person-detail.html?id=${person.id}` : '');
setLink('detail-deal-company', company?.name || '—', company ? `/company-detail.html?id=${company.id}` : '');
setText('detail-deal-type', opportunity.dealType || '—');
setText('detail-deal-description', opportunity.description || 'No description provided.');
setText('detail-deal-tasks-heading', 'Tasks');
const stageTarget = document.getElementById('detail-stage-progress');
if (stageTarget) {
stageTarget.innerHTML = stages.map((label, index) => `<div class="stage-step${index < stageIndex ? ' completed' : ''}${index === stageIndex ? ' active' : ''}"><div class="stage-circle${index < stageIndex ? ' completed' : ''}${index === stageIndex ? ' active' : ''}">${index < stageIndex ? '✓' : index + 1}</div><div class="stage-label">${stageLabels[label]}</div></div>${index < stages.length - 1 ? `<div class="stage-connector${index < stageIndex ? ' completed' : ''}"></div>` : ''}`).join('');
}
renderRelatedTasks('detail-deal-tasks', tasks.filter(item => related(item, 'opportunityIds', oppId)));
renderTimeline('detail-deal-timeline', timeline.timeline || []);
setState('');
} catch (error) {
console.warn('Failed to load opportunity detail:', error);
setState('Unable to load this opportunity. Try again later.', 'error');
}
}
// Render notes grid
export async function loadNotes() {
try {
const notes = await client.list('notes');
const grid = document.getElementById('notes-grid');
if (!grid) return;
grid.innerHTML = notes.map(n => `
<div class="card note-card">
<div class="note-card-header">
<h3 class="note-card-title">${n.title || 'Untitled Note'}</h3>
<span class="text-xs text-muted">${new Date((n.createdAt || 0) * 1000).toLocaleDateString()}</span>
</div>
<div class="note-card-preview">${(n.body || '').substring(0, 120)}${(n.body || '').length > 120 ? '...' : ''}</div>
</div>
`).join('');
} catch(e) {
console.warn('Failed to load notes:', e);
}
}
// Render kanban board from API data
export async function loadKanban() {
try {
const opps = await client.list('opportunities');
const stages = ['new', 'screening', 'meeting', 'proposal', 'negotiation', 'won', 'lost'];
const stageLabels = { new: 'New', screening: 'Screening', meeting: 'Meeting', proposal: 'Proposal', negotiation: 'Negotiation', won: 'Won', lost: 'Lost' };
stages.forEach(stage => {
const col = document.getElementById(`kanban-${stage}`);
if (!col) return;
const cards = opps.filter(o => (o.stage || 'new') === stage);
col.innerHTML = cards.map(o => `
<a href="/opportunity-detail.html?id=${o.id}" class="kanban-card" style="text-decoration:none;color:inherit;border-left-color: ${getStageColor(stage)};">
<div class="card-title">${o.name || 'Unnamed'}</div>
<div class="card-subtitle">${o.amount ? '$' + (o.amount / 100).toLocaleString() : '—'}</div>
<div class="card-footer">
<div class="card-amount">${o.amount ? '$' + (o.amount / 100).toLocaleString() : '—'}</div>
<span class="badge badge-${stage}">${stageLabels[stage] || stage}</span>
</div>
</a>
`).join('');
// Update column header count
const header = col.closest('.kanban-column')?.querySelector('.kanban-count');
if (header) header.textContent = cards.length.toString();
});
} catch(e) {
console.warn('Failed to load kanban:', e);
}
}
function getStageColor(stage) {
const colors = { new: '#94a3b8', screening: '#60a5fa', meeting: '#818cf8', proposal: '#a78bfa', negotiation: '#f59e0b', won: '#22c55e', lost: '#ef4444' };
return colors[stage] || '#94a3b8';
}
// Auto-init: run stat loading on dashboard
if (window.location.pathname.includes('dashboard')) {
loadStats();
}