Total AUM
--
Loading...
Active Investors
--
Loading...
Average ROI
--
Weighted across portfolios
Monthly Revenue
--
Loading...
Total Distributed
--
All-time payouts to investors
Portfolio IRR
--
Weighted avg IRR
Fund NAV
--
Net Asset Value

Analytics & Trends

Revenue Over Time
Daily net profit across all stores
Total Net Profit
Loading...
Revenue by Platform
Net profit breakdown by marketplace
Top Platform
Loading...
ROI Per Investor
■ >35% ■ 20–35% ■ <20%
Avg ROI
Loading...
AUM Growth
Cumulative capital under management over time
Current AUM
Loading...

All Investors

Live Data
Showing all investors
Loading portfolio data...

Notification Log

Loading notification log...

Activity Log

Loading activity log...

Monthly Reports

Loading report history...

Weekly Digest

Portfolio health digest sent to Sohail — risk changes, top performers, action items, and revenue summary. Delivered to: sohail@gec.live
Loading digest history...

Communications Hub

Send bulk announcements to investors — distribution notices, quarterly updates, or custom messages. Variables: {{investor_name}} {{total_invested}} {{roi_percent}}
Loading announcement history...
+ parseFloat(r.fixed_amount || 0).toFixed(2) : parseFloat(r.allocation_percent || 0).toFixed(1) + '%'; const typeBadge = r.rule_type === 'revenue_share' ? 'Revenue Share' : r.rule_type === 'profit_share' ? 'Profit Share' : 'Fixed Amount'; const statusBadge = r.status === 'active' ? 'Active' : r.status === 'paused' ? 'Paused' : 'Expired'; const storeLabel = r.store_id ? escapeHtml(r.store_name || 'Store') : 'Fund-level'; const effectiveTo = r.effective_to ? fmtDateShort(r.effective_to) : 'Ongoing'; const editBtn = ''; const deleteBtn = ''; return '' + '' + escapeHtml(r.investor_name || '') + '' + '' + storeLabel + '' + '' + typeBadge + '' + '' + allocDisplay + '' + '' + fmtDateShort(r.effective_from) + '' + '' + effectiveTo + '' + '' + statusBadge + '' + '' + editBtn + deleteBtn + '' + ''; }).join(''); } async function loadInvestorOptionsForRules() { try { const res = await authFetch('/api/admin/investors'); const data = await res.json(); if (!data.success) return; const invSelect = document.getElementById('ruleInvestorSelect'); if (!invSelect) return; const investors = data.investors || []; invSelect.innerHTML = '' + investors.map(i => '' ).join(''); } catch (err) { console.warn('Failed to load investors for rules:', err); } } function onRuleInvestorChanged() { const investorId = document.getElementById('ruleInvestorSelect').value; const storeSelect = document.getElementById('ruleStoreSelect'); if (!storeSelect || !investorId) return; // Load stores for this investor loadStoresForInvestor(investorId, storeSelect); } async function loadStoresForInvestor(investorId, storeSelect) { try { const res = await authFetch('/api/admin/investors/' + investorId + '/stores'); const data = await res.json(); if (!data.success) return; const stores = data.stores || []; storeSelect.innerHTML = '' + stores.map(s => '' ).join(''); } catch (err) { console.warn('Failed to load stores:', err); } } function onRuleTypeChanged() { const ruleType = document.getElementById('ruleTypeSelect').value; const allocField = document.getElementById('ruleAllocationField'); const fixedField = document.getElementById('ruleFixedAmountField'); if (ruleType === 'fixed_amount') { if (allocField) allocField.style.display = 'none'; if (fixedField) fixedField.style.display = ''; } else { if (allocField) allocField.style.display = ''; if (fixedField) fixedField.style.display = 'none'; } } function showAddRuleModal(ruleId) { const modal = document.getElementById('addRuleModal'); if (!modal) return; // Reset form document.getElementById('ruleInvestorSelect').value = ''; document.getElementById('ruleStoreSelect').innerHTML = ''; document.getElementById('ruleTypeSelect').value = 'revenue_share'; document.getElementById('ruleAllocationInput').value = ''; document.getElementById('ruleFixedAmountInput').value = ''; document.getElementById('ruleEffectiveFrom').value = ''; document.getElementById('ruleEffectiveTo').value = ''; document.getElementById('ruleNotesInput').value = ''; onRuleTypeChanged(); if (ruleId) { // Edit mode - prefill from cache const rule = distributionRulesCache.find(r => r.id === ruleId); if (rule) { document.getElementById('ruleInvestorSelect').value = rule.investor_id; // Load stores then set store const storeSelect = document.getElementById('ruleStoreSelect'); loadStoresForInvestor(rule.investor_id, storeSelect).then(() => { storeSelect.value = rule.store_id || ''; }); document.getElementById('ruleTypeSelect').value = rule.rule_type; document.getElementById('ruleEffectiveFrom').value = rule.effective_from || ''; document.getElementById('ruleEffectiveTo').value = rule.effective_to || ''; document.getElementById('ruleNotesInput').value = rule.notes || ''; onRuleTypeChanged(); if (rule.rule_type === 'fixed_amount') { document.getElementById('ruleFixedAmountInput').value = rule.fixed_amount || ''; } else { document.getElementById('ruleAllocationInput').value = rule.allocation_percent || ''; } modal.dataset.editingId = ruleId; } } else { delete modal.dataset.editingId; } modal.style.display = 'flex'; } async function saveDistributionRule() { const modal = document.getElementById('addRuleModal'); const editingId = modal ? modal.dataset.editingId : null; const investor_id = document.getElementById('ruleInvestorSelect').value; const store_id = document.getElementById('ruleStoreSelect').value; const rule_type = document.getElementById('ruleTypeSelect').value; const allocation_percent = parseFloat(document.getElementById('ruleAllocationInput').value) || 0; const fixed_amount = parseFloat(document.getElementById('ruleFixedAmountInput').value) || 0; const effective_from = document.getElementById('ruleEffectiveFrom').value; const effective_to = document.getElementById('ruleEffectiveTo').value; const notes = document.getElementById('ruleNotesInput').value; if (!investor_id || !rule_type || !effective_from) { showToast('Investor, rule type, and effective from date are required', 'error'); return; } const payload = { investor_id: parseInt(investor_id), store_id: store_id ? parseInt(store_id) : null, rule_type, allocation_percent, fixed_amount: rule_type === 'fixed_amount' ? fixed_amount : null, effective_from, effective_to: effective_to || null, notes: notes || null }; try { let res; if (editingId) { res = await authFetch('/api/admin/distribution-rules/' + editingId, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); } else { res = await authFetch('/api/admin/distribution-rules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); } const data = await res.json(); if (!data.success) throw new Error(data.message); showToast(editingId ? 'Rule updated' : 'Rule created', 'success'); closeModal('addRuleModal'); loadDistributionRulesSection(); } catch (err) { showToast('Failed: ' + err.message, 'error'); } } function editDistributionRule(ruleId) { showAddRuleModal(ruleId); } async function deleteDistributionRule(ruleId) { if (!confirm('Delete this distribution rule? This cannot be undone.')) return; try { const res = await authFetch('/api/admin/distribution-rules/' + ruleId, { method: 'DELETE' }); const data = await res.json(); if (!data.success) throw new Error(data.message); showToast('Rule deleted', 'success'); loadDistributionRulesSection(); } catch (err) { showToast('Failed: ' + err.message, 'error'); } } function showCalculateDistributionsModal() { const modal = document.getElementById('calculateDistModal'); if (!modal) return; document.getElementById('calcResultArea').style.display = 'none'; document.getElementById('calcErrorArea').style.display = 'none'; document.getElementById('calcPeriodInput').value = ''; modal.style.display = 'flex'; } async function runDistributionCalculation() { const period = document.getElementById('calcPeriodInput').value; if (!period) { showToast('Select a period', 'error'); return; } const resultArea = document.getElementById('calcResultArea'); const errorArea = document.getElementById('calcErrorArea'); const resultText = document.getElementById('calcResultText'); const errorText = document.getElementById('calcErrorText'); resultArea.style.display = 'none'; errorArea.style.display = 'none'; try { const res = await authFetch('/api/admin/distributions/calculate?period=' + period, { method: 'POST' }); const data = await res.json(); if (!data.success) throw new Error(data.message); resultText.textContent = data.message || (data.draft_count + ' drafts created. Batch ID: ' + data.batch_id); resultArea.style.display = ''; showToast('Calculation complete: ' + data.draft_count + ' drafts', 'success'); } catch (err) { errorText.textContent = err.message; errorArea.style.display = ''; } } function showViewDraftsModal() { const modal = document.getElementById('viewDraftsModal'); if (!modal) return; document.getElementById('draftBatchInput').value = ''; document.getElementById('draftsLoadingArea').style.display = ''; document.getElementById('draftsContentArea').style.display = 'none'; modal.style.display = 'flex'; } let currentDraftBatch = null; async function loadDistributionDrafts() { const batchId = document.getElementById('draftBatchInput').value.trim(); if (!batchId) { showToast('Enter a batch ID', 'error'); return; } currentDraftBatch = batchId; const loadingArea = document.getElementById('draftsLoadingArea'); const contentArea = document.getElementById('draftsContentArea'); loadingArea.style.display = ''; contentArea.style.display = 'none'; try { const res = await authFetch('/api/admin/distributions/drafts?batch_id=' + encodeURIComponent(batchId)); const data = await res.json(); if (!data.success) throw new Error(data.message); loadingArea.style.display = 'none'; contentArea.style.display = ''; renderDrafts(data.drafts, data.by_investor, batchId); } catch (err) { loadingArea.textContent = 'Error: ' + err.message; loadingArea.style.display = ''; } } function renderDrafts(drafts, byInvestor, batchId) { const summaryBar = document.getElementById('draftsSummaryBar'); const tableArea = document.getElementById('draftsTableArea'); const actionArea = document.getElementById('draftsActionArea'); if (!drafts || drafts.length === 0) { summaryBar.innerHTML = 'No drafts found for this batch.'; tableArea.innerHTML = ''; actionArea.innerHTML = ''; return; } const totalAmount = drafts.reduce((s, d) => s + parseFloat(d.net_amount || 0), 0); summaryBar.innerHTML = '
Total Drafts
' + drafts.length + '
' + '
Total Amount
' + fmtFull(totalAmount) + '
' + '
Status
' + (drafts[0].status || 'draft') + '
'; tableArea.innerHTML = '' + '' + '' + '' + '' + '' + drafts.map(d => '' + '' + '' + '' + '' + '').join('') + '
InvestorStoreTypeNet Amount
' + escapeHtml(d.investor_name || '') + '' + (d.store_name || 'Fund-level') + '' + (d.rule_type || '') + '' + fmtFull(parseFloat(d.net_amount || 0)) + '
'; const status = drafts[0].status; if (status === 'draft') { actionArea.innerHTML = '' + ''; } else { actionArea.innerHTML = 'This batch is ' + status + ''; } } async function approveDistributionBatch() { if (!currentDraftBatch) return; if (!confirm('Approve this distribution batch? This will create actual distribution records for investors. This cannot be undone.')) return; try { const res = await authFetch('/api/admin/distributions/drafts/' + currentDraftBatch + '/approve', { method: 'POST' }); const data = await res.json(); if (!data.success) throw new Error(data.message); showToast('Batch approved: ' + data.finalized_count + ' distributions created', 'success'); closeModal('viewDraftsModal'); loadDistributionRulesSection(); } catch (err) { showToast('Failed: ' + err.message, 'error'); } } async function voidDistributionBatch() { if (!currentDraftBatch) return; if (!confirm('Void this distribution batch? All drafts will be marked as voided. This cannot be undone.')) return; try { const res = await authFetch('/api/admin/distributions/drafts/' + currentDraftBatch + '/void', { method: 'POST' }); const data = await res.json(); if (!data.success) throw new Error(data.message); showToast('Batch voided: ' + data.voided_count + ' drafts', 'success'); closeModal('viewDraftsModal'); } catch (err) { showToast('Failed: ' + err.message, 'error'); } } // ============================================= // END DISTRIBUTION RULES // ============================================= async function loadCapitalCallsSection() { const tbody = document.getElementById('capitalCallsTableBody'); if (!tbody) return; const statusFilter = document.getElementById('capitalCallsFilter') ? document.getElementById('capitalCallsFilter').value : ''; try { const url = '/api/admin/capital-calls' + (statusFilter ? '?status=' + statusFilter : ''); const res = await authFetch(url); const data = await res.json(); if (!data.success) throw new Error(data.message); const calls = data.calls; if (calls.length === 0) { tbody.innerHTML = 'No capital calls found.'; return; } tbody.innerHTML = calls.map(c => ` ${escapeHtml(c.investor_name)} Call ${c.call_number} ${fmtDateShort(c.call_date)} ${fmtDateShort(c.due_date)} ${fmtFull(c.amount_called)} ${fmtFull(c.amount_received)} ${callStatusBadge(c.status)} ${c.status !== 'received' ? `` : ''} `).join(''); } catch (err) { tbody.innerHTML = `Failed to load: ${err.message}`; } } async function markCallReceivedGlobal(callId) { try { const res = await authFetch(`/api/admin/capital-calls/${callId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'received' }) }); const data = await res.json(); if (!data.success) throw new Error(data.message); showToast('Call marked as received', 'success'); loadCapitalCallsSection(); loadCapitalSummaryKpi(); } catch (err) { showToast('Failed: ' + err.message, 'error'); } } async function bulkMarkReceived() { const checked = [...document.querySelectorAll('.capital-call-check:checked')].map(el => parseInt(el.value)); if (checked.length === 0) { showToast('Select calls to mark received', 'error'); return; } try { const res = await authFetch('/api/admin/capital-calls/bulk-received', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ call_ids: checked }) }); const data = await res.json(); if (!data.success) throw new Error(data.message); showToast(`${data.updated_count} call(s) marked received`, 'success'); loadCapitalCallsSection(); loadCapitalSummaryKpi(); } catch (err) { showToast('Bulk update failed: ' + err.message, 'error'); } } function refreshCapitalCallsSection() { if (document.getElementById('capitalCallsSection') && document.getElementById('capitalCallsSection').style.display !== 'none') { loadCapitalCallsSection(); } } function scrollToCapitalCalls(e) { e.preventDefault(); const el = document.getElementById('capital-calls-section'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); } function scrollToCashFlow(e) { e.preventDefault(); const el = document.getElementById('cash-flow-section'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); } function fmtDateShort(iso) { if (!iso) return '—'; const d = new Date(iso); return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); } async function loadAllStatements() { const tbody = document.getElementById('statementsTableBody'); if (!tbody) return; const period = document.getElementById('stmtPeriodFilter').value; try { const url = '/api/admin/statements' + (period ? '?period=' + encodeURIComponent(period) : ''); const res = await authFetch(url); const data = await res.json(); if (!data.success) throw new Error(data.message); const stmts = data.statements; if (stmts.length === 0) { tbody.innerHTML = 'No statements generated yet.'; return; } // Populate period filter dropdown const filterSel = document.getElementById('stmtPeriodFilter'); const periods = [...new Set(stmts.map(s => s.statement_period))].sort().reverse(); const curVal = filterSel.value; filterSel.innerHTML = '' + periods.map(p => ``).join(''); tbody.innerHTML = stmts.map(s => ` ${s.investor_name || '—'} ${s.statement_period} ${fmtDateShort(s.period_start)} – ${fmtDateShort(s.period_end)} ${s.generated_by || '—'} ${fmtDateShort(s.created_at)} `).join(''); } catch (err) { tbody.innerHTML = `${err.message}`; } } // ============================================ // 2FA / SECURITY // ============================================ let _twoFaSecret = null; async function loadSecurity() { const container = document.getElementById('securityContent'); if (!container) return; try { const res = await authFetch('/api/admin/2fa/status'); const data = await res.json(); if (!data.success) throw new Error(data.message); if (data.totp_enabled) { const since = data.totp_verified_at ? ' since ' + new Date(data.totp_verified_at).toLocaleDateString('en-US', { month:'long', day:'numeric', year:'numeric' }) : ''; container.innerHTML = `
🔒

Two-Factor Authentication

Active${since}. Your account is protected with TOTP.

✓ Enabled ${data.backup_codes_remaining} backup code${data.backup_codes_remaining !== 1 ? 's' : ''} remaining
`; } else { container.innerHTML = `
🔓

Two-Factor Authentication

Add an extra layer of security. After login, you'll enter a code from your phone.

`; } } catch (err) { if (container) container.innerHTML = `
Failed to load security settings: ${escapeHtml(err.message)}
`; } } async function startTwoFaSetup() { try { const res = await authFetch('/api/admin/2fa/setup', { method: 'POST' }); const data = await res.json(); if (!data.success) throw new Error(data.message); _twoFaSecret = data.secret; document.getElementById('twoFaQrImg').src = data.qr_url; document.getElementById('twoFaSecretDisplay').textContent = data.secret; document.getElementById('twoFaStep1').style.display = ''; document.getElementById('twoFaStep2').style.display = 'none'; document.getElementById('twoFaStep3').style.display = 'none'; document.getElementById('twoFaStep1Error').style.display = 'none'; const backdrop = document.getElementById('twoFaModalBackdrop'); backdrop.style.display = 'flex'; } catch (err) { showToast('Could not start 2FA setup: ' + err.message, 'error'); } } function twoFaSetupNext() { document.getElementById('twoFaStep1').style.display = 'none'; document.getElementById('twoFaStep2').style.display = ''; document.getElementById('twoFaStep2Error').style.display = 'none'; document.getElementById('twoFaVerifyCode').value = ''; setTimeout(() => document.getElementById('twoFaVerifyCode').focus(), 50); } async function twoFaDoVerify() { const btn = document.getElementById('twoFaVerifyBtn'); const errEl = document.getElementById('twoFaStep2Error'); const code = document.getElementById('twoFaVerifyCode').value.replace(/\s/g, ''); errEl.style.display = 'none'; btn.disabled = true; btn.textContent = 'Verifying...'; try { const res = await authFetch('/api/admin/2fa/verify', { method: 'POST', body: JSON.stringify({ code }) }); const data = await res.json(); if (!data.success) throw new Error(data.message); // Show backup codes const box = document.getElementById('twoFaBackupCodesBox'); box.innerHTML = data.backup_codes.map(c => `
${c}
`).join(''); document.getElementById('twoFaStep2').style.display = 'none'; document.getElementById('twoFaStep3').style.display = ''; } catch (err) { errEl.textContent = err.message; errEl.style.display = 'block'; btn.disabled = false; btn.textContent = 'Enable 2FA'; document.getElementById('twoFaVerifyCode').value = ''; document.getElementById('twoFaVerifyCode').focus(); } } function closeTwoFaModal() { document.getElementById('twoFaModalBackdrop').style.display = 'none'; _twoFaSecret = null; } function openTwoFaDisableModal() { document.getElementById('twoFaDisablePassword').value = ''; document.getElementById('twoFaDisableError').style.display = 'none'; const backdrop = document.getElementById('twoFaDisableBackdrop'); backdrop.style.display = 'flex'; setTimeout(() => document.getElementById('twoFaDisablePassword').focus(), 50); } function closeTwoFaDisableModal() { document.getElementById('twoFaDisableBackdrop').style.display = 'none'; } async function twoFaDoDisable() { const btn = document.getElementById('twoFaDisableBtn'); const errEl = document.getElementById('twoFaDisableError'); const password = document.getElementById('twoFaDisablePassword').value; errEl.style.display = 'none'; if (!password) { errEl.textContent = 'Password is required'; errEl.style.display = 'block'; return; } btn.disabled = true; btn.textContent = 'Disabling...'; try { const res = await authFetch('/api/admin/2fa/disable', { method: 'POST', body: JSON.stringify({ password }) }); const data = await res.json(); if (!data.success) throw new Error(data.message); closeTwoFaDisableModal(); showToast('2FA disabled', 'success'); loadSecurity(); // Reload team to update badges if owner if (currentUser && currentUser.role === 'owner') loadTeam(); } catch (err) { errEl.textContent = err.message; errEl.style.display = 'block'; btn.disabled = false; btn.textContent = 'Disable 2FA'; } } // ============================================ // TOAST // ============================================ function showToast(message, type = 'success') { const existing = document.querySelector('.toast'); if (existing) existing.remove(); const toast = document.createElement('div'); toast.className = 'toast ' + type; toast.textContent = message; document.body.appendChild(toast); setTimeout(() => toast.remove(), 3000); } // ============================================ // FORMATTING // ============================================ function fmt(n) { n = parseFloat(n) || 0; if (Math.abs(n) >= 1000000) return (n < 0 ? '-' : '') + '$' + (Math.abs(n) / 1000000).toFixed(1) + 'M'; if (Math.abs(n) >= 1000) return (n < 0 ? '-' : '') + '$' + (Math.abs(n) / 1000).toFixed(0) + 'K'; return '$' + n.toFixed(0); } function fmtFull(n) { n = parseFloat(n) || 0; return (n < 0 ? '-$' : '$') + Math.abs(n).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); } function escapeHtml(str) { if (!str) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function modelLabel(t) { return t === 'private_label' ? 'Private Label' : 'Wholesale'; } function capitalize(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : ''; } function fmtDate(d) { if (!d) return ''; return new Date(d + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); } // ============================================ // SUMMARY // ============================================ async function loadSummary() { try { const res = await authFetch('/api/admin/dashboard-summary'); const data = await res.json(); if (!data.success) throw new Error(data.message); const kpis = data.kpis; const topPerformers = data.top_performers || []; const engagement = data.engagement || {}; // KPI Cards document.getElementById('totalAum').textContent = kpis.total_aum_formatted || fmt(kpis.total_aum); document.getElementById('aumSub').textContent = kpis.active_investors + ' active investor' + (kpis.active_investors !== 1 ? 's' : ''); document.getElementById('activeInvestors').textContent = kpis.active_investors + ' / ' + kpis.total_investors; document.getElementById('investorsSub').textContent = 'active / total'; document.getElementById('avgRoi').textContent = kpis.avg_roi + '%'; document.getElementById('avgRoi').className = 'summary-value ' + (kpis.avg_roi >= 0 ? 'roi-positive' : 'roi-negative'); document.getElementById('monthlyRevenue').textContent = kpis.monthly_revenue_formatted || fmt(kpis.monthly_revenue); const delta = kpis.monthly_revenue_delta; const deltaArrow = delta > 0 ? '↑' : (delta < 0 ? '↓' : ''); const deltaClass = delta > 0 ? 'delta-positive' : (delta < 0 ? 'delta-negative' : ''); document.getElementById('revenueDelta').innerHTML = delta !== 0 ? `${deltaArrow}${Math.abs(delta)}% vs last month` : 'No change'; // Total Distributed card if (document.getElementById('totalDistributed')) { document.getElementById('totalDistributed').textContent = kpis.total_distributed_formatted || fmt(kpis.total_distributed || 0); const distThisMonth = kpis.total_distributed_this_month || 0; document.getElementById('distributedSub').textContent = distThisMonth > 0 ? fmt(distThisMonth) + ' this month' : 'All-time payouts to investors'; } // Show KPI Details section document.getElementById('kpiDetails').style.display = 'block'; // Top Performers Table const performersBody = document.getElementById('topPerformersBody'); if (topPerformers.length > 0) { performersBody.innerHTML = topPerformers.map(inv => { const roiClass = inv.roi_percent >= 0 ? 'roi-positive' : 'roi-negative'; const roiPrefix = inv.roi_percent >= 0 ? '+' : ''; return ` ${escapeHtml(inv.name)} ${fmt(inv.invested_amount)} ${roiPrefix}${inv.roi_percent}% ${fmt(inv.revenue_30d)} `; }).join(''); } else { performersBody.innerHTML = 'No data available'; } // Engagement Stats document.getElementById('activeThisWeek').textContent = engagement.active_this_week || 0; document.getElementById('neverLoggedIn').textContent = engagement.never_logged_in || 0; // Portfolio IRR (async, non-blocking) loadPortfolioReturns(); // Portfolio NAV (async, non-blocking) loadPortfolioNAV(); } catch (err) { console.error('Failed to load summary:', err); } } // ============================================ // INVESTOR SEARCH, FILTER & SORT // ============================================ let investorSearchTimeout = null; let currentSortConfig = JSON.parse(localStorage.getItem('investorSortConfig') || '{"column":"name","direction":"asc"}'); function handleInvestorSearch() { const searchInput = document.getElementById('investorSearch'); const clearBtn = document.getElementById('clearSearch'); clearBtn.style.display = searchInput.value ? 'flex' : 'none'; if (investorSearchTimeout) clearTimeout(investorSearchTimeout); investorSearchTimeout = setTimeout(() => { applyInvestorFilters(); }, 300); } function clearInvestorSearch() { document.getElementById('investorSearch').value = ''; document.getElementById('clearSearch').style.display = 'none'; applyInvestorFilters(); } function getFilteredInvestors() { const searchTerm = (document.getElementById('investorSearch').value || '').toLowerCase().trim(); const statusFilter = document.getElementById('filterStatus').value; const modelFilter = document.getElementById('filterModel').value; const investmentFilter = document.getElementById('filterInvestment').value; const riskFilter = document.getElementById('filterRisk') ? document.getElementById('filterRisk').value : ''; let filtered = [...investorsCache]; // Search filter if (searchTerm) { filtered = filtered.filter(inv => (inv.name && inv.name.toLowerCase().includes(searchTerm)) || (inv.email && inv.email.toLowerCase().includes(searchTerm)) || (inv.phone && inv.phone.toLowerCase().includes(searchTerm)) ); } // Status filter if (statusFilter) { filtered = filtered.filter(inv => (inv.status || 'active') === statusFilter); } // Model type filter if (modelFilter) { filtered = filtered.filter(inv => (inv.model_type || 'wholesale') === modelFilter); } // Investment range filter if (investmentFilter) { filtered = filtered.filter(inv => { const amount = parseFloat(inv.invested_amount) || 0; switch (investmentFilter) { case 'under50k': return amount < 50000; case '50k-100k': return amount >= 50000 && amount < 100000; case '100k-250k': return amount >= 100000 && amount < 250000; case '250k+': return amount >= 250000; default: return true; } }); } // Risk category filter if (riskFilter) { filtered = filtered.filter(inv => (inv.risk_category || '') === riskFilter); } // Sort const sortCol = currentSortConfig.column || 'name'; const sortDir = currentSortConfig.direction || 'asc'; filtered.sort((a, b) => { let valA, valB; switch (sortCol) { case 'name': valA = (a.name || '').toLowerCase(); valB = (b.name || '').toLowerCase(); break; case 'invested': valA = parseFloat(a.invested_amount) || 0; valB = parseFloat(b.invested_amount) || 0; break; case 'roi': valA = parseFloat(a.roi) || 0; valB = parseFloat(b.roi) || 0; break; case 'irr': valA = a.irr_annual !== null && a.irr_annual !== undefined ? parseFloat(a.irr_annual) : -9999; valB = b.irr_annual !== null && b.irr_annual !== undefined ? parseFloat(b.irr_annual) : -9999; break; case 'nav': valA = a.nav !== null && a.nav !== undefined ? parseFloat(a.nav) : -9999; valB = b.nav !== null && b.nav !== undefined ? parseFloat(b.nav) : -9999; break; case 'risk_score': valA = typeof a.risk_score === 'number' ? a.risk_score : 50; valB = typeof b.risk_score === 'number' ? b.risk_score : 50; break; case 'created': valA = new Date(a.created_at || 0).getTime(); valB = new Date(b.created_at || 0).getTime(); break; default: valA = a[sortCol] || ''; valB = b[sortCol] || ''; } if (valA < valB) return sortDir === 'asc' ? -1 : 1; if (valA > valB) return sortDir === 'asc' ? 1 : -1; return 0; }); return filtered; } function applyInvestorFilters() { if (!investorsCache || investorsCache.length === 0) return; const filtered = getFilteredInvestors(); renderInvestorTable(filtered); updateFilterStatus(); } function populateModelTypeFilter() { if (!investorsCache || investorsCache.length === 0) return; const modelSelect = document.getElementById('filterModel'); const existingOptions = new Set(); for (const inv of investorsCache) { if (inv.model_type) existingOptions.add(inv.model_type); } // Keep the "All" option, clear others modelSelect.innerHTML = ''; // Add options for each unique model type found in data const sortedModels = Array.from(existingOptions).sort(); for (const model of sortedModels) { const option = document.createElement('option'); option.value = model; option.textContent = model.charAt(0).toUpperCase() + model.slice(1); modelSelect.appendChild(option); } } function updateFilterStatus() { const searchTerm = document.getElementById('investorSearch').value; const statusFilter = document.getElementById('filterStatus').value; const modelFilter = document.getElementById('filterModel').value; const investmentFilter = document.getElementById('filterInvestment').value; const riskFilter = document.getElementById('filterRisk') ? document.getElementById('filterRisk').value : ''; let activeCount = 0; if (searchTerm) activeCount++; if (statusFilter) activeCount++; if (modelFilter) activeCount++; if (investmentFilter) activeCount++; if (riskFilter) activeCount++; const filterCountEl = document.getElementById('activeFilterCount'); const clearAllEl = document.getElementById('clearAllFilters'); if (activeCount > 0) { filterCountEl.textContent = `${activeCount} filter${activeCount > 1 ? 's' : ''} active · `; clearAllEl.style.display = 'inline'; } else { filterCountEl.textContent = ''; clearAllEl.style.display = 'none'; } // Update results count const filtered = getFilteredInvestors(); const total = investorsCache.length; const resultsEl = document.getElementById('resultsCount'); if (activeCount > 0) { resultsEl.textContent = `Showing ${filtered.length} of ${total} investors`; } else { resultsEl.textContent = `Showing all ${total} investor${total !== 1 ? 's' : ''}`; } } function clearAllInvestorFilters(e) { if (e) e.preventDefault(); document.getElementById('investorSearch').value = ''; document.getElementById('clearSearch').style.display = 'none'; document.getElementById('filterStatus').value = ''; document.getElementById('filterModel').value = ''; document.getElementById('filterInvestment').value = ''; if (document.getElementById('filterRisk')) document.getElementById('filterRisk').value = ''; applyInvestorFilters(); } function sortInvestorTable(column) { if (currentSortConfig.column === column) { currentSortConfig.direction = currentSortConfig.direction === 'asc' ? 'desc' : 'asc'; } else { currentSortConfig.column = column; currentSortConfig.direction = 'asc'; } localStorage.setItem('investorSortConfig', JSON.stringify(currentSortConfig)); applyInvestorFilters(); } function getSortIndicator(column) { if (currentSortConfig.column !== column) return '↕'; return currentSortConfig.direction === 'asc' ? '↑' : '↓'; } function getSortClass(column) { if (currentSortConfig.column !== column) return 'sortable'; return 'sortable ' + currentSortConfig.direction; } // Modified renderInvestorTable function to accept pre-filtered data function renderInvestorTable(data) { if (!data || data.length === 0) { document.getElementById('tableContent').innerHTML = `

No investors found

Try adjusting your search or filters.

`; return; } let totalAum = 0; const totalInvestors = data.length; function getActivityDot(inv) { const status = inv.activity_status || inv.status_color; const color = inv.status_color || 'gray'; const labels = { active_7d: 'Active (7d)', active_30d: 'Active (30d)', inactive_30d_plus: 'Inactive', never_login: 'Never logged in', never_setup: 'Never set up' }; const label = labels[status] || 'Unknown'; return ``; } let html = ``; for (const inv of data) { totalAum += parseFloat(inv.invested_amount); const stores = inv.stores || []; const storeCount = stores.length; const activeCount = stores.filter(s => s.status === 'active').length; const roiClass = inv.roi >= 0 ? 'roi-positive' : 'roi-negative'; const roiPrefix = inv.roi >= 0 ? '+' : ''; const modelType = inv.model_type || 'wholesale'; const invStatus = inv.status || 'active'; const isExpanded = expandedRows.has(inv.id); const inviteStatus = inv.invite_status; const canResend = inv.email && inviteStatus !== 'password_set'; const inviteBadge = inviteStatus ? `
${inviteStatus === 'password_set' ? '✓ Password Set' : '✉ Invite Sent'}
` : (inv.email ? '
No Invite
' : ''); const profileEditBadge = inv.has_recent_profile_edits ? '
📝 Profile Updated
' : ''; const lastContactHtml = buildLastContactBadge(inv.last_note_at); html += ``; } html += '
Investor ${getSortIndicator('name')} Invested ${getSortIndicator('invested')} Model Status Activity Stores Revenue ROI ${getSortIndicator('roi')} IRR ${getSortIndicator('irr')} NAV ${getSortIndicator('nav')} Last Contact Risk ${getSortIndicator('risk_score')} Actions
${inv.name}
${inv.email || ''}
${fmtFull(inv.invested_amount)} ${modelLabel(modelType)} ${capitalize(invStatus)} ${inviteBadge} ${profileEditBadge} ${getActivityDot(inv)} ${storeCount} (${activeCount} active) ${fmtFull(inv.total_revenue)} ${roiPrefix}${inv.roi}% ${buildIRRBadge(inv.irr_annual)} ${lastContactHtml} ${buildRiskBadge(inv)}
${canResend ? `` : ''}
'; html += ``; document.getElementById('tableContent').innerHTML = html; // Re-load expanded rows for (const id of expandedRows) { loadExpandedStores(id); } } // ============================================ // IRR BADGE + PORTFOLIO RETURNS // ============================================ function buildIRRBadge(irr) { if (irr === null || irr === undefined) { return ''; } const val = parseFloat(irr); let cls, label; if (val >= 10) { cls = 'roi-positive'; } else if (val >= 5) { cls = 'irr-medium'; } else { cls = 'roi-negative'; } const prefix = val >= 0 ? '+' : ''; return `${prefix}${val.toFixed(1)}%`; } async function recalculateAllReturns() { const btn = document.getElementById('recalcReturnsBtn'); if (!btn) return; const origText = btn.innerHTML; btn.disabled = true; btn.innerHTML = 'Recalculating…'; try { const res = await authFetch('/api/admin/returns/recalculate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); const data = await res.json(); if (!data.success) throw new Error(data.message); showToast(`Returns recalculated for ${data.recalculated} investor${data.recalculated !== 1 ? 's' : ''}.`, 'success'); loadPortfolioReturns(); } catch (err) { showToast('Failed to recalculate returns: ' + err.message, 'error'); } finally { btn.disabled = false; btn.innerHTML = origText; } } async function loadPortfolioReturns() { try { const res = await authFetch('/api/admin/returns/summary'); if (!res.ok) return; const data = await res.json(); if (!data.success || !data.summary) return; const s = data.summary; const irrEl = document.getElementById('portfolioIRR'); const irrSubEl = document.getElementById('portfolioIRRSub'); if (irrEl && s.avg_irr !== null && s.avg_irr !== undefined) { const val = parseFloat(s.avg_irr); const prefix = val >= 0 ? '+' : ''; irrEl.textContent = prefix + val.toFixed(1) + '%'; irrEl.className = 'summary-value ' + (val >= 10 ? 'roi-positive' : val >= 5 ? 'irr-medium' : 'roi-negative'); } else if (irrEl) { irrEl.textContent = 'No data'; } if (irrSubEl && s.investors_calculated > 0) { irrSubEl.textContent = `Across ${s.investors_calculated} investor${s.investors_calculated !== 1 ? 's' : ''}`; } // If returns data available, re-merge into investorsCache and re-render if (data.investors && data.investors.length > 0 && investorsCache && investorsCache.length > 0) { const returnsMap = new Map(); for (const r of data.investors) returnsMap.set(r.investor_id, r); investorsCache = investorsCache.map(inv => { const ret = returnsMap.get(inv.id) || {}; return { ...inv, irr_annual: ret.irr_annual !== undefined ? ret.irr_annual : null, twr_annual: ret.twr_annual !== undefined ? ret.twr_annual : null }; }); renderInvestorTable(getFilteredInvestors()); } } catch (err) { console.warn('[Returns] Failed to load portfolio returns:', err.message); } } // ============================================ // RISK SCORING // ============================================ function buildRiskBadge(inv) { if (inv.risk_score === undefined || inv.risk_score === null) { return ''; } const cat = inv.risk_category || 'watch'; const score = inv.risk_score; const label = cat === 'healthy' ? 'Healthy' : cat === 'watch' ? 'Watch' : 'At Risk'; const breakdown = inv.risk_breakdown || {}; const primaryLabel = inv.risk_primary_label || ''; // Build tooltip with breakdown const tips = Object.entries(breakdown).map(([k, v]) => `${k}: ${v.score}/100 (${v.weight}%)`).join(' '); const tooltip = `Score: ${score}/100 ${primaryLabel ? 'Primary concern: ' + primaryLabel + ' ' : ''} Breakdown: ${tips}`; return `${label}${score}`; } function renderPortfolioHealth(riskData) { const { summary, investors } = riskData; if (!summary || summary.total === 0) return; // Update counts document.getElementById('healthyCount').textContent = summary.healthy; document.getElementById('watchCount').textContent = summary.watch; document.getElementById('atRiskCount').textContent = summary.at_risk; // Donut chart via conic-gradient const total = summary.total; const healthyPct = Math.round((summary.healthy / total) * 100); const watchPct = Math.round((summary.watch / total) * 100); const atRiskPct = 100 - healthyPct - watchPct; const donut = document.getElementById('healthDonut'); if (donut) { donut.style.background = `conic-gradient(#16a34a 0% ${healthyPct}%, #d97706 ${healthyPct}% ${healthyPct + watchPct}%, #dc2626 ${healthyPct + watchPct}% 100%)`; } // Legend const legend = document.getElementById('healthLegend'); if (legend) { legend.innerHTML = `
${summary.healthy} Healthy (${healthyPct}%)
${summary.watch} Watch (${watchPct}%)
${summary.at_risk} At Risk (${atRiskPct}%)
`; } // Top at-risk list (worst first, show up to 5 with watch + at_risk) const atRiskInvestors = [...investors] .filter(i => i.category === 'at_risk' || i.category === 'watch') .sort((a, b) => a.score - b.score) .slice(0, 6); const topAtRiskList = document.getElementById('topAtRiskList'); if (topAtRiskList) { if (atRiskInvestors.length === 0) { topAtRiskList.innerHTML = '
✓ All investors are healthy — great work!
'; } else { topAtRiskList.innerHTML = atRiskInvestors.map(inv => { const catLabel = inv.category === 'at_risk' ? 'At Risk' : 'Watch'; const catClass = inv.category === 'at_risk' ? 'at-risk' : 'watch'; return `
${escapeHtml(inv.name)} ${catLabel}${inv.score}
${escapeHtml(inv.primary_risk_label || '')}
View →
`; }).join(''); } } // At-Risk Alert Panel (only show if there are at_risk investors) const atRiskPanel = document.getElementById('atRiskPanel'); const atRiskAlertList = document.getElementById('atRiskAlertList'); const atRiskOnly = [...investors].filter(i => i.category === 'at_risk').sort((a, b) => a.score - b.score); if (atRiskOnly.length > 0 && atRiskPanel && atRiskAlertList) { document.getElementById('atRiskPanelSubtitle').textContent = `${atRiskOnly.length} investor${atRiskOnly.length !== 1 ? 's' : ''} need attention`; atRiskAlertList.innerHTML = atRiskOnly.map(inv => `
${inv.score}
${escapeHtml(inv.name)}
${escapeHtml(inv.primary_risk_label || 'Multiple risk factors')}
View investor →
`).join(''); atRiskPanel.style.display = 'block'; } // Show portfolio health section document.getElementById('portfolioHealthSection').style.display = 'block'; } function scrollToInvestor(investorId) { // Filter by this investor or scroll to their row const row = document.getElementById('row-' + investorId); if (row) { row.scrollIntoView({ behavior: 'smooth', block: 'center' }); row.style.background = '#fffbeb'; setTimeout(() => { row.style.background = ''; }, 2000); } } // ============================================ // INVESTOR TABLE // ============================================ async function loadInvestors() { try { // Fetch investors, activity data, and risk scores in parallel const [investorsRes, activityRes, riskRes] = await Promise.all([ authFetch('/api/investors'), authFetch('/api/admin/investor-activity').catch(() => ({ ok: false, json: () => ({ success: false, investors: [] }) })), authFetch('/api/admin/risk-scores').catch(() => ({ ok: false, json: () => ({ success: false, investors: [] }) })) ]); const data = await investorsRes.json(); if (!data.success) throw new Error(data.message); // Merge activity data into investors cache const activityData = activityRes.ok ? await activityRes.json() : { success: false, investors: [] }; const activityMap = new Map(); if (activityData.success && activityData.investors) { for (const inv of activityData.investors) { activityMap.set(inv.id, inv); } } // Merge risk data const riskData = riskRes.ok ? await riskRes.json() : { success: false, investors: [] }; const riskMap = new Map(); if (riskData.success && riskData.investors) { for (const inv of riskData.investors) { riskMap.set(inv.id, inv); } // Render portfolio health renderPortfolioHealth(riskData); } // Enhance investors with activity + risk data investorsCache = data.investors.map(inv => { const activity = activityMap.get(inv.id) || {}; const risk = riskMap.get(inv.id) || {}; return { ...inv, activity_status: activity.activity_status, status_color: activity.status_color, last_login_at: activity.last_login_at, login_count_30d: activity.login_count_30d, risk_score: risk.score, risk_category: risk.category, risk_breakdown: risk.breakdown, risk_primary_label: risk.primary_risk_label }; }); if (data.investors.length === 0) { document.getElementById('tableContent').innerHTML = `

No investors yet

Click "Add Investor" to get started.

`; document.getElementById('resultsCount').textContent = 'No investors'; return; } // Use the filtered rendering system populateModelTypeFilter(); renderInvestorTable(getFilteredInvestors()); updateFilterStatus(); // Re-load expanded rows for (const id of expandedRows) { loadExpandedStores(id); } } catch (err) { console.error('Failed to load investors:', err); document.getElementById('tableContent').innerHTML = `

Failed to load data

${err.message}

`; } } // ============================================ // EXPANDABLE ROW - STORES // ============================================ async function toggleExpand(investorId, event) { if (event) event.stopPropagation(); const expandRow = document.getElementById('expand-' + investorId); const toggleBtn = document.querySelector('#row-' + investorId + ' .expand-toggle'); if (!expandRow) return; const isVisible = expandRow.style.display !== 'none'; if (isVisible) { expandRow.style.display = 'none'; expandedRows.delete(investorId); if (toggleBtn) { toggleBtn.textContent = '▶'; toggleBtn.classList.remove('open'); } } else { expandRow.style.display = 'table-row'; expandedRows.add(investorId); if (toggleBtn) { toggleBtn.textContent = '▼'; toggleBtn.classList.add('open'); } await loadExpandedStores(investorId); } } async function loadExpandedStores(investorId) { const content = document.getElementById('expand-content-' + investorId); if (!content) return; content.innerHTML = '
'; try { const res = await authFetch('/api/investors/' + investorId + '/stores'); const data = await res.json(); if (!data.success) throw new Error(data.message); const inv = investorsCache.find(i => i.id === investorId); const invested = parseFloat(inv ? inv.invested_amount : 0); // Calculate totals from revenue_entries const totalRevenue = data.stores.reduce((s, st) => s + parseFloat(st.total_revenue || 0), 0); const totalNetProfit = data.stores.reduce((s, st) => s + parseFloat(st.total_net_profit || 0), 0); const totalUnits = data.stores.reduce((s, st) => s + parseInt(st.total_units_sold || 0), 0); const roi = invested > 0 ? ((totalNetProfit / invested) * 100) : 0; let storesHtml = ''; if (data.stores.length === 0) { storesHtml = `
No stores yet — add one to start tracking revenue.
`; } else { storesHtml = '
'; for (const st of data.stores) { const netP = parseFloat(st.total_net_profit || 0); const rev = parseFloat(st.total_revenue || 0); const netClass = netP >= 0 ? 'positive' : 'negative'; const storeUrl = st.url ? `href="${st.url}" target="_blank"` : ''; storesHtml += `
${capitalize(st.platform)}
${st.name}
${st.url ? `${st.url.replace('https://', '').replace('http://','')}` : ''}
${capitalize(st.status)}
Revenue
${fmtFull(rev)}
Net Profit
${fmtFull(netP)}
Units Sold
${parseInt(st.total_units_sold || 0).toLocaleString()}
Entries
${st.entry_count}
`; } storesHtml += '
'; } // ROI summary bar const roiClass2 = roi >= 0 ? 'positive' : 'negative'; const roiPrefix = roi >= 0 ? '+' : ''; // IRR/TWR from cached returns data const irrVal = inv && inv.irr_annual !== null && inv.irr_annual !== undefined ? parseFloat(inv.irr_annual) : null; const twrVal = inv && inv.twr_annual !== null && inv.twr_annual !== undefined ? parseFloat(inv.twr_annual) : null; const irrColor = irrVal === null ? 'var(--muted)' : (irrVal >= 10 ? 'var(--positive)' : irrVal >= 5 ? 'var(--warning)' : 'var(--negative)'); const twrColor = twrVal === null ? 'var(--muted)' : (twrVal >= 0 ? 'var(--positive)' : 'var(--negative)'); const irrDisplay = irrVal !== null ? (irrVal >= 0 ? '+' : '') + irrVal.toFixed(1) + '%' : '—'; const twrDisplay = twrVal !== null ? (twrVal >= 0 ? '+' : '') + twrVal.toFixed(1) + '%' : '—'; let roiBar = ''; if (data.stores.length > 0) { roiBar = `
Revenue (entries)
${fmtFull(totalRevenue)}
Net Profit (entries)
${fmtFull(totalNetProfit)}
Total Units
${totalUnits.toLocaleString()}
Simple ROI
${roiPrefix}${Math.round(roi * 10) / 10}%
IRR (annual)
${irrDisplay}
TWR
${twrDisplay}
`; } // Build per-store MoM mini-cards using cached store analytics data let storeMomHtml = ''; if (data.stores.length > 0 && window.storeAnalyticsCache) { const miniCards = data.stores.map(st => { const cached = window.storeAnalyticsCache.find(s => s.store_id === st.id); const curRev = cached ? cached.current_month_revenue : 0; const momPct = cached ? cached.mom_change_pct : null; const perfLabel = cached ? cached.performance_label : 'new'; const momStr = momPct !== null ? (momPct > 0 ? `+${momPct.toFixed(1)}%` : `${momPct.toFixed(1)}%`) : 'No prior data'; const momClass = momPct === null ? 'flat' : momPct > 0 ? 'positive' : momPct < 0 ? 'negative' : 'flat'; return `
${escapeHtml(st.name)}
${st.platform}
${fmtFull(curRev)}
MoM: ${momStr}
`; }).join(''); storeMomHtml = `
This Month's Performance
${miniCards}
`; } content.innerHTML = `
Stores — ${inv ? inv.name : ''}
${roiBar} ${storeMomHtml} ${storesHtml}

Communication Log

Loading notes...

Documents

Loading documents...

Distributions & Payouts

Loading distributions...

Onboarding Checklist

Loading onboarding status...

Revenue Forecast

3-month projection
Loading forecast...

Quarterly Statements

Loading statements...

Annual Tax Summaries

Loading tax summaries...

Capital Commitments & Calls

Loading capital data...
`; // Load notes, documents, distributions, onboarding, forecast, statements, capital, and NAV async loadInvestorNotes(investorId); loadInvestorDocuments(investorId); loadInvestorDistributions(investorId); loadInvestorOnboarding(investorId); loadInvestorForecastExpand(investorId); loadInvestorStatements(investorId); loadInvestorTaxSummaries(investorId); loadInvestorCapital(investorId); loadInvestorNAVBreakdown(investorId); } catch (err) { content.innerHTML = `

Failed to load stores: ${err.message}

`; } } async function loadInvestorNAVBreakdown(investorId) { const container = document.getElementById('nav-breakdown-' + investorId); if (!container) return; try { const res = await authFetch('/api/admin/investors/' + investorId + '/nav'); if (!res.ok) return; const data = await res.json(); if (!data.success || !data.nav) return; const n = data.nav; const navColor = parseFloat(n.nav) >= parseFloat(n.total_invested) ? 'var(--positive)' : 'var(--negative)'; container.innerHTML = `
NAV (Equity Position)
${fmtFull(parseFloat(n.nav))}
Invested
${fmtFull(parseFloat(n.total_invested))}
Revenue Share
${fmtFull(parseFloat(n.total_revenue_share))}
Distributed
−${fmtFull(parseFloat(n.total_distributions))}
Unrealized
${fmtFull(parseFloat(n.unrealized_value))}
As of ${new Date(n.snapshot_date).toLocaleDateString('en-US', { month:'short', day:'numeric', year:'numeric' })}
`; } catch (err) { // silently skip — NAV may not exist yet for this investor } } // ============================================ // INVESTOR NOTES (CRM) // ============================================ let quickNoteInvestorId = null; function noteTypeIcon(type) { const icons = { call: '📞', email: '✉️', meeting: '🤝', general: '📝' }; return icons[type] || '📝'; } function buildLastContactBadge(lastNoteAt) { if (!lastNoteAt) { return `Never`; } const days = Math.floor((Date.now() - new Date(lastNoteAt)) / (1000 * 60 * 60 * 24)); if (days < 7) { return `${days === 0 ? 'Today' : days + 'd ago'}`; } else if (days < 30) { return `${days}d ago`; } else { return `${days}d ago`; } } async function loadInvestorNotes(investorId) { const container = document.getElementById('notes-list-' + investorId); if (!container) return; try { const res = await authFetch('/api/admin/investors/' + investorId + '/notes?limit=20'); const data = await res.json(); if (!data.success) throw new Error(data.message); if (data.notes.length === 0) { container.innerHTML = `
No notes yet. Use "+ Add Note" to log a call, email, or meeting.
`; return; } let html = '
'; for (const note of data.notes) { const icon = noteTypeIcon(note.note_type); const ts = new Date(note.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' }); const safeContent = note.content.replace(//g, '>'); html += `
${icon}
${note.note_type} ${ts}
${safeContent}
`; } html += '
'; container.innerHTML = html; } catch (err) { container.innerHTML = `
Failed to load notes: ${err.message}
`; } } async function deleteNote(noteId, investorId) { if (!confirm('Delete this note?')) return; try { const res = await authFetch('/api/admin/investors/' + investorId + '/notes/' + noteId, { method: 'DELETE' }); const data = await res.json(); if (!data.success) throw new Error(data.message); // Remove from DOM const el = document.getElementById('note-' + noteId); if (el) el.remove(); // Check if no notes left const container = document.getElementById('notes-list-' + investorId); if (container && container.querySelectorAll('.note-item').length === 0) { container.innerHTML = `
No notes yet. Use "+ Add Note" to log a call, email, or meeting.
`; } // Update last contact badge in main table refreshLastContactBadge(investorId); } catch (err) { alert('Failed to delete note: ' + err.message); } } async function refreshLastContactBadge(investorId) { try { const res = await authFetch('/api/admin/investors/' + investorId + '/notes?limit=1'); const data = await res.json(); const cell = document.getElementById('last-contact-' + investorId); if (cell) { const lastAt = data.notes && data.notes.length > 0 ? data.notes[0].created_at : null; cell.innerHTML = buildLastContactBadge(lastAt); } } catch (_) { /* best-effort */ } } // ============================================ // INVESTOR DOCUMENTS // ============================================ function documentTypeIcon(type) { const icons = { contract: '📄', agreement: '🤝', kyc: '🛡️', report: '📊', other: '📁' }; return icons[type] || '📁'; } function formatFileSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; } let docUploadInvestorId = null; async function loadInvestorDocuments(investorId) { const container = document.getElementById('documents-list-' + investorId); if (!container) return; try { const res = await authFetch('/api/admin/investors/' + investorId + '/documents'); const data = await res.json(); if (!data.success) throw new Error(data.message); if (data.documents.length === 0) { container.innerHTML = `
No documents yet. Use "Upload" to add contracts, agreements, or KYC docs.
`; return; } let html = '
'; for (const doc of data.documents) { const icon = documentTypeIcon(doc.document_type); const ts = new Date(doc.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); const size = formatFileSize(doc.file_size); html += `
${icon}
${doc.document_type} ${ts}
${doc.original_filename}
${size}
`; } html += '
'; container.innerHTML = html; } catch (err) { container.innerHTML = `
Failed to load documents: ${err.message}
`; } } function openDocUploadModal(investorId) { docUploadInvestorId = investorId; document.getElementById('docUploadInvestorName').textContent = 'Upload Document'; document.getElementById('docUploadType').value = 'contract'; document.getElementById('docUploadFile').value = ''; document.getElementById('docUploadNotes').value = ''; document.getElementById('docUploadError').style.display = 'none'; document.getElementById('docUploadSaveBtn').disabled = false; document.getElementById('docUploadSaveBtn').textContent = 'Upload'; document.getElementById('docUploadBackdrop').classList.add('active'); } function closeDocUploadModal(event) { if (event && event.target !== document.getElementById('docUploadBackdrop')) return; document.getElementById('docUploadBackdrop').classList.remove('active'); docUploadInvestorId = null; } async function saveDocument() { if (!docUploadInvestorId) return; const fileInput = document.getElementById('docUploadFile'); const typeSelect = document.getElementById('docUploadType'); const notesInput = document.getElementById('docUploadNotes'); const errorDiv = document.getElementById('docUploadError'); const saveBtn = document.getElementById('docUploadSaveBtn'); const file = fileInput.files[0]; if (!file) { errorDiv.textContent = 'Please select a file'; errorDiv.style.display = 'block'; return; } // Validate file size (10MB max) if (file.size > 10 * 1024 * 1024) { errorDiv.textContent = 'File exceeds 10MB limit'; errorDiv.style.display = 'block'; return; } // Validate file type const allowedTypes = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'image/png', 'image/jpeg', 'text/plain']; if (!allowedTypes.includes(file.type)) { errorDiv.textContent = 'File type not allowed. Allowed: PDF, DOC, DOCX, XLS, XLSX, PNG, JPG, TXT'; errorDiv.style.display = 'block'; return; } saveBtn.disabled = true; saveBtn.textContent = 'Uploading...'; errorDiv.style.display = 'none'; try { // Read file as base64 const reader = new FileReader(); const base64Data = await new Promise((resolve, reject) => { reader.onload = () => resolve(reader.result.split(',')[1]); reader.onerror = reject; reader.readAsDataURL(file); }); const res = await authFetch('/api/admin/investors/' + docUploadInvestorId + '/documents', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ documentType: typeSelect.value, filename: file.name, mimeType: file.type, data: base64Data, notes: notesInput.value }) }); const data = await res.json(); if (!data.success) throw new Error(data.message); closeDocUploadModal(); loadInvestorDocuments(docUploadInvestorId); } catch (err) { errorDiv.textContent = err.message || 'Failed to upload document'; errorDiv.style.display = 'block'; saveBtn.disabled = false; saveBtn.textContent = 'Upload'; } } async function downloadDocument(docId) { try { const res = await authFetch('/api/admin/documents/' + docId + '/download'); if (!res.ok) { const data = await res.json(); throw new Error(data.message || 'Download failed'); } const blob = await res.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; // Try to get filename from content-disposition header const contentDisposition = res.headers.get('Content-Disposition'); if (contentDisposition && contentDisposition.includes('filename=')) { const match = contentDisposition.match(/filename="?([^";\n]+)"?/); if (match) a.download = match[1]; } else { a.download = 'document'; } document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); } catch (err) { alert('Failed to download: ' + err.message); } } async function deleteDocument(docId, investorId) { if (!confirm('Delete this document? This cannot be undone.')) return; try { const res = await authFetch('/api/admin/documents/' + docId, { method: 'DELETE' }); const data = await res.json(); if (!data.success) throw new Error(data.message); // Remove from DOM const el = document.getElementById('doc-' + docId); if (el) el.remove(); // Check if no documents left const container = document.getElementById('documents-list-' + investorId); if (container && container.querySelectorAll('.document-item').length === 0) { container.innerHTML = `
No documents yet. Use "Upload" to add contracts, agreements, or KYC docs.
`; } } catch (err) { alert('Failed to delete document: ' + err.message); } } // ============================================ // INVESTOR DISTRIBUTIONS // ============================================ let recordDistInvestorId = null; function distTypeLabel(type) { const labels = { profit_share: 'Profit Share', capital_return: 'Capital Return', bonus: 'Bonus', other: 'Other' }; return labels[type] || type; } async function loadInvestorDistributions(investorId) { const container = document.getElementById('distributions-list-' + investorId); const totalEl = document.getElementById('dist-total-' + investorId); const netPosEl = document.getElementById('net-position-' + investorId); if (!container) return; try { const res = await authFetch('/api/admin/investors/' + investorId + '/distributions'); const data = await res.json(); if (!data.success) throw new Error(data.message); // Update running total if (totalEl) { if (data.total_amount > 0) { totalEl.innerHTML = `Total Distributed: ${fmt(data.total_amount)}`; } else { totalEl.innerHTML = ''; } } // Net position + yield if (netPosEl && data.total_amount > 0) { const inv = investorsCache ? investorsCache.find(i => i.id === investorId) : null; const invested = inv ? (parseFloat(inv.invested_amount) || 0) : 0; const netPos = invested - data.total_amount; const yieldPct = invested > 0 ? Math.round((data.total_amount / invested) * 1000) / 10 : 0; netPosEl.style.display = 'flex'; netPosEl.innerHTML = `
Net Position
${fmt(netPos)}
Distribution Yield
${yieldPct}%
`; } else if (netPosEl) { netPosEl.style.display = 'none'; } if (data.distributions.length === 0) { container.innerHTML = `
No distributions recorded. Use "Record Distribution" to log a payout.
`; return; } let html = '
'; for (const d of data.distributions) { const dateStr = new Date(d.distribution_date + 'T12:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); const safeNotes = d.notes ? d.notes.replace(//g, '>') : ''; html += `
${fmt(d.amount)}
${distTypeLabel(d.distribution_type)} ${dateStr}
${safeNotes ? `
${safeNotes}
` : ''}
`; } html += '
'; container.innerHTML = html; } catch (err) { container.innerHTML = `
Failed to load distributions: ${err.message}
`; } } async function deleteDistribution(distId, investorId) { if (!confirm('Delete this distribution record?')) return; try { const res = await authFetch('/api/admin/investors/' + investorId + '/distributions/' + distId, { method: 'DELETE' }); const data = await res.json(); if (!data.success) throw new Error(data.message); // Remove from DOM then reload to refresh total/net position const el = document.getElementById('dist-' + distId); if (el) el.remove(); // Reload full distributions for this investor to update totals await loadInvestorDistributions(investorId); } catch (err) { alert('Failed to delete distribution: ' + err.message); } } function openRecordDistModal(investorId, investorName) { recordDistInvestorId = investorId; document.getElementById('recordDistTitle').textContent = 'Record Distribution — ' + (investorName || ''); document.getElementById('recordDistAmount').value = ''; document.getElementById('recordDistDate').value = new Date().toISOString().slice(0, 10); document.getElementById('recordDistType').value = 'profit_share'; document.getElementById('recordDistNotes').value = ''; document.getElementById('recordDistError').style.display = 'none'; document.getElementById('recordDistSaveBtn').disabled = false; document.getElementById('recordDistSaveBtn').textContent = 'Record Distribution'; document.getElementById('recordDistBackdrop').classList.add('active'); setTimeout(() => document.getElementById('recordDistAmount').focus(), 100); } function closeRecordDistModal(event) { if (event && event.target !== document.getElementById('recordDistBackdrop')) return; document.getElementById('recordDistBackdrop').classList.remove('active'); recordDistInvestorId = null; } async function saveDistribution() { if (!recordDistInvestorId) return; const amount = document.getElementById('recordDistAmount').value.trim(); const date = document.getElementById('recordDistDate').value; const type = document.getElementById('recordDistType').value; const notes = document.getElementById('recordDistNotes').value.trim(); const errEl = document.getElementById('recordDistError'); const btn = document.getElementById('recordDistSaveBtn'); errEl.style.display = 'none'; if (!amount || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) { errEl.textContent = 'Enter a valid positive amount.'; errEl.style.display = 'block'; return; } if (!date) { errEl.textContent = 'Distribution date is required.'; errEl.style.display = 'block'; return; } btn.disabled = true; btn.textContent = 'Saving...'; try { const res = await authFetch('/api/admin/investors/' + recordDistInvestorId + '/distributions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ amount: parseFloat(amount), distribution_date: date, distribution_type: type, notes: notes || null }) }); const data = await res.json(); if (!data.success) throw new Error(data.message); document.getElementById('recordDistBackdrop').classList.remove('active'); await loadInvestorDistributions(recordDistInvestorId); recordDistInvestorId = null; } catch (err) { errEl.textContent = err.message; errEl.style.display = 'block'; btn.disabled = false; btn.textContent = 'Record Distribution'; } } // ============================================ // INVESTOR ONBOARDING // ============================================ async function loadInvestorOnboarding(investorId) { const stepsEl = document.getElementById('ob-steps-' + investorId); const barEl = document.getElementById('ob-bar-' + investorId); const labelEl = document.getElementById('ob-progress-label-' + investorId); if (!stepsEl) return; try { const res = await authFetch('/api/admin/investors/' + investorId + '/onboarding'); const data = await res.json(); if (!data.success) throw new Error(data.message); const ob = data.onboarding; const progress = ob.overall_progress || 0; const steps = ob.steps || []; // Update progress bar if (barEl) { const colorClass = progress < 25 ? 'red' : progress < 75 ? 'yellow' : 'green'; barEl.style.width = progress + '%'; barEl.className = 'ob-progress-bar-fill ' + colorClass; } if (labelEl) labelEl.textContent = progress + '% complete'; // Build steps list const completedCount = steps.filter(s => s.completed).length; let html = `
`; for (const step of steps) { const dateStr = step.completed_at ? new Date(step.completed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : ''; html += `
${step.completed ? '✓' : ''}
${step.label}
${dateStr ? `
${dateStr}
` : ''}
`; } html += `
`; stepsEl.innerHTML = html; } catch (err) { if (stepsEl) stepsEl.innerHTML = `
Could not load onboarding status
`; } } async function toggleOnboardingStep(investorId, stepKey, newCompleted) { try { const res = await authFetch( '/api/admin/investors/' + investorId + '/onboarding/' + stepKey, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ completed: newCompleted }) } ); const data = await res.json(); if (!data.success) throw new Error(data.message); // Reload the onboarding section await loadInvestorOnboarding(investorId); } catch (err) { alert('Failed to update step: ' + err.message); } } async function loadOnboardingSummary() { try { const res = await authFetch('/api/admin/onboarding/summary'); const data = await res.json(); if (!data.success) return; const s = data.summary; const all = parseInt(s.bracket_0_25) + parseInt(s.bracket_26_50) + parseInt(s.bracket_51_75) + parseInt(s.bracket_76_100); // Show the section const section = document.getElementById('onboardingOverviewSection'); if (section) section.style.display = 'block'; // KPI counts const foEl = document.getElementById('obFullyOnboarded'); const peEl = document.getElementById('obPending'); if (foEl) foEl.textContent = s.fully_onboarded; if (peEl) peEl.textContent = s.pending; const subEl = document.getElementById('onboardingSubtitle'); if (subEl) subEl.textContent = all + ' investor' + (all !== 1 ? 's' : '') + ' tracked'; // Bracket bars const brackets = [ { id: 'obBar0', countId: 'obCount0', val: parseInt(s.bracket_0_25) }, { id: 'obBar26', countId: 'obCount26', val: parseInt(s.bracket_26_50) }, { id: 'obBar51', countId: 'obCount51', val: parseInt(s.bracket_51_75) }, { id: 'obBar76', countId: 'obCount76', val: parseInt(s.bracket_76_100) } ]; const maxVal = Math.max(...brackets.map(b => b.val), 1); brackets.forEach(b => { const el = document.getElementById(b.id); const ce = document.getElementById(b.countId); if (el) el.style.width = Math.round((b.val / maxVal) * 100) + '%'; if (ce) ce.textContent = b.val; }); // Incomplete investors table const listEl = document.getElementById('obIncompleteList'); if (!listEl) return; if (!data.incomplete_investors.length) { listEl.innerHTML = `
🎉 All investors are fully onboarded!
`; return; } const STEP_LABELS = { profile_complete: 'Profile', password_set: 'Password', first_login: 'Login', documents_uploaded: 'Docs', first_distribution: 'Distribution' }; let html = `
Investor
Progress
Missing steps
`; for (const inv of data.incomplete_investors) { const pct = inv.overall_progress; const pillClass = pct < 25 ? 'red' : pct < 75 ? 'yellow' : 'green'; const steps = (inv.steps || []).filter(s => !s.completed); const missingBadges = steps.map(s => `${STEP_LABELS[s.key] || s.key}`).join(''); html += `
${escapeHtml(inv.investor_name || '')}
${escapeHtml(inv.investor_email || '')}
${pct}%
${missingBadges}
`; } listEl.innerHTML = html; } catch (err) { console.warn('[Onboarding] Summary load failed:', err.message); } } function openQuickNoteModal(investorId, investorName) { quickNoteInvestorId = investorId; document.getElementById('quickNoteTitle').textContent = 'Add Note'; document.getElementById('quickNoteSubtitle').textContent = investorName; document.getElementById('quickNoteType').value = 'general'; document.getElementById('quickNoteContent').value = ''; document.getElementById('quickNoteError').style.display = 'none'; document.getElementById('quickNoteSaveBtn').disabled = false; document.getElementById('quickNoteSaveBtn').textContent = 'Save Note'; document.getElementById('quickNoteBackdrop').classList.add('active'); setTimeout(() => document.getElementById('quickNoteContent').focus(), 100); } function closeQuickNoteModal(event) { if (event && event.target !== document.getElementById('quickNoteBackdrop')) return; document.getElementById('quickNoteBackdrop').classList.remove('active'); quickNoteInvestorId = null; } async function saveQuickNote() { if (!quickNoteInvestorId) return; const type = document.getElementById('quickNoteType').value; const content = document.getElementById('quickNoteContent').value.trim(); const errEl = document.getElementById('quickNoteError'); const btn = document.getElementById('quickNoteSaveBtn'); errEl.style.display = 'none'; if (!content) { errEl.textContent = 'Note content is required.'; errEl.style.display = 'block'; return; } btn.disabled = true; btn.textContent = 'Saving...'; try { const res = await authFetch('/api/admin/investors/' + quickNoteInvestorId + '/notes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ note_type: type, content }) }); const data = await res.json(); if (!data.success) throw new Error(data.message); // Close modal document.getElementById('quickNoteBackdrop').classList.remove('active'); const savedInvestorId = quickNoteInvestorId; quickNoteInvestorId = null; // Update last contact badge in table const cell = document.getElementById('last-contact-' + savedInvestorId); if (cell) cell.innerHTML = buildLastContactBadge(data.note.created_at); // Reload notes in expanded row if open const notesList = document.getElementById('notes-list-' + savedInvestorId); if (notesList) loadInvestorNotes(savedInvestorId); } catch (err) { errEl.textContent = err.message; errEl.style.display = 'block'; btn.disabled = false; btn.textContent = 'Save Note'; } } // ============================================ // INVESTOR ADD/EDIT // ============================================ function openAddModal() { document.getElementById('formTitle').textContent = 'Add Investor'; document.getElementById('formSubmitBtn').textContent = 'Add Investor'; document.getElementById('formInvestorId').value = ''; document.getElementById('formError').textContent = ''; document.getElementById('investorForm').reset(); document.getElementById('formDate').value = new Date().toISOString().split('T')[0]; document.getElementById('formBackdrop').classList.add('active'); } async function resendInvite(id, email) { const btn = document.getElementById('resend-btn-' + id); if (btn) { btn.disabled = true; btn.textContent = 'Sending...'; } try { const res = await authFetch('/api/investors/' + id + '/resend-invite', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); const data = await res.json(); if (!data.success) throw new Error(data.message || 'Failed to resend invite'); if (btn) { btn.textContent = '✓ Sent!'; setTimeout(() => { if (btn) btn.textContent = 'Resend Invite'; btn.disabled = false; }, 3000); } console.log('[Invite] Resent to', email); } catch (err) { alert(err.message || 'Failed to resend invite'); if (btn) { btn.disabled = false; btn.textContent = 'Resend Invite'; } } } function openEditModal(id) { const inv = investorsCache.find(i => i.id === id); if (!inv) return; document.getElementById('formTitle').textContent = 'Edit Investor'; document.getElementById('formSubmitBtn').textContent = 'Save Changes'; document.getElementById('formInvestorId').value = id; document.getElementById('formError').textContent = ''; document.getElementById('formName').value = inv.name || ''; document.getElementById('formEmail').value = inv.email || ''; document.getElementById('formPhone').value = inv.phone || ''; document.getElementById('formAmount').value = inv.invested_amount || ''; document.getElementById('formDate').value = inv.invested_at ? new Date(inv.invested_at).toISOString().split('T')[0] : ''; document.getElementById('formModelType').value = inv.model_type || 'wholesale'; document.getElementById('formStatus').value = inv.status || 'active'; document.getElementById('formNotes').value = inv.notes || ''; document.getElementById('formBackdrop').classList.add('active'); } function closeFormModal(e) { if (e && e.target !== document.getElementById('formBackdrop')) return; document.getElementById('formBackdrop').classList.remove('active'); } async function handleFormSubmit(e) { e.preventDefault(); const submitBtn = document.getElementById('formSubmitBtn'); const errorEl = document.getElementById('formError'); errorEl.textContent = ''; const investorId = document.getElementById('formInvestorId').value; const isEdit = !!investorId; const name = document.getElementById('formName').value.trim(); const email = document.getElementById('formEmail').value.trim(); const phone = document.getElementById('formPhone').value.trim(); const amount = parseFloat(document.getElementById('formAmount').value); const date = document.getElementById('formDate').value; const modelType = document.getElementById('formModelType').value; const status = document.getElementById('formStatus').value; const notes = document.getElementById('formNotes').value.trim(); if (!name) { errorEl.textContent = 'Name is required'; return; } if (isNaN(amount) || amount < 0) { errorEl.textContent = 'Enter a valid invested amount'; return; } if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { errorEl.textContent = 'Enter a valid email address'; return; } const body = { name, email: email || null, phone: phone || null, invested_amount: amount, invested_at: date || null, model_type: modelType, status, notes: notes || null }; submitBtn.disabled = true; submitBtn.textContent = isEdit ? 'Saving...' : 'Adding...'; try { const url = isEdit ? '/api/investors/' + investorId : '/api/investors'; const method = isEdit ? 'PUT' : 'POST'; const res = await authFetch(url, { method, body: JSON.stringify(body) }); const data = await res.json(); if (!data.success) { errorEl.textContent = data.message || 'Something went wrong'; return; } closeFormModal(); showToast(isEdit ? name + ' updated' : name + ' added'); loadSummary(); loadInvestors(); } catch (err) { errorEl.textContent = err.message; } finally { submitBtn.disabled = false; submitBtn.textContent = isEdit ? 'Save Changes' : 'Add Investor'; } } // ============================================ // INVESTOR DELETE // ============================================ let deleteTarget = null; function openDeleteModal(id, name) { deleteTarget = id; document.getElementById('deleteInvestorName').textContent = name; document.getElementById('deleteBackdrop').classList.add('active'); } function closeDeleteModal(e) { if (e && e.target !== document.getElementById('deleteBackdrop')) return; document.getElementById('deleteBackdrop').classList.remove('active'); deleteTarget = null; } async function confirmDelete() { if (!deleteTarget) return; const btn = document.getElementById('confirmDeleteBtn'); btn.disabled = true; btn.textContent = 'Deleting...'; try { const res = await authFetch('/api/investors/' + deleteTarget, { method: 'DELETE' }); const data = await res.json(); if (!data.success) { showToast(data.message || 'Failed to delete', 'error'); return; } expandedRows.delete(deleteTarget); closeDeleteModal(); showToast(data.message || 'Investor deleted'); loadSummary(); loadInvestors(); } catch (err) { showToast(err.message, 'error'); } finally { btn.disabled = false; btn.textContent = 'Delete'; } } // ============================================ // STORE ADD/EDIT // ============================================ let currentStoreInvestorId = null; function openAddStoreModal(investorId) { currentStoreInvestorId = investorId; const inv = investorsCache.find(i => i.id === investorId); document.getElementById('storeFormTitle').textContent = 'Add Store'; document.getElementById('storeFormSubtitle').textContent = inv ? 'for ' + inv.name : ''; document.getElementById('storeFormSubmitBtn').textContent = 'Add Store'; document.getElementById('storeFormId').value = ''; document.getElementById('storeFormInvestorId').value = investorId; document.getElementById('storeFormError').textContent = ''; document.getElementById('storeForm').reset(); document.getElementById('storeFormBackdrop').classList.add('active'); } function openEditStoreModal(storeId, investorId) { currentStoreInvestorId = investorId; // Find store in the current expanded data - re-fetch from API authFetch('/api/investors/' + investorId + '/stores').then(r => r.json()).then(data => { if (!data.success) return; const store = data.stores.find(s => s.id === storeId); if (!store) return; document.getElementById('storeFormTitle').textContent = 'Edit Store'; const inv = investorsCache.find(i => i.id === investorId); document.getElementById('storeFormSubtitle').textContent = inv ? 'for ' + inv.name : ''; document.getElementById('storeFormSubmitBtn').textContent = 'Save Changes'; document.getElementById('storeFormId').value = storeId; document.getElementById('storeFormInvestorId').value = investorId; document.getElementById('storeFormError').textContent = ''; document.getElementById('storeFormName').value = store.name || ''; document.getElementById('storeFormPlatform').value = store.platform || ''; document.getElementById('storeFormStatus').value = store.status || 'active'; document.getElementById('storeFormUrl').value = store.url || ''; document.getElementById('storeFormBackdrop').classList.add('active'); }); } function closeStoreModal(e) { if (e && e.target !== document.getElementById('storeFormBackdrop')) return; document.getElementById('storeFormBackdrop').classList.remove('active'); } async function handleStoreSubmit(e) { e.preventDefault(); const submitBtn = document.getElementById('storeFormSubmitBtn'); const errorEl = document.getElementById('storeFormError'); errorEl.textContent = ''; const storeId = document.getElementById('storeFormId').value; const investorId = document.getElementById('storeFormInvestorId').value; const isEdit = !!storeId; const name = document.getElementById('storeFormName').value.trim(); const platform = document.getElementById('storeFormPlatform').value; const status = document.getElementById('storeFormStatus').value; const url = document.getElementById('storeFormUrl').value.trim(); if (!name) { errorEl.textContent = 'Store name is required'; return; } if (!platform) { errorEl.textContent = 'Platform is required'; return; } const body = { investor_id: parseInt(investorId), name, platform, status, url: url || null }; submitBtn.disabled = true; submitBtn.textContent = isEdit ? 'Saving...' : 'Adding...'; try { const url2 = isEdit ? '/api/stores/' + storeId : '/api/stores'; const method = isEdit ? 'PUT' : 'POST'; const res = await authFetch(url2, { method, body: JSON.stringify(body) }); const data = await res.json(); if (!data.success) { errorEl.textContent = data.message || 'Something went wrong'; return; } closeStoreModal(); showToast(isEdit ? name + ' updated' : name + ' added'); loadSummary(); loadInvestors(); } catch (err) { errorEl.textContent = err.message; } finally { submitBtn.disabled = false; submitBtn.textContent = isEdit ? 'Save Changes' : 'Add Store'; } } // ============================================ // STORE DELETE // ============================================ let deleteStoreTarget = null; let deleteStoreInvestorId = null; function openStoreDeleteModal(storeId, storeName, investorId) { deleteStoreTarget = storeId; deleteStoreInvestorId = investorId; document.getElementById('deleteStoreName').textContent = storeName; document.getElementById('storeDeleteBackdrop').classList.add('active'); } function closeStoreDeleteModal(e) { if (e && e.target !== document.getElementById('storeDeleteBackdrop')) return; document.getElementById('storeDeleteBackdrop').classList.remove('active'); deleteStoreTarget = null; } async function confirmStoreDelete() { if (!deleteStoreTarget) return; const btn = document.getElementById('confirmStoreDeleteBtn'); btn.disabled = true; btn.textContent = 'Deleting...'; try { const res = await authFetch('/api/stores/' + deleteStoreTarget, { method: 'DELETE' }); const data = await res.json(); if (!data.success) { showToast(data.message || 'Failed to delete', 'error'); return; } closeStoreDeleteModal(); showToast(data.message || 'Store deleted'); loadSummary(); if (deleteStoreInvestorId) loadExpandedStores(deleteStoreInvestorId); loadInvestors(); } catch (err) { showToast(err.message, 'error'); } finally { btn.disabled = false; btn.textContent = 'Delete Store'; } } // ============================================ // REVENUE ENTRIES MODAL // ============================================ let currentRevenueStoreId = null; let currentRevenueStoreName = null; async function openRevenueModal(storeId, storeName, platform) { currentRevenueStoreId = storeId; currentRevenueStoreName = storeName; document.getElementById('revenueModalTitle').textContent = storeName; document.getElementById('revenueModalSubtitle').innerHTML = `${capitalize(platform)}   Revenue Entries`; document.getElementById('revenueModalBackdrop').classList.add('active'); await loadRevenueEntries(storeId); } function closeRevenueModal(e) { if (e && e.target !== document.getElementById('revenueModalBackdrop')) return; document.getElementById('revenueModalBackdrop').classList.remove('active'); currentRevenueStoreId = null; } async function loadRevenueEntries(storeId) { const body = document.getElementById('revenueModalBody'); body.innerHTML = '
Loading entries...
'; try { const res = await authFetch('/api/stores/' + storeId + '/revenue-entries'); const data = await res.json(); if (!data.success) throw new Error(data.message); const entries = data.entries; const totalRevenue = entries.reduce((s, e) => s + parseFloat(e.revenue || 0), 0); const totalFees = entries.reduce((s, e) => s + parseFloat(e.fees || 0), 0); const totalNetProfit = entries.reduce((s, e) => s + parseFloat(e.net_profit || 0), 0); const totalUnits = entries.reduce((s, e) => s + parseInt(e.units_sold || 0), 0); let totalsHtml = ''; if (entries.length > 0) { totalsHtml = `
Total Revenue
${fmtFull(totalRevenue)}
Total Fees
${fmtFull(totalFees)}
Net Profit
${fmtFull(totalNetProfit)}
Units Sold
${totalUnits.toLocaleString()}
`; } let entriesHtml = ''; if (entries.length === 0) { entriesHtml = '
No revenue entries yet — click "Add Entry" to record revenue.
'; } else { // Header entriesHtml = `
DateRevenueUnitsFeesNet Profit
`; for (const entry of entries) { const netP = parseFloat(entry.net_profit || 0); const netClass = netP >= 0 ? 'positive' : 'negative'; entriesHtml += `
${fmtDate(entry.entry_date.split('T')[0])} ${fmtFull(entry.revenue)} ${parseInt(entry.units_sold || 0).toLocaleString()} ${fmtFull(entry.fees)} ${fmtFull(netP)}
`; if (entry.notes) { entriesHtml += `
📝 ${entry.notes}
`; } } } body.innerHTML = ` ${totalsHtml}
${entriesHtml}
`; } catch (err) { body.innerHTML = `

Failed to load entries: ${err.message}

`; } } // ============================================ // REVENUE ENTRY ADD/EDIT // ============================================ function calcNetProfit() { const rev = parseFloat(document.getElementById('entryFormRevenue').value) || 0; const fees = parseFloat(document.getElementById('entryFormFees').value) || 0; document.getElementById('entryFormNetProfit').value = (rev - fees).toFixed(2); } function openAddEntryModal(storeId) { document.getElementById('entryFormTitle').textContent = 'Add Revenue Entry'; document.getElementById('entryFormSubmitBtn').textContent = 'Add Entry'; document.getElementById('entryFormId').value = ''; document.getElementById('entryFormStoreId').value = storeId; document.getElementById('entryFormError').textContent = ''; document.getElementById('entryForm').reset(); document.getElementById('entryFormDate').value = new Date().toISOString().split('T')[0]; document.getElementById('entryFormFees').value = '0'; document.getElementById('entryFormNetProfit').value = '0.00'; document.getElementById('entryFormBackdrop').classList.add('active'); } function openEditEntryModal(entry) { if (typeof entry === 'string') { try { entry = JSON.parse(entry); } catch(e) { return; } } document.getElementById('entryFormTitle').textContent = 'Edit Revenue Entry'; document.getElementById('entryFormSubmitBtn').textContent = 'Save Changes'; document.getElementById('entryFormId').value = entry.id; document.getElementById('entryFormStoreId').value = entry.store_id; document.getElementById('entryFormError').textContent = ''; document.getElementById('entryFormDate').value = entry.entry_date ? entry.entry_date.split('T')[0] : ''; document.getElementById('entryFormRevenue').value = parseFloat(entry.revenue || 0).toFixed(2); document.getElementById('entryFormUnits').value = entry.units_sold || 0; document.getElementById('entryFormFees').value = parseFloat(entry.fees || 0).toFixed(2); document.getElementById('entryFormNetProfit').value = parseFloat(entry.net_profit || 0).toFixed(2); document.getElementById('entryFormNotes').value = entry.notes || ''; document.getElementById('entryFormBackdrop').classList.add('active'); } function closeEntryModal(e) { if (e && e.target !== document.getElementById('entryFormBackdrop')) return; document.getElementById('entryFormBackdrop').classList.remove('active'); } async function handleEntrySubmit(e) { e.preventDefault(); const submitBtn = document.getElementById('entryFormSubmitBtn'); const errorEl = document.getElementById('entryFormError'); errorEl.textContent = ''; const entryId = document.getElementById('entryFormId').value; const storeId = document.getElementById('entryFormStoreId').value; const isEdit = !!entryId; const entry_date = document.getElementById('entryFormDate').value; const revenue = document.getElementById('entryFormRevenue').value; const units_sold = document.getElementById('entryFormUnits').value; const fees = document.getElementById('entryFormFees').value; const notes = document.getElementById('entryFormNotes').value.trim(); if (!entry_date) { errorEl.textContent = 'Date is required'; return; } if (!revenue) { errorEl.textContent = 'Revenue is required'; return; } const body = { store_id: parseInt(storeId), entry_date, revenue: parseFloat(revenue), units_sold: parseInt(units_sold) || 0, fees: parseFloat(fees) || 0, notes: notes || null }; submitBtn.disabled = true; submitBtn.textContent = isEdit ? 'Saving...' : 'Adding...'; try { const url = isEdit ? '/api/revenue-entries/' + entryId : '/api/revenue-entries'; const method = isEdit ? 'PUT' : 'POST'; const res = await authFetch(url, { method, body: JSON.stringify(body) }); const data = await res.json(); if (!data.success) { errorEl.textContent = data.message || 'Something went wrong'; return; } closeEntryModal(); showToast(isEdit ? 'Entry updated' : 'Entry added'); // Refresh both the revenue modal and the expanded stores if (currentRevenueStoreId) await loadRevenueEntries(currentRevenueStoreId); // Refresh the expanded investor row for (const invId of expandedRows) { const inv = investorsCache.find(i => i.id === invId); if (inv && inv.stores && inv.stores.some(s => s.id === parseInt(storeId))) { loadExpandedStores(invId); break; } } // Try all expanded rows since we may not have store cache if (expandedRows.size > 0) { for (const invId of expandedRows) { loadExpandedStores(invId); } } loadSummary(); } catch (err) { errorEl.textContent = err.message; } finally { submitBtn.disabled = false; submitBtn.textContent = isEdit ? 'Save Changes' : 'Add Entry'; } } // ============================================ // REVENUE ENTRY DELETE // ============================================ let deleteEntryTarget = null; function openEntryDeleteModal(entryId) { deleteEntryTarget = entryId; document.getElementById('entryDeleteBackdrop').classList.add('active'); } function closeEntryDeleteModal(e) { if (e && e.target !== document.getElementById('entryDeleteBackdrop')) return; document.getElementById('entryDeleteBackdrop').classList.remove('active'); deleteEntryTarget = null; } async function confirmEntryDelete() { if (!deleteEntryTarget) return; const btn = document.getElementById('confirmEntryDeleteBtn'); btn.disabled = true; btn.textContent = 'Deleting...'; try { const res = await authFetch('/api/revenue-entries/' + deleteEntryTarget, { method: 'DELETE' }); const data = await res.json(); if (!data.success) { showToast(data.message || 'Failed to delete', 'error'); return; } closeEntryDeleteModal(); showToast('Entry deleted'); if (currentRevenueStoreId) await loadRevenueEntries(currentRevenueStoreId); for (const invId of expandedRows) loadExpandedStores(invId); loadSummary(); } catch (err) { showToast(err.message, 'error'); } finally { btn.disabled = false; btn.textContent = 'Delete'; } } // ============================================ // EXPORT & REPORT // ============================================ async function exportCSV() { if (!getToken()) { showToast('Not authenticated', 'error'); return; } try { const res = await authFetch('/api/investors/export'); if (!res.ok) { const data = await res.json().catch(() => ({})); showToast(data.message || 'Export failed', 'error'); return; } const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `gecos-investors-${new Date().toISOString().split('T')[0]}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); showToast('CSV downloaded'); } catch (err) { showToast('Export failed', 'error'); } } async function generateReport() { if (!getToken()) { showToast('Not authenticated', 'error'); return; } try { showToast('Generating report...'); const res = await authFetch('/api/investors/report'); if (!res.ok) { const data = await res.json().catch(() => ({})); showToast(data.message || 'Report generation failed', 'error'); return; } const html = await res.text(); const blob = new Blob([html], { type: 'text/html' }); const url = URL.createObjectURL(blob); window.open(url, '_blank'); setTimeout(() => URL.revokeObjectURL(url), 60000); } catch (err) { showToast('Report generation failed', 'error'); } } // ============================================ // KEYBOARD SHORTCUTS // ============================================ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeFormModal(); closeDeleteModal(); closeStoreModal(); closeStoreDeleteModal(); closeRevenueModal(); closeEntryModal(); closeEntryDeleteModal(); } }); // ============================================ // CHARTS — Analytics & Trends // ============================================ let chartInstances = {}; let currentRange = '30d'; const CHART_COLORS = { accent: '#1a5c3a', accentLight: 'rgba(26,92,58,0.15)', accentFill: 'rgba(26,92,58,0.08)', grid: '#e8e3da', text: '#6b6560', positive: '#16a34a', warning: '#ca8a04', negative: '#dc2626', platforms: { amazon: '#e65100', walmart: '#0d47a1', ebay: '#c62828', shopify: '#2e7d32', tiktok: '#6a1b9a', facebook: '#1565c0', } }; const PLATFORM_LABELS = { amazon: 'Amazon', walmart: 'Walmart', ebay: 'eBay', shopify: 'Shopify', tiktok: 'TikTok', facebook: 'Facebook' }; function setRange(range) { currentRange = range; document.querySelectorAll('.range-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.range === range); }); loadCharts(); } function fmtCurrency(n) { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(n || 0); } function fmtDate(dateStr) { if (!dateStr) return ''; const d = new Date(dateStr + 'T12:00:00'); return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } function destroyChart(key) { if (chartInstances[key]) { chartInstances[key].destroy(); delete chartInstances[key]; } } function showChartState(prefix, state) { // state: 'loading' | 'empty' | 'chart' const loading = document.getElementById(prefix + 'Loading'); const canvas = document.getElementById(prefix + 'Chart'); const empty = document.getElementById(prefix + 'Empty'); if (loading) loading.style.display = state === 'loading' ? 'flex' : 'none'; if (canvas) canvas.style.display = state === 'chart' ? 'block' : 'none'; if (empty) empty.style.display = state === 'empty' ? 'flex' : 'none'; } // ============================================ // STORE ANALYTICS // ============================================ // Cache of all store analytics data (set by loadStoreAnalytics) window.storeAnalyticsCache = null; // Platform icons const PLATFORM_ICONS = { amazon: '📦', walmart: '🛒', ebay: '🏷️', shopify: '🛍️', tiktok: '🎵', facebook: '👥', }; function buildSparkline(timeline) { if (!timeline || timeline.length === 0) return 'No data'; const values = timeline.map(t => parseFloat(t.revenue) || 0); const maxVal = Math.max(...values, 1); return `
${values.map((v, i) => { const h = Math.max(2, Math.round((v / maxVal) * 22)); const cls = i === values.length - 1 ? (v > (values[i - 1] || 0) ? 'positive' : v < (values[i - 1] || 0) ? 'negative' : 'neutral') : 'neutral'; return `
`; }).join('')}
`; } function buildStoreTable(stores, limit) { if (!stores || stores.length === 0) { return '
No stores to display.
'; } const rows = (limit ? stores.slice(0, limit) : stores).map(s => { const momPct = s.mom_change_pct; const momStr = momPct !== null ? (momPct > 0 ? `+${momPct.toFixed(1)}%` : `${momPct.toFixed(1)}%`) : '—'; const momCls = momPct === null ? '' : momPct > 0 ? 'sa-mom-positive' : momPct < 0 ? 'sa-mom-negative' : 'sa-mom-flat'; const perfLabel = s.performance_label || 'new'; return ` ${escapeHtml(s.store_name)} ${capitalize(s.platform)} ${escapeHtml(s.investor_name)} ${fmtFull(s.current_month_revenue)} ${fmtFull(s.total_revenue)} ${momStr} ${buildSparkline(null)} ${capitalize(perfLabel)} `; }).join(''); return `${rows}
Store Platform Investor 30d Rev Total Rev MoM Trend Perf
`; } async function loadStoreAnalytics() { try { const platform = document.getElementById('saFilterPlatform').value; const performance = document.getElementById('saFilterPerf').value; const sort = document.getElementById('saSort').value; const order = document.getElementById('saSortOrder').value; const params = new URLSearchParams(); if (platform) params.set('platform', platform); if (performance) params.set('performance', performance); if (sort) params.set('sort', sort); if (order) params.set('order', order); // Fetch store analytics and platform summary in parallel const [saRes, psRes] = await Promise.all([ authFetch('/api/admin/store-analytics?' + params.toString()), authFetch('/api/admin/platform-summary'), ]); const saData = await saRes.json(); const psData = await psRes.json(); if (!saData.success) throw new Error(saData.message || 'Failed to load store analytics'); // Cache all stores (unfiltered, for use in expanded rows) if (!window.storeAnalyticsCache) { window.storeAnalyticsCache = saData.stores; } const section = document.getElementById('storeAnalyticsSection'); if (!section) return; section.style.display = 'block'; const total = saData.total_stores || 0; document.getElementById('storeAnalyticsSubtitle').textContent = `${total} store${total !== 1 ? 's' : ''} across all investors`; // Platform breakdown cards if (psData.success) { const cards = document.getElementById('saPlatformCards'); if (cards) { if (psData.platforms.length === 0) { cards.innerHTML = '
No platform data yet.
'; } else { cards.innerHTML = psData.platforms.map(p => { const icon = PLATFORM_ICONS[p.platform] || '🏪'; const momStr = p.mom_change_pct !== null ? (p.mom_change_pct > 0 ? `+${p.mom_change_pct.toFixed(1)}%` : `${p.mom_change_pct.toFixed(1)}%`) : null; const momCls = p.mom_change_pct === null ? '' : p.mom_change_pct > 0 ? 'sa-mom-positive' : 'sa-mom-negative'; return `
${icon}
${capitalize(p.platform)}
${fmt(p.total_revenue)}
${p.total_stores} store${p.total_stores !== 1 ? 's' : ''} ${momStr ? `${momStr} MoM` : ''}
`; }).join(''); } } } // Underperformer alert const underEl = document.getElementById('saUnderperformerAlert'); const underList = document.getElementById('saUnderperformerList'); if (saData.underperformers && saData.underperformers.length > 0 && underEl && underList) { document.getElementById('saUnderperformerTitle').textContent = `${saData.underperformers.length} Store${saData.underperformers.length !== 1 ? 's' : ''} Down >15% MoM`; underList.innerHTML = saData.underperformers.map(s => `
${escapeHtml(s.store_name)} · ${s.platform} ${s.mom_change_pct.toFixed(1)}% ${escapeHtml(s.investor_name)}
`).join(''); underEl.style.display = 'block'; } else if (underEl) { underEl.style.display = 'none'; } // Top 10 const topEl = document.getElementById('saTopTable'); if (topEl) topEl.innerHTML = buildStoreTable(saData.top10, 10); // Bottom 10 const botEl = document.getElementById('saBottomTable'); if (botEl) botEl.innerHTML = buildStoreTable(saData.bottom10, 10); // All stores table const tbodyEl = document.getElementById('saAllStoresTbody'); const countEl = document.getElementById('saTableCount'); if (tbodyEl) { if (saData.stores.length === 0) { tbodyEl.innerHTML = 'No stores match the current filters.'; } else { const momStr2 = s => { const m = s.mom_change_pct; return m !== null ? (m > 0 ? `+${m.toFixed(1)}%` : `${m.toFixed(1)}%`) : '—'; }; tbodyEl.innerHTML = saData.stores.map(s => { const momPct = s.mom_change_pct; const momCls = momPct === null ? '' : momPct > 0 ? 'sa-mom-positive' : momPct < 0 ? 'sa-mom-negative' : 'sa-mom-flat'; const perfLabel = s.performance_label || 'new'; const inComp = window.compareSelection && window.compareSelection.has(s.store_id); return ` ${escapeHtml(s.store_name)} ${capitalize(s.platform)} ${escapeHtml(s.investor_name)} ${fmtFull(s.current_month_revenue)} ${fmtFull(s.total_revenue)} ${momStr2(s)} ${buildSparkline(null)} ${capitalize(perfLabel)} `; }).join(''); } } if (countEl) countEl.textContent = `${saData.stores.length} store${saData.stores.length !== 1 ? 's' : ''}`; } catch (err) { console.error('[Store Analytics] Error:', err); const section = document.getElementById('storeAnalyticsSection'); if (section) { section.style.display = 'block'; section.querySelector('.charts-toolbar').insertAdjacentHTML('afterend', `
Failed to load store analytics: ${escapeHtml(err.message)}
`); } } } function applyStoreFilters() { // Update cache with full (unfiltered) data on re-filter window.storeAnalyticsCache = null; loadStoreAnalytics(); } async function loadCharts() { const platform = document.getElementById('platformFilter').value; const token = localStorage.getItem('gecos_token'); if (!token) return; // Show all loading ['revenue', 'platform', 'roi', 'aum'].forEach(k => showChartState(k, 'loading')); try { const res = await fetch(`/api/admin/analytics?range=${currentRange}&platform=${platform}`, { headers: { 'Authorization': 'Bearer ' + token } }); if (!res.ok) throw new Error('Analytics fetch failed'); const data = await res.json(); if (!data.success) throw new Error(data.message); renderRevenueChart(data.revenue_over_time); renderPlatformChart(data.revenue_by_platform); renderROIChart(data.roi_per_investor); renderAUMChart(data.aum_growth); } catch (err) { console.error('Charts load error:', err); ['revenue', 'platform', 'roi', 'aum'].forEach(k => showChartState(k, 'empty')); } } function chartDefaults() { return { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { backgroundColor: '#0a0a0a', titleColor: '#fff', bodyColor: '#ccc', cornerRadius: 8, padding: 10 } } }; } // 1. Revenue Over Time — Line chart function renderRevenueChart(rows) { destroyChart('revenue'); if (!rows || rows.length === 0) { showChartState('revenue', 'empty'); return; } showChartState('revenue', 'chart'); const totalProfit = rows.reduce((s, r) => s + (r.net_profit || 0), 0); document.getElementById('revenueStatValue').textContent = fmtCurrency(totalProfit); // Resize canvas wrap to fixed height const wrap = document.querySelector('#revenueChart').parentElement; wrap.style.height = '220px'; const ctx = document.getElementById('revenueChart').getContext('2d'); chartInstances.revenue = new Chart(ctx, { type: 'line', data: { labels: rows.map(r => fmtDate(r.date)), datasets: [{ label: 'Net Profit', data: rows.map(r => r.net_profit || 0), borderColor: CHART_COLORS.accent, backgroundColor: CHART_COLORS.accentFill, fill: true, tension: 0.35, pointRadius: rows.length > 30 ? 0 : 3, pointHoverRadius: 5, borderWidth: 2, }, { label: 'Revenue', data: rows.map(r => r.revenue || 0), borderColor: CHART_COLORS.accentLight, borderDash: [4, 3], backgroundColor: 'transparent', fill: false, tension: 0.35, pointRadius: 0, borderWidth: 1.5, }] }, options: { ...chartDefaults(), plugins: { ...chartDefaults().plugins, legend: { display: true, position: 'bottom', labels: { boxWidth: 12, font: { family: 'DM Sans', size: 11 }, color: CHART_COLORS.text } } }, scales: { x: { grid: { color: CHART_COLORS.grid }, ticks: { color: CHART_COLORS.text, font: { family: 'DM Sans', size: 11 }, maxTicksLimit: 8 } }, y: { grid: { color: CHART_COLORS.grid }, ticks: { color: CHART_COLORS.text, font: { family: 'DM Sans', size: 11 }, callback: v => fmtCurrency(v) } } } } }); } // 3. Revenue by Platform — Donut chart function renderPlatformChart(rows) { destroyChart('platform'); if (!rows || rows.length === 0) { showChartState('platform', 'empty'); return; } showChartState('platform', 'chart'); const topPlatform = rows[0]; document.getElementById('platformStatValue').textContent = topPlatform ? (PLATFORM_LABELS[topPlatform.platform] || topPlatform.platform) : '—'; const ctx = document.getElementById('platformChart').getContext('2d'); const colors = rows.map(r => CHART_COLORS.platforms[r.platform] || '#888'); chartInstances.platform = new Chart(ctx, { type: 'doughnut', data: { labels: rows.map(r => PLATFORM_LABELS[r.platform] || r.platform), datasets: [{ data: rows.map(r => Math.max(r.net_profit || 0, 0)), backgroundColor: colors, borderColor: '#fffdf8', borderWidth: 3, hoverOffset: 6 }] }, options: { ...chartDefaults(), cutout: '60%', plugins: { legend: { display: true, position: 'bottom', labels: { boxWidth: 12, font: { family: 'DM Sans', size: 11 }, color: CHART_COLORS.text } }, tooltip: { ...chartDefaults().plugins.tooltip, callbacks: { label: ctx => ` ${ctx.label}: ${fmtCurrency(ctx.parsed)}` } } } } }); } // 2. ROI Per Investor — Horizontal bar chart function renderROIChart(rows) { destroyChart('roi'); if (!rows || rows.length === 0) { showChartState('roi', 'empty'); return; } showChartState('roi', 'chart'); const validRois = rows.filter(r => r.roi_pct !== null); const avgRoi = validRois.length ? (validRois.reduce((s, r) => s + r.roi_pct, 0) / validRois.length).toFixed(1) : 0; document.getElementById('roiStatValue').textContent = avgRoi + '%'; const barColors = rows.map(r => { const v = r.roi_pct; if (v >= 35) return CHART_COLORS.positive; if (v >= 20) return CHART_COLORS.warning; return CHART_COLORS.negative; }); // Size the wrap to fit all bars (28px per investor + padding) const wrap = document.getElementById('roiChartWrap'); wrap.style.height = Math.max(180, rows.length * 36 + 40) + 'px'; const ctx = document.getElementById('roiChart').getContext('2d'); chartInstances.roi = new Chart(ctx, { type: 'bar', data: { labels: rows.map(r => r.name), datasets: [{ label: 'ROI %', data: rows.map(r => r.roi_pct || 0), backgroundColor: barColors, borderRadius: 6, barThickness: 20 }] }, options: { ...chartDefaults(), indexAxis: 'y', plugins: { ...chartDefaults().plugins, tooltip: { ...chartDefaults().plugins.tooltip, callbacks: { label: ctx => ` ROI: ${ctx.parsed.x.toFixed(1)}%` } } }, scales: { x: { grid: { color: CHART_COLORS.grid }, ticks: { color: CHART_COLORS.text, font: { family: 'DM Sans', size: 11 }, callback: v => v + '%' } }, y: { grid: { display: false }, ticks: { color: CHART_COLORS.text, font: { family: 'DM Sans', size: 12 } } } } } }); } // 4. AUM Growth — Area chart function renderAUMChart(rows) { destroyChart('aum'); if (!rows || rows.length === 0) { showChartState('aum', 'empty'); return; } showChartState('aum', 'chart'); const currentAUM = rows[rows.length - 1]?.cumulative_aum || 0; document.getElementById('aumStatValue').textContent = fmtCurrency(currentAUM); const wrap = document.querySelector('#aumChart').parentElement; wrap.style.height = '220px'; const ctx = document.getElementById('aumChart').getContext('2d'); chartInstances.aum = new Chart(ctx, { type: 'line', data: { labels: rows.map(r => fmtDate(r.date) + (r.name ? ' (' + r.name.split(' ')[0] + ')' : '')), datasets: [{ label: 'Cumulative AUM', data: rows.map(r => r.cumulative_aum || 0), borderColor: CHART_COLORS.accent, backgroundColor: CHART_COLORS.accentFill, fill: true, tension: 0.2, pointRadius: 4, pointHoverRadius: 6, borderWidth: 2, stepped: 'after' }] }, options: { ...chartDefaults(), plugins: { ...chartDefaults().plugins, tooltip: { ...chartDefaults().plugins.tooltip, callbacks: { label: ctx => ` AUM: ${fmtCurrency(ctx.parsed.y)}` } } }, scales: { x: { grid: { color: CHART_COLORS.grid }, ticks: { color: CHART_COLORS.text, font: { family: 'DM Sans', size: 11 }, maxRotation: 30 } }, y: { grid: { color: CHART_COLORS.grid }, ticks: { color: CHART_COLORS.text, font: { family: 'DM Sans', size: 11 }, callback: v => fmtCurrency(v) } } } } }); } // ============================================ // NOTIFICATION TOGGLE // ============================================ async function toggleNotifications(investorId, currentEnabled) { const btn = document.getElementById('notif-btn-' + investorId); if (btn) btn.disabled = true; try { const res = await authFetch('/api/investors/' + investorId + '/notifications', { method: 'PATCH', body: JSON.stringify({ email_notifications_enabled: !currentEnabled }) }); const data = await res.json(); if (!data.success) throw new Error(data.message || 'Failed to update'); showToast((!currentEnabled) ? 'Notifications enabled' : 'Notifications disabled'); loadInvestors(); } catch (err) { showToast(err.message || 'Failed to update notifications', 'error'); if (btn) btn.disabled = false; } } // ============================================ // NOTIFICATION LOG // ============================================ async function loadNotificationLog() { const container = document.getElementById('notificationLogContent'); container.innerHTML = '
Loading...
'; try { const res = await authFetch('/api/admin/notifications/log?limit=50'); const data = await res.json(); if (!data.success) throw new Error(data.message || 'Failed to load'); const badge = document.getElementById('notifLogBadge'); if (data.total > 0) { badge.textContent = data.total; badge.style.display = ''; } else { badge.style.display = 'none'; } if (!data.logs || data.logs.length === 0) { container.innerHTML = '
🔔
No notifications sent yet. Notifications will trigger when revenue data is entered for investors with email addresses.
'; return; } const fmt = (d) => new Date(d).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); const statusColor = { sent: '#1a5c3a', failed: '#dc2626', skipped: '#6b6560' }; const statusBg = { sent: '#dcfce7', failed: '#fee2e2', skipped: '#f3f4f6' }; let rows = data.logs.map(log => { const meta = log.metadata || {}; const revenueStr = meta.total_revenue != null ? '$' + Math.round(meta.total_revenue).toLocaleString() : '—'; const roiStr = meta.roi_pct != null ? meta.roi_pct + '%' : '—'; return ` ${log.investor_name} ${log.investor_email || '—'} ${fmt(log.sent_at)} ${revenueStr} ${roiStr} ${log.status} `; }).join(''); container.innerHTML = `${rows}
Investor Email Sent At Revenue ROI Status
`; } catch (err) { container.innerHTML = `
Failed to load notification log: ${err.message}
`; } } // ============================================ // ACTIVITY LOG // ============================================ async function loadAuditLog() { const container = document.getElementById('auditLogContent'); const badge = document.getElementById('auditLogBadge'); container.innerHTML = '
Loading...
'; const action = document.getElementById('auditActionFilter')?.value || ''; const range = document.getElementById('auditRangeFilter')?.value || '7d'; const params = new URLSearchParams({ limit: 100 }); if (action) params.set('action', action); if (range) params.set('range', range); try { const res = await authFetch('/api/admin/audit-log?' + params.toString()); const data = await res.json(); if (!data.success) throw new Error(data.message || 'Failed to load'); if (badge) { if (data.total > 0) { badge.textContent = data.total; badge.style.display = ''; } else { badge.style.display = 'none'; } } if (!data.logs || data.logs.length === 0) { container.innerHTML = '
📋
No activity recorded yet. Admin actions will appear here.
'; return; } const fmt = (d) => new Date(d).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); const badgeStyle = (action) => { const create = ['investor_create','store_create','revenue_create','distribution_create','document_upload']; const update = ['investor_update','store_update','revenue_update','investor_toggle_notifications']; const del = ['investor_delete','store_delete','revenue_delete','distribution_delete','document_delete']; const login = ['admin_login','investor_login']; const send = ['weekly_digest_send','monthly_reports_generate']; const export_ = ['investors_export','investors_report_export']; const import_ = ['investor_bulk_import']; const misc = ['investor_resend_invite']; if (create.includes(action)) return { bg: '#dcfce7', color: '#15803d' }; if (update.includes(action)) return { bg: '#fef9c3', color: '#a16207' }; if (del.includes(action)) return { bg: '#fee2e2', color: '#dc2626' }; if (login.includes(action)) return { bg: '#ede9fe', color: '#7c3aed' }; if (send.includes(action)) return { bg: '#dbeafe', color: '#1d4ed8' }; if (export_.includes(action)) return { bg: '#e0f2fe', color: '#0369a1' }; if (import_.includes(action)) return { bg: '#fce7f3', color: '#9d174d' }; if (misc.includes(action)) return { bg: '#f3f4f6', color: '#374151' }; return { bg: '#f3f4f6', color: '#6b6560' }; }; const actionLabel = (a) => a.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); const entityName = (log) => { const d = log.details || {}; if (d.investor_email) return d.investor_email; if (d.email) return d.email; if (d.name) return d.name; if (d.deleted_name) return d.deleted_name; if (d.investor_id) return 'Investor #' + d.investor_id; if (log.entity_id) return log.entity_type + ' #' + log.entity_id; return log.entity_type; }; const detailsCell = (log) => { const d = log.details || {}; let parts = []; if (d.amount) parts.push('$' + Math.round(d.amount).toLocaleString()); if (d.invested_amount) parts.push('$' + Math.round(d.invested_amount).toLocaleString()); if (d.revenue) parts.push('+$' + Math.round(d.revenue).toLocaleString()); if (d.imported !== undefined) parts.push(d.imported + ' imported'); if (d.sent !== undefined) parts.push(d.sent + ' sent'); if (d.count !== undefined) parts.push(d.count + ' rows'); if (d.month) parts.push(d.month); if (parts.length) return '' + parts.join(' · ') + ''; return '-'; }; const rows = data.logs.map(log => { const style = badgeStyle(log.action); const label = actionLabel(log.action); const userDisplay = log.user_email || (log.user_type === 'admin' ? 'Admin' : 'Investor'); return ` ${fmt(log.created_at)} ${userDisplay} ${label} ${entityName(log)} ${detailsCell(log)} `; }).join(''); container.innerHTML = `${rows}
Time User Action Entity Details
`; } catch (err) { container.innerHTML = `
Failed to load activity log: ${err.message}
`; } } // ============================================ // MONTHLY REPORTS // ============================================ // Set default month picker to current month (function initMonthPicker() { const picker = document.getElementById('monthlyReportMonthPicker'); if (!picker) return; const now = new Date(); const y = now.getFullYear(); const m = String(now.getMonth() + 1).padStart(2, '0'); picker.value = `${y}-${m}`; })(); async function loadMonthlyReportsHistory() { const container = document.getElementById('monthlyReportsHistoryContent'); container.innerHTML = '
Loading...
'; try { const res = await authFetch('/api/admin/monthly-reports?limit=24'); const data = await res.json(); if (!data.success) throw new Error(data.message || 'Failed to load'); const badge = document.getElementById('monthlyReportsBadge'); if (data.total > 0) { badge.textContent = data.total + ' month' + (data.total !== 1 ? 's' : ''); badge.style.display = ''; } else { badge.style.display = 'none'; } if (!data.months || data.months.length === 0) { container.innerHTML = '
📊
No monthly reports generated yet.
Select a month and click Generate & Send to start.
'; return; } const fmtMonth = (dateStr) => { // dateStr is like "2026-03-01" const d = new Date(dateStr + 'T00:00:00'); return d.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); }; const fmtDate = (d) => d ? new Date(d).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—'; const rows = data.months.map(m => { const allSent = m.sent_count === m.investor_count; const partialSent = m.sent_count > 0 && m.sent_count < m.investor_count; const statusColor = allSent ? '#1a5c3a' : partialSent ? '#92400e' : '#6b6560'; const statusBg = allSent ? '#dcfce7' : partialSent ? '#fef3c7' : '#f3f4f6'; const statusLabel = allSent ? 'Sent' : partialSent ? 'Partial' : 'Unsent'; return ` ${fmtMonth(m.report_month)} ${m.sent_count} / ${m.investor_count} ${fmtDate(m.last_sent_at)} ${statusLabel} `; }).join(''); container.innerHTML = `${rows}
Month Sent / Total Last Sent Status
`; } catch (err) { container.innerHTML = `
Failed to load report history: ${err.message}
`; } } async function openGenerateReportsModal() { const month = document.getElementById('monthlyReportMonthPicker').value; if (!month) { showToast('Select a month first', 'error'); return; } const [year, mon] = month.split('-').map(Number); const monthLabel = new Date(year, mon - 1, 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); document.getElementById('generateReportsTitle').textContent = 'Generate Monthly Reports'; document.getElementById('generateReportsSubtitle').textContent = `Preparing report for ${monthLabel}`; document.getElementById('generateReportsError').style.display = 'none'; document.getElementById('generateReportsConfirm').style.display = 'block'; document.getElementById('generateReportsResult').style.display = 'none'; document.getElementById('generateReportsSubmitBtn').disabled = false; document.getElementById('generateReportsSubmitBtn').textContent = 'Send Reports'; // Count active investors with email try { const res = await authFetch('/api/investors'); const data = await res.json(); if (data.success) { const eligible = (data.investors || []).filter(i => i.status === 'active' && i.email); document.getElementById('generateReportsInvestorCount').textContent = eligible.length === 0 ? '⚠️ No active investors with email found.' : `Will send to ${eligible.length} investor${eligible.length !== 1 ? 's' : ''}`; document.getElementById('generateReportsSubmitBtn').disabled = eligible.length === 0; } } catch (e) { document.getElementById('generateReportsInvestorCount').textContent = 'Could not load investor count.'; } document.getElementById('generateReportsBackdrop').classList.add('active'); } function closeGenerateReportsModal(e) { if (e && e.target !== document.getElementById('generateReportsBackdrop')) return; document.getElementById('generateReportsBackdrop').classList.remove('active'); } async function submitGenerateReports() { const month = document.getElementById('monthlyReportMonthPicker').value; const btn = document.getElementById('generateReportsSubmitBtn'); const errEl = document.getElementById('generateReportsError'); errEl.style.display = 'none'; btn.disabled = true; btn.textContent = 'Sending...'; try { const res = await authFetch('/api/admin/generate-monthly-reports', { method: 'POST', body: JSON.stringify({ month }) }); const data = await res.json(); if (!data.success) { errEl.textContent = data.message || 'Failed to generate reports'; errEl.style.display = 'block'; btn.disabled = false; btn.textContent = 'Send Reports'; return; } // Show results document.getElementById('generateReportsConfirm').style.display = 'none'; document.getElementById('generateReportsResult').style.display = 'block'; const [yr, mn] = month.split('-').map(Number); const monthLabel = new Date(yr, mn - 1, 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); let html = `
✓ ${data.sent} report${data.sent !== 1 ? 's' : ''} sent
${monthLabel}
${data.errors > 0 ? `
⚠️ ${data.errors} failed to send
` : ''}
`; if (data.results && data.results.length > 0) { const failedRows = data.results.filter(r => r.status !== 'sent'); if (failedRows.length > 0) { html += `
Failed sends:
${failedRows.map(r => `
${r.investor} <${r.email}> — ${r.reason || r.status}
`).join('')}
`; } } document.getElementById('generateReportsResultContent').innerHTML = html; } catch (err) { errEl.textContent = 'Network error: ' + err.message; errEl.style.display = 'block'; btn.disabled = false; btn.textContent = 'Send Reports'; } } // ============================================ // BULK CSV IMPORT // ============================================ let importParsedRows = []; // All parsed rows from CSV let importValidRows = []; // Only valid rows function openImportModal() { document.getElementById('importBackdrop').classList.add('active'); resetImportModal(); } function closeImportModal(e) { if (e && e.target !== document.getElementById('importBackdrop')) return; document.getElementById('importBackdrop').classList.remove('active'); resetImportModal(); } function resetImportModal() { importParsedRows = []; importValidRows = []; document.getElementById('importStep1').style.display = 'block'; document.getElementById('importStep2').style.display = 'none'; document.getElementById('importStep3').style.display = 'none'; document.getElementById('importFileInput').value = ''; document.getElementById('importSendWelcomeEmails').checked = false; const errEl = document.getElementById('importError'); if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; } } function downloadCSVTemplate() { const headers = 'name,email,phone,invested_amount,investment_date,model_type,status'; const example = 'John Smith,john@example.com,+1 555 000 0000,50000,2024-01-15,wholesale,active'; const csv = headers + '\r\n' + example; const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'gecos-investors-template.csv'; a.click(); URL.revokeObjectURL(url); } function parseCSV(text) { // Simple but robust CSV parser supporting quoted fields const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); const result = []; function parseLine(line) { const fields = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (inQuotes) { if (ch === '"' && line[i+1] === '"') { current += '"'; i++; } else if (ch === '"') { inQuotes = false; } else { current += ch; } } else { if (ch === '"') { inQuotes = true; } else if (ch === ',') { fields.push(current.trim()); current = ''; } else { current += ch; } } } fields.push(current.trim()); return fields; } for (const line of lines) { if (line.trim()) result.push(parseLine(line)); } return result; } function handleCSVFile(event) { const file = event.target.files[0]; if (!file) return; if (file.size > 1 * 1024 * 1024) { showToast('File too large — max 1MB', 'error'); return; } const reader = new FileReader(); reader.onload = (e) => { try { const text = e.target.result; const rows = parseCSV(text); if (rows.length < 2) { showToast('CSV must have a header row and at least one data row', 'error'); return; } const headers = rows[0].map(h => h.toLowerCase().replace(/\s+/g, '_')); const dataRows = rows.slice(1).filter(r => r.some(cell => cell.trim())); if (dataRows.length === 0) { showToast('No data rows found in CSV', 'error'); return; } if (dataRows.length > 500) { showToast('Too many rows — max 500 per import', 'error'); return; } // Map headers to expected fields const fieldMap = { name: headers.findIndex(h => h === 'name'), email: headers.findIndex(h => h === 'email'), phone: headers.findIndex(h => h === 'phone'), invested_amount: headers.findIndex(h => ['invested_amount','amount','investment','capital'].includes(h)), investment_date: headers.findIndex(h => ['investment_date','date','invested_at','invest_date'].includes(h)), model_type: headers.findIndex(h => ['model_type','model','type'].includes(h)), status: headers.findIndex(h => h === 'status') }; if (fieldMap.name < 0) { showToast('CSV must have a "name" column', 'error'); return; } if (fieldMap.invested_amount < 0) { showToast('CSV must have an "invested_amount" column', 'error'); return; } // Parse rows const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const validModelTypes = ['wholesale', 'private_label']; const validStatuses = ['active', 'onboarding', 'paused']; importParsedRows = dataRows.map((row, i) => { const get = (idx) => idx >= 0 && idx < row.length ? row[idx].trim() : ''; const name = get(fieldMap.name); const email = get(fieldMap.email); const phone = get(fieldMap.phone); const invested_amount = get(fieldMap.invested_amount); const investment_date = get(fieldMap.investment_date); const model_type = get(fieldMap.model_type).toLowerCase() || 'wholesale'; const status = get(fieldMap.status).toLowerCase() || 'active'; const errors = []; if (!name) errors.push('Name required'); const amt = parseFloat(invested_amount); if (!invested_amount || isNaN(amt) || amt < 0) errors.push('Invalid amount'); if (email && !emailRegex.test(email)) errors.push('Invalid email'); if (model_type && !validModelTypes.includes(model_type)) errors.push('Invalid model_type'); if (status && !validStatuses.includes(status)) errors.push('Invalid status'); if (investment_date && isNaN(new Date(investment_date).getTime())) errors.push('Invalid date'); return { rowNum: i + 2, name, email, phone, invested_amount, investment_date, model_type, status, errors }; }); importValidRows = importParsedRows.filter(r => r.errors.length === 0); const invalidRows = importParsedRows.filter(r => r.errors.length > 0); // Build validation summary const summaryEl = document.getElementById('importValidationSummary'); if (invalidRows.length === 0) { summaryEl.style.background = '#dcfce7'; summaryEl.style.color = '#1a5c3a'; summaryEl.innerHTML = `✅ ${importParsedRows.length} rows ready to import — all valid`; } else { summaryEl.style.background = '#fef9c3'; summaryEl.style.color = '#92400e'; summaryEl.innerHTML = `⚠️ ${importValidRows.length} valid rows will be imported, ${invalidRows.length} rows will be skipped (errors shown in red)`; } // Build preview table const thStyle = 'padding:8px 12px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:#6b6560;text-align:left;background:#f8f6f1;border-bottom:1px solid #e8e4de;white-space:nowrap'; const tdStyle = 'padding:8px 12px;font-size:13px;border-bottom:1px solid #f0ece6;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'; document.getElementById('importPreviewHead').innerHTML = ` # Name Email Amount Date Model Status Issues `; document.getElementById('importPreviewBody').innerHTML = importParsedRows.map(r => { const hasError = r.errors.length > 0; const rowBg = hasError ? '#fff5f5' : ''; return ` ${r.rowNum} ${r.name || '—'} ${r.email || '—'} ${r.invested_amount ? '$' + parseFloat(r.invested_amount).toLocaleString() : '—'} ${r.investment_date || '—'} ${r.model_type || 'wholesale'} ${r.status || 'active'} ${hasError ? r.errors.join(', ') : ''} `; }).join(''); const submitBtn = document.getElementById('importSubmitBtn'); if (importValidRows.length === 0) { submitBtn.disabled = true; submitBtn.textContent = 'No Valid Rows'; } else { submitBtn.disabled = false; submitBtn.textContent = `Import ${importValidRows.length} Valid Row${importValidRows.length !== 1 ? 's' : ''}`; } document.getElementById('importStep1').style.display = 'none'; document.getElementById('importStep2').style.display = 'block'; } catch (err) { showToast('Failed to parse CSV: ' + err.message, 'error'); } }; reader.readAsText(file); } async function submitImport() { if (importValidRows.length === 0) return; const btn = document.getElementById('importSubmitBtn'); const errEl = document.getElementById('importError'); errEl.style.display = 'none'; btn.disabled = true; btn.textContent = 'Importing...'; const sendWelcomeEmails = document.getElementById('importSendWelcomeEmails').checked; try { const rowsToSend = importValidRows.map(({ rowNum, errors, ...rest }) => rest); const res = await authFetch('/api/admin/investors/import', { method: 'POST', body: JSON.stringify({ rows: rowsToSend, sendWelcomeEmails }) }); const data = await res.json(); if (!data.success) { errEl.textContent = data.message || 'Import failed'; errEl.style.display = 'block'; btn.disabled = false; btn.textContent = `Import ${importValidRows.length} Valid Row${importValidRows.length !== 1 ? 's' : ''}`; return; } // Show results document.getElementById('importStep2').style.display = 'none'; document.getElementById('importStep3').style.display = 'block'; let html = `
${data.imported} investor${data.imported !== 1 ? 's' : ''} imported
${data.skipped > 0 ? `
${data.skipped} skipped (duplicates)
` : ''} ${sendWelcomeEmails && data.imported > 0 ? '
📧 Welcome emails queued
' : ''}
`; const rowErrors = data.errors || []; if (rowErrors.length > 0) { html += `
Skipped rows (${rowErrors.length}):
${rowErrors.map(e => ``).join('')}
Row Reason
${e.row} ${e.reason}
`; } document.getElementById('importResultsSummary').innerHTML = html; // Refresh investor list loadInvestors(); loadSummary(); } catch (err) { errEl.textContent = 'Network error: ' + err.message; errEl.style.display = 'block'; btn.disabled = false; btn.textContent = `Import ${importValidRows.length} Valid Row${importValidRows.length !== 1 ? 's' : ''}`; } } // ============================================ // WEEKLY DIGEST // ============================================ async function loadWeeklyDigestHistory() { const container = document.getElementById('weeklyDigestHistoryContent'); if (!container) return; container.innerHTML = '
Loading...
'; try { const res = await authFetch('/api/admin/weekly-digest/history?limit=12'); const data = await res.json(); if (!data.success) throw new Error(data.message || 'Failed to load'); const badge = document.getElementById('weeklyDigestBadge'); if (data.total > 0) { badge.textContent = data.total + ' digest' + (data.total !== 1 ? 's' : ''); badge.style.display = ''; } else { badge.style.display = 'none'; } if (!data.digests || data.digests.length === 0) { container.innerHTML = '
📧
No weekly digests sent yet.
Click Send Now to send the first one.
'; return; } const fmtDate = (d) => d ? new Date(d).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—'; const fmtWeek = (dateStr) => { const d = new Date(dateStr + 'T00:00:00'); return 'Week of ' + d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); }; const rows = data.digests.map(digest => { const isSent = !!digest.sent_at; const statusColor = isSent ? '#1a5c3a' : '#92400e'; const statusBg = isSent ? '#dcfce7' : '#fef3c7'; const statusLabel = isSent ? 'Sent' : 'Draft'; const snap = digest.metrics_snapshot || {}; const aum = snap.total_aum ? fmt(snap.total_aum) : '—'; const investors = snap.active_investors != null ? snap.active_investors : '—'; return ` ${fmtWeek(digest.digest_date)} ${aum} ${investors} ${fmtDate(digest.sent_at)} ${statusLabel} `; }).join(''); container.innerHTML = `${rows}
Week AUM Snapshot Active Investors Sent At Status
`; } catch (err) { container.innerHTML = `
Failed to load digest history: ${escapeHtml(err.message)}
`; } } async function openWeeklyDigestPreview() { const backdrop = document.getElementById('weeklyDigestPreviewBackdrop'); const content = document.getElementById('weeklyDigestPreviewContent'); content.innerHTML = '
Generating preview…
'; backdrop.classList.add('active'); try { const res = await authFetch('/api/admin/weekly-digest/preview'); const data = await res.json(); if (!data.success) throw new Error(data.message || 'Failed to generate preview'); // Render the email HTML inside a sandboxed iframe const iframe = document.createElement('iframe'); iframe.style.cssText = 'width:100%;height:560px;border:none;border-radius:8px;background:#fff'; iframe.setAttribute('sandbox', 'allow-same-origin'); iframe.srcdoc = data.html; content.innerHTML = ''; content.appendChild(iframe); } catch (err) { content.innerHTML = `
Failed to generate preview: ${escapeHtml(err.message)}
`; } } function closeWeeklyDigestPreview(e) { if (e && e.target !== document.getElementById('weeklyDigestPreviewBackdrop')) return; document.getElementById('weeklyDigestPreviewBackdrop').classList.remove('active'); // Clear iframe to stop any resources const content = document.getElementById('weeklyDigestPreviewContent'); content.innerHTML = '
Generating preview...
'; } function openWeeklyDigestConfirm() { const backdrop = document.getElementById('weeklyDigestConfirmBackdrop'); document.getElementById('weeklyDigestConfirmError').style.display = 'none'; document.getElementById('weeklyDigestConfirmBody').style.display = 'block'; document.getElementById('weeklyDigestConfirmResult').style.display = 'none'; const btn = document.getElementById('weeklyDigestConfirmSubmitBtn'); btn.disabled = false; btn.textContent = 'Send Digest'; backdrop.classList.add('active'); } function closeWeeklyDigestConfirm(e) { if (e && e.target !== document.getElementById('weeklyDigestConfirmBackdrop')) return; document.getElementById('weeklyDigestConfirmBackdrop').classList.remove('active'); } async function submitWeeklyDigest() { const btn = document.getElementById('weeklyDigestConfirmSubmitBtn'); const errEl = document.getElementById('weeklyDigestConfirmError'); errEl.style.display = 'none'; btn.disabled = true; btn.textContent = 'Sending…'; try { const res = await authFetch('/api/admin/send-weekly-digest', { method: 'POST' }); const data = await res.json(); if (!data.success) { errEl.textContent = data.message || 'Failed to send digest'; errEl.style.display = 'block'; btn.disabled = false; btn.textContent = 'Send Digest'; return; } // Show success result document.getElementById('weeklyDigestConfirmBody').style.display = 'none'; const resultEl = document.getElementById('weeklyDigestConfirmResult'); resultEl.style.display = 'block'; resultEl.innerHTML = `
Digest Sent!
Emailed to sohail@gec.live
${data.digest_date ? 'Week of ' + new Date(data.digest_date + 'T00:00:00').toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) : ''}
`; // Refresh history loadWeeklyDigestHistory(); } catch (err) { errEl.textContent = 'Network error: ' + err.message; errEl.style.display = 'block'; btn.disabled = false; btn.textContent = 'Send Digest'; } } // ============================================ // COMMUNICATIONS HUB // ============================================ let emailTemplatesCache = null; async function fetchEmailTemplates() { if (emailTemplatesCache) return emailTemplatesCache; try { const res = await authFetch('/api/admin/email-templates'); const data = await res.json(); if (data.success) emailTemplatesCache = data.templates; } catch (e) { /* silent */ } return emailTemplatesCache || []; } async function openComposeModal() { document.getElementById('composeSubject').value = ''; document.getElementById('composeBody').value = ''; document.getElementById('composeRecipientType').value = 'all'; document.getElementById('composeIndividualPicker').style.display = 'none'; document.getElementById('composeError').style.display = 'none'; document.getElementById('composeSendBtn').disabled = false; document.getElementById('composeSendBtn').textContent = 'Send Announcement'; document.getElementById('composePreviewBtn').disabled = false; // Pre-load templates fetchEmailTemplates(); // Populate individual investor picker const picker = document.getElementById('composeInvestorPicker'); picker.innerHTML = ''; if (investorsCache && investorsCache.length) { investorsCache.forEach(inv => { const opt = document.createElement('option'); opt.value = inv.id; opt.textContent = inv.name + (inv.email ? ' (' + inv.email + ')' : ''); picker.appendChild(opt); }); } document.getElementById('composeBackdrop').classList.add('active'); } function closeComposeModal() { document.getElementById('composeBackdrop').classList.remove('active'); } function handleRecipientTypeChange() { const type = document.getElementById('composeRecipientType').value; document.getElementById('composeIndividualPicker').style.display = type === 'individual' ? 'block' : 'none'; } async function applyTemplate(templateId) { const templates = await fetchEmailTemplates(); const tpl = templates.find(t => t.id === templateId); if (!tpl) return; document.getElementById('composeSubject').value = tpl.subject; document.getElementById('composeBody').value = tpl.body; } function getComposeParams() { const subject = document.getElementById('composeSubject').value.trim(); const body = document.getElementById('composeBody').value.trim(); const rawType = document.getElementById('composeRecipientType').value; let recipient_type = 'all'; let recipient_filter = null; if (rawType === 'all') { recipient_type = 'all'; } else if (rawType === 'filtered_active') { recipient_type = 'filtered'; recipient_filter = { status: 'active' }; } else if (rawType === 'filtered_min50k') { recipient_type = 'filtered'; recipient_filter = { min_invested: 50000 }; } else if (rawType === 'filtered_min100k') { recipient_type = 'filtered'; recipient_filter = { min_invested: 100000 }; } else if (rawType === 'individual') { const investorId = parseInt(document.getElementById('composeInvestorPicker').value); if (!investorId) return null; recipient_type = 'individual'; recipient_filter = { investor_id: investorId }; } return { subject, body, recipient_type, recipient_filter }; } async function previewAnnouncement() { const errEl = document.getElementById('composeError'); errEl.style.display = 'none'; const params = getComposeParams(); if (!params) { errEl.textContent = 'Please select an investor.'; errEl.style.display = 'block'; return; } if (!params.subject) { errEl.textContent = 'Subject is required.'; errEl.style.display = 'block'; return; } if (!params.body) { errEl.textContent = 'Message body is required.'; errEl.style.display = 'block'; return; } const btn = document.getElementById('composePreviewBtn'); btn.disabled = true; btn.textContent = 'Loading…'; try { const res = await authFetch('/api/admin/announcements/preview', { method: 'POST', body: JSON.stringify(params) }); const data = await res.json(); if (!data.success) { errEl.textContent = data.message || 'Preview failed'; errEl.style.display = 'block'; return; } if (!data.recipient_count) { errEl.textContent = 'No recipients match the selected criteria.'; errEl.style.display = 'block'; return; } document.getElementById('announcePreviewMeta').textContent = data.recipient_count + ' recipient' + (data.recipient_count !== 1 ? 's' : '') + (data.preview_for ? ' — preview shown for: ' + data.preview_for.name : ''); document.getElementById('announcePreviewContent').innerHTML = data.preview_html || 'No preview available'; document.getElementById('announcePreviewBackdrop').classList.add('active'); } catch (err) { errEl.textContent = 'Network error: ' + err.message; errEl.style.display = 'block'; } finally { btn.disabled = false; btn.textContent = 'Preview'; btn.innerHTML = ' Preview'; } } function closeAnnouncePreview(e) { if (e && e.target !== document.getElementById('announcePreviewBackdrop')) return; document.getElementById('announcePreviewBackdrop').classList.remove('active'); document.getElementById('announcePreviewContent').innerHTML = '
Generating preview...
'; } async function sendAnnouncement() { const errEl = document.getElementById('composeError'); errEl.style.display = 'none'; const params = getComposeParams(); if (!params) { errEl.textContent = 'Please select an investor.'; errEl.style.display = 'block'; return; } if (!params.subject) { errEl.textContent = 'Subject is required.'; errEl.style.display = 'block'; return; } if (!params.body) { errEl.textContent = 'Message body is required.'; errEl.style.display = 'block'; return; } const btn = document.getElementById('composeSendBtn'); btn.disabled = true; btn.textContent = 'Sending…'; try { const res = await authFetch('/api/admin/announcements', { method: 'POST', body: JSON.stringify(params) }); const data = await res.json(); if (!data.success) { errEl.textContent = data.message || 'Failed to send announcement'; errEl.style.display = 'block'; btn.disabled = false; btn.textContent = 'Send Announcement'; return; } closeComposeModal(); showToast(`✓ Sent to ${data.sent}/${data.total} investor${data.total !== 1 ? 's' : ''}`, 'success'); loadAnnouncementsHistory(); } catch (err) { errEl.textContent = 'Network error: ' + err.message; errEl.style.display = 'block'; btn.disabled = false; btn.textContent = 'Send Announcement'; } } async function loadAnnouncementsHistory() { const el = document.getElementById('announcementsHistoryContent'); if (!el) return; try { const res = await authFetch('/api/admin/announcements?limit=50'); const data = await res.json(); if (!data.success) { el.innerHTML = '
Failed to load announcements
'; return; } const badge = document.getElementById('announcementsBadge'); if (badge) { badge.style.display = data.total > 0 ? 'inline-flex' : 'none'; badge.textContent = data.total + ' sent'; } if (!data.announcements.length) { el.innerHTML = `
📭
No announcements yet
Click "Compose & Send" to send your first bulk email.
`; return; } const rows = data.announcements.map(a => { const recipLabel = a.recipient_type === 'all' ? 'All Investors' : a.recipient_type === 'individual' ? 'Individual' : 'Filtered'; const sentDate = a.sent_at ? new Date(a.sent_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'; return ` ${escapeHtml(a.subject)} ${sentDate} ${recipLabel} ✓ ${a.sent_count} sent `; }).join(''); el.innerHTML = `${rows}
Subject Sent Recipients Delivered
`; } catch (err) { el.innerHTML = '
Error loading announcements
'; } } async function openAnnounceDetail(id) { const subjectEl = document.getElementById('announceDetailSubject'); const metaEl = document.getElementById('announceDetailMeta'); const statsEl = document.getElementById('announceDetailStats'); const recipientsEl = document.getElementById('announceDetailRecipients'); subjectEl.textContent = 'Loading…'; metaEl.textContent = ''; statsEl.innerHTML = ''; recipientsEl.innerHTML = '
Loading...
'; document.getElementById('announceDetailBackdrop').classList.add('active'); try { const res = await authFetch('/api/admin/announcements/' + id); const data = await res.json(); if (!data.success) { subjectEl.textContent = 'Error loading'; return; } const ann = data.announcement; const sentDate = ann.sent_at ? new Date(ann.sent_at).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : 'Not sent'; subjectEl.textContent = ann.subject; metaEl.textContent = 'Sent ' + sentDate + (ann.created_by ? ' by ' + ann.created_by : ''); statsEl.innerHTML = [ { label: 'Total', value: data.stats.total, color: '#6b6560' }, { label: 'Sent', value: data.stats.sent, color: '#1a5c3a' }, { label: 'Failed', value: data.stats.failed, color: '#b91c1c' }, { label: 'Pending', value: data.stats.pending, color: '#92400e' } ].map(s => `
${s.value}
${s.label}
`).join(''); if (!data.recipients.length) { recipientsEl.innerHTML = '
No recipients found
'; return; } const rows = data.recipients.map(r => { const statusColor = r.status === 'sent' ? { bg: '#dcfce7', text: '#1a5c3a' } : r.status === 'failed' ? { bg: '#fee2e2', text: '#b91c1c' } : { bg: '#fef3c7', text: '#92400e' }; const sentAt = r.sent_at ? new Date(r.sent_at).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) : '—'; return ` ${escapeHtml(r.investor_name || '—')} ${escapeHtml(r.email)} ${r.status} ${sentAt} `; }).join(''); recipientsEl.innerHTML = `${rows}
Investor Email Status Time
`; } catch (err) { subjectEl.textContent = 'Error'; recipientsEl.innerHTML = '
Failed to load: ' + escapeHtml(err.message) + '
'; } } function closeAnnounceDetail(e) { if (e && e.target !== document.getElementById('announceDetailBackdrop')) return; document.getElementById('announceDetailBackdrop').classList.remove('active'); } // ============================================ // TEAM MANAGEMENT (owner only) // ============================================ const ROLE_BADGE = { owner: { bg: '#f3e8ff', color: '#7c3aed', label: 'Owner' }, manager: { bg: '#dbeafe', color: '#1d4ed8', label: 'Manager' }, viewer: { bg: '#f1f5f9', color: '#475569', label: 'Viewer' } }; function roleBadgeHtml(role) { const r = ROLE_BADGE[role] || ROLE_BADGE.viewer; return `${r.label}`; } async function loadTeam() { const tbody = document.getElementById('teamTableBody'); if (!tbody) return; try { const res = await authFetch('/api/admin/team'); const data = await res.json(); if (!data.success) throw new Error(data.message); const team = data.team; if (team.length === 0) { tbody.innerHTML = 'No team members yet'; return; } tbody.innerHTML = team.map(member => { const isCurrentUser = currentUser && member.id === currentUser.id; const statusBadge = member.pending_invite ? `Pending invite` : member.is_active ? `Active` : `Inactive`; const lastLogin = member.last_login ? new Date(member.last_login).toLocaleDateString('en-US', { month:'short', day:'numeric', year:'numeric' }) : '—'; const youBadge = isCurrentUser ? ' (you)' : ''; const twoFaBadge = member.totp_enabled ? `🔒 2FA` : ``; const actions = isCurrentUser ? '—' : ` `; return ` ${escapeHtml(member.name || '—')}${youBadge} ${escapeHtml(member.email)} ${roleBadgeHtml(member.role)} ${statusBadge} ${twoFaBadge} ${lastLogin} ${actions} `; }).join(''); } catch (err) { tbody.innerHTML = `Failed to load team: ${escapeHtml(err.message)}`; } } async function updateMemberRole(id, newRole) { if (!newRole) return; const confirmMsg = `Change this member's role to ${newRole}?`; if (!confirm(confirmMsg)) { loadTeam(); return; } try { const res = await authFetch(`/api/admin/team/${id}`, { method: 'PATCH', body: JSON.stringify({ role: newRole }) }); const data = await res.json(); if (!data.success) throw new Error(data.message); showToast('Role updated', 'success'); loadTeam(); } catch (err) { showToast('Failed: ' + err.message, 'error'); loadTeam(); } } async function removeMember(id, name) { if (!confirm(`Remove ${name} from the team? This cannot be undone.`)) return; try { const res = await authFetch(`/api/admin/team/${id}`, { method: 'DELETE' }); const data = await res.json(); if (!data.success) throw new Error(data.message); showToast(name + ' removed from team', 'success'); loadTeam(); } catch (err) { showToast('Failed: ' + err.message, 'error'); } } function openInviteModal() { const modal = document.getElementById('inviteBackdrop'); document.getElementById('inviteError').style.display = 'none'; document.getElementById('inviteName').value = ''; document.getElementById('inviteEmail').value = ''; document.getElementById('inviteRole').value = 'viewer'; modal.style.display = 'flex'; setTimeout(() => document.getElementById('inviteEmail').focus(), 100); } function closeInviteModal(e) { if (e && e.target !== document.getElementById('inviteBackdrop')) return; document.getElementById('inviteBackdrop').style.display = 'none'; } // ============================================ // REVENUE FORECASTING // ============================================ function trendArrow(trend) { return { growing: '↑', stable: '→', declining: '↓' }[trend] || '→'; } function trendArrowDetailed(trend) { // More nuanced arrows return { growing: '↑', stable: '→', declining: '↓' }[trend] || '→'; } function fmtForecastMonth(dateStr) { // dateStr like "2026-05-01" return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); } async function loadForecast() { const section = document.getElementById('revenueForecastSection'); const cardsEl = document.getElementById('forecastSummaryCards'); const tableEl = document.getElementById('forecastInvestorTable'); if (cardsEl) cardsEl.innerHTML = '
Loading forecast...
'; try { const res = await authFetch('/api/admin/forecasts'); const data = await res.json(); if (!data.success) throw new Error(data.message || 'Failed to load forecast'); if (section) section.style.display = 'block'; const projections = data.projections || []; const summary = data.summary || {}; const byStore = data.by_store || []; if (projections.length === 0) { cardsEl.innerHTML = '
📊
No revenue data available for forecasting.
Add revenue entries to stores to generate projections.
'; return; } // Summary bar const trendLabel = { growing: 'Growing', stable: 'Stable', declining: 'Declining' }[summary.trend] || 'Stable'; const summaryHtml = `
${trendArrow(summary.trend)} ${trendLabel}
Portfolio Trend
${(summary.confidence || 'low').toUpperCase()}
Confidence
${summary.data_stores || 0} / ${summary.total_stores || 0}
Stores w/ 3+ months data
`; // 3-month projection cards const cardsHtml = projections.map(p => `
${fmtForecastMonth(p.month)}
${fmt(p.projected_revenue)}
`).join(''); cardsEl.innerHTML = summaryHtml + '
' + cardsHtml + '
'; // Per-investor breakdown table if (tableEl && byStore.length > 0) { // Group by investor const investorMap = new Map(); for (const sf of byStore) { if (!sf.investor_id) continue; if (!investorMap.has(sf.investor_id)) { const inv = investorsCache.find(i => i.id === sf.investor_id); investorMap.set(sf.investor_id, { id: sf.investor_id, name: inv ? inv.name : 'Investor #' + sf.investor_id, stores: [] }); } investorMap.get(sf.investor_id).stores.push(sf); } // Aggregate investor projections const investorRows = []; for (const [, invData] of investorMap.entries()) { const storeList = invData.stores; const n = projections.length; const invProjections = projections.map((_, i) => storeList.reduce((sum, sf) => sum + (sf.projections[i] ? sf.projections[i].projected_revenue : 0), 0) ); const trendCounts = { growing: 0, stable: 0, declining: 0 }; for (const sf of storeList) trendCounts[sf.trend] = (trendCounts[sf.trend] || 0) + 1; const invTrend = Object.entries(trendCounts).sort((a, b) => b[1] - a[1])[0][0]; investorRows.push({ id: invData.id, name: invData.name, trend: invTrend, projections: invProjections, store_count: storeList.length }); } investorRows.sort((a, b) => (b.projections[0] || 0) - (a.projections[0] || 0)); const monthLabels = projections.map(p => fmtForecastMonth(p.month)); const tableHtml = `
${monthLabels.map(m => ``).join('')} ${investorRows.map(r => ` ${r.projections.map(p => ``).join('')} `).join('')}
Investor Trend${m}Stores
${escapeHtml(r.name)} ${trendArrow(r.trend)} ${{ growing: 'Growing', stable: 'Stable', declining: 'Declining' }[r.trend] || 'Stable'} ${fmt(p)}${r.store_count}
`; tableEl.innerHTML = tableHtml; const cardWrap = document.getElementById('forecastInvestorCardWrap'); if (cardWrap) cardWrap.style.display = 'block'; } else if (tableEl) { tableEl.innerHTML = '
No per-investor data available yet.
'; } } catch (err) { console.error('[Forecast] Error:', err); if (cardsEl) cardsEl.innerHTML = '
Could not load forecast data.
'; } } async function loadInvestorForecastExpand(investorId) { const container = document.getElementById('inv-forecast-content-' + investorId); if (!container) return; try { const res = await authFetch('/api/admin/investors/' + investorId + '/forecast'); const data = await res.json(); if (!data.success) throw new Error(data.message || 'Failed'); const projections = data.projections || []; if (projections.length === 0) { container.innerHTML = '
No revenue data yet — add entries to generate a forecast.
'; return; } const cardsHtml = projections.map(p => `
${fmtForecastMonth(p.month)}
${fmt(p.projected_revenue)}
${trendArrow(p.trend)} ${(p.confidence || 'low').toUpperCase()}
`).join(''); container.innerHTML = '
' + cardsHtml + '
'; } catch (err) { container.innerHTML = '
Could not load forecast.
'; } } // ============================================ // ALERTS // ============================================ let alertsCurrentPage = 1; function scrollToAlerts(e) { e.preventDefault(); const el = document.getElementById('alerts-section'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); } async function loadAlertSummary() { try { const res = await authFetch('/api/admin/alerts/summary'); const data = await res.json(); if (!data.success) return; const badge = document.getElementById('alertNavBadge'); const navLink = document.getElementById('alertsNavLink'); if (navLink) navLink.style.display = ''; if (data.unacknowledged_critical > 0) { badge.textContent = data.unacknowledged_critical; badge.style.display = ''; } else { badge.style.display = 'none'; } const sectionBadge = document.getElementById('alertsSectionBadge'); const total = data.unacknowledged_critical + data.unacknowledged_warning; if (sectionBadge) { if (total > 0) { sectionBadge.textContent = `${total} Active Alert${total !== 1 ? 's' : ''}`; sectionBadge.style.background = data.unacknowledged_critical > 0 ? '#fee2e2' : '#fef3c7'; sectionBadge.style.color = data.unacknowledged_critical > 0 ? '#b91c1c' : '#92400e'; } else { sectionBadge.textContent = 'All Clear'; sectionBadge.style.background = ''; sectionBadge.style.color = ''; } } } catch (e) { // non-critical } } async function loadAlerts(page) { if (page) alertsCurrentPage = page; const grid = document.getElementById('alertsGrid'); if (!grid) return; grid.innerHTML = '
Loading...
'; const severity = document.getElementById('alertFilterSeverity').value; const type = document.getElementById('alertFilterType').value; const acked = document.getElementById('alertFilterAcknowledged').value; let url = `/api/admin/alerts?page=${alertsCurrentPage}&limit=20`; if (severity) url += `&severity=${severity}`; if (type) url += `&type=${type}`; if (acked !== '') url += `&acknowledged=${acked}`; try { const res = await authFetch(url); const data = await res.json(); if (!data.success) throw new Error(data.message); if (data.alerts.length === 0) { grid.innerHTML = `
No alerts found
All stores are performing within expected thresholds.
`; document.getElementById('alertsPagination').innerHTML = ''; return; } grid.innerHTML = data.alerts.map(a => renderAlertCard(a)).join(''); renderAlertsPagination(data.total, data.page, data.limit); } catch (err) { grid.innerHTML = `
Failed to load alerts: ${err.message}
`; } } function renderAlertCard(a) { const icons = { revenue_drop: '📉', forecast_miss: '🎯', zero_revenue: '🔇', trend_reversal: '🔄' }; const labels = { revenue_drop: 'Revenue Drop', forecast_miss: 'Forecast Miss', zero_revenue: 'Zero Revenue', trend_reversal: 'Trend Reversal' }; const icon = icons[a.alert_type] || '⚠️'; const label = labels[a.alert_type] || a.alert_type; const acked = a.acknowledged; const ts = new Date(a.created_at).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' }); const metricStr = a.metric_value !== null ? `Metric: $${parseFloat(a.metric_value).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2})}${a.threshold_value !== null ? ` / Threshold: $${parseFloat(a.threshold_value).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2})}` : ''}` : ''; const ackedLabel = acked ? `Acknowledged${a.acknowledged_by ? ' by ' + a.acknowledged_by : ''}${a.acknowledged_at ? ' · ' + new Date(a.acknowledged_at).toLocaleDateString() : ''}` : ``; return `
${icon}
${escHtml(a.store_name)} ${escHtml(a.investor_name)} ${label} ${a.severity.toUpperCase()}

${escHtml(a.message)}

${metricStr ? '
' + metricStr + '
' : ''}
`; } function escHtml(str) { if (!str) return ''; return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function renderAlertsPagination(total, page, limit) { const pagination = document.getElementById('alertsPagination'); const totalPages = Math.ceil(total / limit); if (totalPages <= 1) { pagination.innerHTML = ''; return; } let html = `Page ${page} of ${totalPages} (${total} total)`; if (page > 1) html += ``; if (page < totalPages) html += ``; pagination.innerHTML = html; } async function acknowledgeAlert(id, btn) { btn.disabled = true; btn.textContent = '...'; try { const res = await authFetch(`/api/admin/alerts/${id}/acknowledge`, { method: 'PATCH' }); const data = await res.json(); if (!data.success) throw new Error(data.message); const card = document.getElementById(`alert-card-${id}`); if (card) { card.classList.add('acknowledged'); const footer = card.querySelector('.alert-footer'); if (footer) { const btnEl = footer.querySelector('.btn-ack'); if (btnEl) btnEl.replaceWith(Object.assign(document.createElement('span'), { className: 'alert-ack-label', textContent: 'Acknowledged' })); } } loadAlertSummary(); } catch (err) { btn.disabled = false; btn.textContent = 'Acknowledge'; showToast('Failed to acknowledge: ' + err.message, 'error'); } } // Alert Settings (owner only) let _alertSettingsData = []; async function loadAlertSettings() { try { const res = await authFetch('/api/admin/alerts/settings'); const data = await res.json(); if (!data.success) return; _alertSettingsData = data.settings; renderAlertSettings(data.settings); } catch (e) { // non-critical } } function renderAlertSettings(settings) { const container = document.getElementById('alertSettingsRows'); if (!container) return; const typeLabels = { revenue_drop: 'Revenue Drop', forecast_miss: 'Forecast Miss', zero_revenue: 'Zero Revenue', trend_reversal: 'Trend Reversal' }; const typeDesc = { revenue_drop: 'Alert when month-over-month revenue falls by threshold %', forecast_miss: 'Alert when actual revenue is below forecast by threshold %', zero_revenue: 'Alert when a store has no revenue entries in the last 30 days', trend_reversal: 'Alert when a growing store shows 2 consecutive months of decline' }; const hasThreshold = { revenue_drop: true, forecast_miss: true, zero_revenue: false, trend_reversal: false }; container.innerHTML = settings.map(s => `
${typeLabels[s.alert_type] || s.alert_type}
${typeDesc[s.alert_type] || ''}
${hasThreshold[s.alert_type] ? `
%
` : '
'}
`).join(''); } async function saveAlertSettings() { const updates = _alertSettingsData.map(s => { const enabledEl = document.getElementById(`toggle-${s.alert_type}`); const thresholdEl = document.getElementById(`threshold-${s.alert_type}`); return { alert_type: s.alert_type, enabled: enabledEl ? enabledEl.checked : s.enabled, threshold_pct: thresholdEl ? parseFloat(thresholdEl.value) : s.threshold_pct }; }); try { const res = await authFetch('/api/admin/alerts/settings', { method: 'PATCH', body: JSON.stringify({ updates }) }); const data = await res.json(); if (!data.success) throw new Error(data.message); _alertSettingsData = data.settings; showToast('Alert settings saved', 'success'); } catch (err) { showToast('Failed to save settings: ' + err.message, 'error'); } } async function submitInvite() { const btn = document.getElementById('inviteSubmitBtn'); const errEl = document.getElementById('inviteError'); const email = document.getElementById('inviteEmail').value.trim(); const name = document.getElementById('inviteName').value.trim(); const role = document.getElementById('inviteRole').value; errEl.style.display = 'none'; if (!email) { errEl.textContent = 'Email is required'; errEl.style.display = 'block'; return; } btn.disabled = true; btn.textContent = 'Sending...'; try { const res = await authFetch('/api/admin/team/invite', { method: 'POST', body: JSON.stringify({ email, name, role }) }); const data = await res.json(); if (!data.success) throw new Error(data.message); document.getElementById('inviteBackdrop').style.display = 'none'; showToast(`Invitation sent to ${email}`, 'success'); loadTeam(); } catch (err) { errEl.textContent = err.message; errEl.style.display = 'block'; } finally { btn.disabled = false; btn.textContent = 'Send Invitation'; } } // ============================================ // REVENUE IMPORT MODAL // ============================================ function openRevenueImportModal() { document.getElementById('revenueImportBackdrop').style.display = 'flex'; document.getElementById('revenueImportFileInput').value = ''; document.getElementById('revenueImportPaste').value = ''; document.getElementById('revenueImportPreview').innerHTML = ''; document.getElementById('revenueImportPreviewSection').style.display = 'none'; document.getElementById('revenueImportError').style.display = 'none'; document.getElementById('revenueImportResult').style.display = 'none'; } function closeRevenueImportModal(event) { if (event && event.target !== event.currentTarget) return; document.getElementById('revenueImportBackdrop').style.display = 'none'; } function parseCSV(text) { const lines = text.trim().split(/\r?\n/); if (lines.length < 2) return []; const headers = lines[0].split(',').map(h => h.trim().toLowerCase()); const rows = []; for (let i = 1; i < lines.length; i++) { const vals = lines[i].split(',').map(v => v.trim()); const row = {}; headers.forEach((h, idx) => { row[h] = vals[idx] || ''; }); rows.push(row); } return rows; } async function previewRevenueImport() { const pasteText = document.getElementById('revenueImportPaste').value.trim(); const fileInput = document.getElementById('revenueImportFileInput'); let rows = []; if (fileInput.files.length > 0) { const file = fileInput.files[0]; const text = await file.text(); rows = parseCSV(text); } else if (pasteText) { rows = parseCSV(pasteText); } else { const errEl = document.getElementById('revenueImportError'); errEl.textContent = 'Please upload a CSV file or paste CSV data'; errEl.style.display = 'block'; return; } if (rows.length === 0) { const errEl = document.getElementById('revenueImportError'); errEl.textContent = 'No data rows found in input'; errEl.style.display = 'block'; return; } document.getElementById('revenueImportError').style.display = 'none'; const previewSection = document.getElementById('revenueImportPreviewSection'); const preview = document.getElementById('revenueImportPreview'); preview.innerHTML = `
Validating ${rows.length} rows...
`; previewSection.style.display = 'block'; // Client-side validation preview const monthRegex = /^\/d{4}-(0[1-9]|1[0-2])$/; let validCount = 0; let errorCount = 0; const previewRows = rows.slice(0, 100).map(row => { const storeName = row.store_name || row.store || ''; const investorName = row.investor_name || row.investor || ''; const month = row.month || ''; const revenue = row.revenue || ''; const isValid = storeName && investorName && month.match(/^\/d{4}-\/d{2}$/) && !isNaN(parseFloat(revenue)) && parseFloat(revenue) >= 0; if (isValid) validCount++; else errorCount++; return ` ${escapeHtml(storeName)} ${escapeHtml(investorName)} ${escapeHtml(month)} ${escapeHtml(revenue)} ${isValid ? '✓ Valid' : '✗ Invalid'} `; }).join(''); const summaryText = rows.length > 100 ? ` (showing first 100 of ${rows.length})` : ''; preview.innerHTML = `
${validCount} valid ${errorCount} invalid ${rows.length} total${summaryText}
${previewRows}
Store Investor Month Revenue Status
`; // Store parsed rows for submit document.getElementById('revenueImportPreview').dataset.rows = JSON.stringify(rows); } async function submitRevenueImport() { const rowsData = document.getElementById('revenueImportPreview').dataset.rows; if (!rowsData) { showToast('Please preview first', 'error'); return; } const rows = JSON.parse(rowsData); if (rows.length === 0) return; const fileInput = document.getElementById('revenueImportFileInput'); const filename = fileInput.files.length > 0 ? fileInput.files[0].name : 'pasted_data.csv'; const btn = document.getElementById('revenueImportSubmitBtn'); btn.disabled = true; btn.textContent = 'Importing...'; const resultEl = document.getElementById('revenueImportResult'); resultEl.style.display = 'none'; document.getElementById('revenueImportError').style.display = 'none'; try { const res = await authFetch('/api/admin/revenue/import', { method: 'POST', body: JSON.stringify({ rows, filename }) }); const data = await res.json(); if (!data.success) { const errEl = document.getElementById('revenueImportError'); errEl.textContent = data.message || 'Import failed'; errEl.style.display = 'block'; btn.disabled = false; btn.textContent = 'Import Revenue'; return; } resultEl.innerHTML = `
Import Complete
${data.successful_rows}
Imported
${data.failed_rows}
Failed
${data.total_rows}
Total
${data.errors && data.errors.length > 0 ? `
First error: ${escapeHtml(data.errors[0].reason)}
` : ''}
`; resultEl.style.display = 'block'; if (typeof loadStoreAnalytics === 'function') loadStoreAnalytics(); loadRevenueImportHistory(); setTimeout(() => closeRevenueImportModal(), 3000); } catch (err) { const errEl = document.getElementById('revenueImportError'); errEl.textContent = 'Network error: ' + err.message; errEl.style.display = 'block'; } finally { btn.disabled = false; btn.textContent = 'Import Revenue'; } } async function loadRevenueImportHistory() { const listEl = document.getElementById('revenueImportHistoryList'); const countEl = document.getElementById('revenueImportHistoryCount'); const badgeEl = document.getElementById('revenueImportHistoryBadge'); try { const res = await authFetch('/api/admin/revenue/imports?limit=10'); const data = await res.json(); if (!data.success) { listEl.innerHTML = '
Failed to load
'; return; } countEl.textContent = `${data.total} total`; if (data.total > 0) { badgeEl.style.display = 'inline'; badgeEl.textContent = data.total; } if (data.imports.length === 0) { listEl.innerHTML = '
No imports yet
'; return; } const statusColors = { processing: '#f59e0b', completed: '#10b981', completed_with_errors: '#f97316' }; listEl.innerHTML = data.imports.map(im => { const errors = im.error_log ? JSON.parse(im.error_log) : []; const date = new Date(im.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' }); return `
${escapeHtml(im.filename)}
${im.uploaded_by} · ${date}
${errors.length > 0 ? `
${errors[0].reason}
` : ''}
${im.successful_rows}
imported
${im.failed_rows > 0 ? `
${im.failed_rows} failed
` : ''}
${im.status}
`; }).join(''); } catch (err) { listEl.innerHTML = '
Failed to load
'; } } function toggleRevenueImportHistory() { const content = document.getElementById('revenueImportHistoryContent'); const arrow = document.getElementById('revenueImportHistoryArrow'); if (content.style.display === 'none') { content.style.display = 'block'; arrow.style.transform = 'rotate(90deg)'; loadRevenueImportHistory(); } else { content.style.display = 'none'; arrow.style.transform = ''; } } function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str || ''; return div.innerHTML; }