Dashboard
Accounts
Active accounts
Portfolios
Managed accounts
Advisers
Team members
FUM
Total AUM

Organisation Hierarchy

Loading hierarchy…

No accounts yet

Add your first account to start managing portfolios, CGT events, and compliance documents.

Email
Phone
Tax Rate
Period:
Opening Balance
Net Deposits
Investment Return
Closing Balance
Return %
Net Assets

Valuation

Market Movements

Loading…

Asset Allocation

Loading…

Top 5 Holdings

Loading…

Performance

Display by:
Balances displayed as AUD equivalent
Loading holdings…
Cash Balance

Transaction History

Loading…
Loading KYC data…

No portfolios yet

Portfolios are created when you add an account.

Market Value
Cost Base
Unrealised P&L
Holdings

Performance

Asset Allocation

CGT Engine

Select a portfolio above to calculate capital gains, run scenarios, and find tax-loss harvesting opportunities.

Pending Reviews
Approved
Total Submissions

Pending KYC Reviews

Loading…

No advisers yet

Invite your team members to manage client portfolios.

Firm Details

White-Label Branding

Click to upload logo

Shown in sidebar, page titles, and emails

Sidebar & nav
Hover states
Buttons & links

Workspace URL

Email Digest

Weekly portfolio summary with tax alerts delivered to your inbox.

+parseFloat(bal).toFixed(2); // Update cache const c = clientsCache.find(x=>x.id===clientId); if(c) c.cash_balance = bal; // Populate portfolio dropdown in Transactions filter bar const txPortSel = document.getElementById('acctTxPortfolio'); if (txPortSel && _holdingsPortfolios && _holdingsPortfolios.length) { const opts = _holdingsPortfolios.map(p => ``).join(''); txPortSel.innerHTML = '' + opts; } if(!txns.length) { body.innerHTML = `
💳
No transactions yet
Add a deposit to get started
`; return; } body.innerHTML = `
${txns.map(tx => { const sign = TX_SIGN[tx.type] || ''; const color = TX_COLORS[tx.type] || 'text-slate-700'; const amt = parseFloat(tx.amount).toFixed(2); const runBal = parseFloat(tx.running_balance||0).toFixed(2); const dateStr = tx.date ? new Date(String(tx.date).includes('T') ? tx.date : tx.date+'T00:00:00').toLocaleDateString('en-AU',{day:'numeric',month:'short',year:'numeric'}) : '—'; // For equity buy/sell show security details as sub-line under description const isEquity = tx.type === 'buy' || tx.type === 'sell'; const equityDetail = isEquity && tx.security_id ? `
${esc(tx.security_id)} · ${parseFloat(tx.quantity||0).toLocaleString()} @ $${parseFloat(tx.price_per_unit||0).toFixed(4)}${tx.brokerage && parseFloat(tx.brokerage)>0 ? ` + $${parseFloat(tx.brokerage).toFixed(2)} brok.` : ''}
` : ''; const typeBadgeColor = tx.type === 'buy' ? 'bg-blue-50 text-blue-700' : tx.type === 'sell' ? 'bg-amber-50 text-amber-700' : 'bg-slate-100 text-slate-600'; return ``; }).join('')}
Date Type Description Amount Balance
${dateStr} ${TX_LABELS[tx.type]||tx.type}
${esc(tx.description||'—')}
${equityDetail}
${sign}$${amt} $${runBal}
`; } catch(e) { console.error('[Transactions]', e.message); body.innerHTML = '
Error loading transactions
'; } } async function deleteTransaction(clientId, txId, btn) { if(!confirm('Delete this transaction? This will reverse its effect on the cash balance.')) return; btn.disabled = true; try { const r = await apiFetch(`/api/clients/${clientId}/transactions/${txId}`, {method:'DELETE'}); const d = await r.json(); if(!r.ok) { showToast(d.error||'Delete failed',true); return; } showToast('Transaction deleted'); loadClientTransactions(clientId); } catch(e) { showToast(e.message,true); } finally { if(btn) btn.disabled=false; } } // ── Transaction modal state ───────────────────────────────────────────── let txBuySelectedSecurity = null; let txBuySearchTimer = null; function onTxTypeChange() { const type = document.getElementById('txType').value; const isEquity = type === 'buy' || type === 'sell'; document.getElementById('txAmountRow').classList.toggle('hidden', isEquity); document.getElementById('txBuyFields').classList.toggle('hidden', type !== 'buy'); document.getElementById('txSellFields').classList.toggle('hidden', type !== 'sell'); document.getElementById('txDividendFields').classList.toggle('hidden', type !== 'dividend'); // Show portfolio selector only for equity types document.getElementById('txPortfolioRow').classList.toggle('hidden', !isEquity); if (isEquity && currentClientId) loadTxPortfolioOptions(currentClientId); // Colour the submit button const btn = document.getElementById('addTxSubmitBtn'); if (type === 'buy') { btn.className = 'flex-1 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium py-2 rounded-lg transition-colors'; } else if (type === 'sell') { btn.className = 'flex-1 bg-amber-500 hover:bg-amber-600 text-white text-sm font-medium py-2 rounded-lg transition-colors'; } else { btn.className = 'flex-1 bg-emerald-700 hover:bg-emerald-800 text-white text-sm font-medium py-2 rounded-lg transition-colors'; } // Lazy-load sell holdings when switching to sell if (type === 'sell' && currentClientId) { loadSellHoldings(currentClientId); } // Show dividend preview if amount already entered if (type === 'dividend') updateDividendPreview(); } function updateDividendPreview() { const amt = parseFloat(document.getElementById('txAmount').value) || 0; const frankingPct = parseFloat(document.getElementById('txDividendFrankingPct').value) || 0; const preview = document.getElementById('txDividendPreview'); if (amt > 0) { const frankingCredits = Math.round(amt * (frankingPct / 100) * (30 / 70) * 100) / 100; const grossed = amt + frankingCredits; const fmtAU = v => v.toLocaleString('en-AU', { style: 'currency', currency: 'AUD' }); preview.textContent = frankingPct > 0 ? `Net: ${fmtAU(amt)} + Franking: ${fmtAU(frankingCredits)} = Grossed-up: ${fmtAU(grossed)}` : `Unfranked dividend: ${fmtAU(amt)} (no franking credits)`; preview.classList.remove('hidden'); } else { preview.classList.add('hidden'); } } let _txPortfoliosLoaded = false; async function loadTxPortfolioOptions(clientId) { if (_txPortfoliosLoaded) return; // already loaded for this modal open try { const r = await apiFetch(`/api/clients/${clientId}/portfolios`); if (!r.ok) return; const d = await r.json(); const portfolios = d.portfolios || d || []; const sel = document.getElementById('txPortfolioSelect'); sel.innerHTML = portfolios.length === 0 ? '' : '' + portfolios.map(p => `` ).join(''); // Auto-select if only one portfolio if (portfolios.length === 1) sel.value = portfolios[0].id; _txPortfoliosLoaded = true; } catch(e) { console.error('[TxPortfolioOptions]', e); } } // Security search for the Buy flow async function searchBuySecurity(q) { clearTimeout(txBuySearchTimer); const results = document.getElementById('txBuySearchResults'); if (!q || q.length < 1) { results.classList.add('hidden'); return; } txBuySearchTimer = setTimeout(async () => { try { const r = await apiFetch(`/api/securities/search?q=${encodeURIComponent(q)}`); if (!r.ok) return; const d = await r.json(); const items = d.results || []; if (!items.length) { results.innerHTML = '
No results found
'; results.classList.remove('hidden'); return; } results.innerHTML = items.map(s => `
${esc(s.ticker)} ${esc(s.name)} (${esc(s.exchange||'')})
` ).join(''); results.classList.remove('hidden'); } catch(e) { console.error('[SecuritySearch]', e); } }, 280); } function selectBuySecurity(sec) { txBuySelectedSecurity = sec; document.getElementById('txBuySearchInput').value = `${sec.ticker} — ${sec.name}`; document.getElementById('txBuySearchResults').classList.add('hidden'); const chip = document.getElementById('txBuySelectedSecurity'); chip.textContent = `${sec.ticker} · ${sec.exchange || 'equity'}`; chip.classList.remove('hidden'); // Auto-fill price from market data apiFetch(`/api/holdings/auto-fill?ticker=${encodeURIComponent(sec.ticker)}`) .then(r => r.ok ? r.json() : null) .then(d => { if (d && d.current_price) { document.getElementById('txBuyPrice').value = parseFloat(d.current_price).toFixed(4); updateBuyCostPreview(); } else { console.warn('[Buy] Auto-fill returned no price for', sec.ticker, d); } }) .catch(e => { console.error('[Buy] Auto-fill failed for', sec.ticker, e); }); updateBuyCostPreview(); } function updateBuyCostPreview() { const qty = parseFloat(document.getElementById('txBuyQty').value) || 0; const price = parseFloat(document.getElementById('txBuyPrice').value) || 0; const brok = parseFloat(document.getElementById('txBuyBrokerage').value) || 0; const el = document.getElementById('txBuyCostPreview'); if (qty > 0 && price > 0) { const total = (qty * price + brok); el.textContent = `Total cost: $${total.toFixed(2)} (${qty.toLocaleString()} units × $${price.toFixed(4)} + $${brok.toFixed(2)} brokerage)`; el.classList.remove('hidden'); } else { el.classList.add('hidden'); } } async function loadSellHoldings(clientId) { const sel = document.getElementById('txSellHoldingSelect'); sel.innerHTML = ''; try { const r = await apiFetch(`/api/clients/${clientId}/holdings`); if (!r.ok) { sel.innerHTML = ''; return; } const d = await r.json(); const holdings = d.holdings || []; if (!holdings.length) { sel.innerHTML = ''; return; } sel.innerHTML = '' + holdings.map(h => `` ).join(''); } catch(e) { sel.innerHTML = ''; } } function onSellHoldingChange() { const sel = document.getElementById('txSellHoldingSelect'); const info = document.getElementById('txSellHoldingInfo'); const opt = sel.options[sel.selectedIndex]; if (opt && opt.value) { const units = parseFloat(opt.dataset.units || 0); info.textContent = `Held: ${units.toLocaleString(undefined, {maximumFractionDigits:4})} units · ${opt.dataset.name}`; info.classList.remove('hidden'); document.getElementById('txSellQty').max = units; } else { info.classList.add('hidden'); } updateSellProceedsPreview(); } function updateSellProceedsPreview() { const qty = parseFloat(document.getElementById('txSellQty').value) || 0; const price = parseFloat(document.getElementById('txSellPrice').value) || 0; const brok = parseFloat(document.getElementById('txSellBrokerage').value) || 0; const el = document.getElementById('txSellProceedsPreview'); if (qty > 0 && price > 0) { const proceeds = (qty * price - brok); el.textContent = `Net proceeds: $${proceeds.toFixed(2)} (${qty.toLocaleString()} units × $${price.toFixed(4)} − $${brok.toFixed(2)} brokerage)`; el.classList.remove('hidden'); } else { el.classList.add('hidden'); } } function openAddTransactionModal() { _txPortfoliosLoaded = false; // reset so portfolios reload fresh const today = new Date().toISOString().split('T')[0]; document.getElementById('txDate').value = today; document.getElementById('txType').value = 'deposit'; document.getElementById('txPortfolioRow').classList.add('hidden'); document.getElementById('txPortfolioSelect').innerHTML = ''; document.getElementById('txAmount').value = ''; document.getElementById('txDescription').value = ''; document.getElementById('addTxError').classList.add('hidden'); // Reset equity state txBuySelectedSecurity = null; document.getElementById('txAmountRow').classList.remove('hidden'); document.getElementById('txBuyFields').classList.add('hidden'); document.getElementById('txSellFields').classList.add('hidden'); document.getElementById('txBuySearchInput').value = ''; document.getElementById('txBuySearchResults').classList.add('hidden'); document.getElementById('txBuySelectedSecurity').classList.add('hidden'); document.getElementById('txBuyQty').value = ''; document.getElementById('txBuyPrice').value = ''; document.getElementById('txBuyBrokerage').value = ''; document.getElementById('txBuyCostPreview').classList.add('hidden'); document.getElementById('txSellHoldingSelect').innerHTML = ''; document.getElementById('txSellHoldingInfo').classList.add('hidden'); document.getElementById('txSellQty').value = ''; document.getElementById('txSellPrice').value = ''; document.getElementById('txSellBrokerage').value = ''; document.getElementById('txSellProceedsPreview').classList.add('hidden'); // Reset dividend fields document.getElementById('txDividendFields').classList.add('hidden'); document.getElementById('txDividendSecurityId').value = ''; document.getElementById('txDividendFrankingPct').value = '100'; document.getElementById('txDividendPreview').classList.add('hidden'); const btn = document.getElementById('addTxSubmitBtn'); btn.className = 'flex-1 bg-emerald-700 hover:bg-emerald-800 text-white text-sm font-medium py-2 rounded-lg transition-colors'; document.getElementById('addTransactionModal').classList.remove('hidden'); } function openAddTransactionModalForPortfolio() { if (!currentPortfolioId) { showToast('Select a portfolio first', true); return; } _txPortfoliosLoaded = false; const today = new Date().toISOString().split('T')[0]; document.getElementById('txDate').value = today; document.getElementById('txType').value = 'deposit'; // Pre-select current portfolio and hide selector document.getElementById('txPortfolioSelect').innerHTML = ``; document.getElementById('txPortfolioRow').classList.add('hidden'); document.getElementById('txAmount').value = ''; document.getElementById('txDescription').value = ''; document.getElementById('addTxError').classList.add('hidden'); txBuySelectedSecurity = null; document.getElementById('txAmountRow').classList.remove('hidden'); document.getElementById('txBuyFields').classList.add('hidden'); document.getElementById('txSellFields').classList.add('hidden'); document.getElementById('txBuySearchInput').value = ''; document.getElementById('txBuySearchResults').classList.add('hidden'); document.getElementById('txBuySelectedSecurity').classList.add('hidden'); document.getElementById('txBuyQty').value = ''; document.getElementById('txBuyPrice').value = ''; document.getElementById('txBuyBrokerage').value = ''; document.getElementById('txBuyCostPreview').classList.add('hidden'); document.getElementById('txSellHoldingSelect').innerHTML = ''; document.getElementById('txSellHoldingInfo').classList.add('hidden'); document.getElementById('txSellQty').value = ''; document.getElementById('txSellPrice').value = ''; document.getElementById('txSellBrokerage').value = ''; document.getElementById('txSellProceedsPreview').classList.add('hidden'); // Reset dividend fields document.getElementById('txDividendFields').classList.add('hidden'); document.getElementById('txDividendSecurityId').value = ''; document.getElementById('txDividendFrankingPct').value = '100'; document.getElementById('txDividendPreview').classList.add('hidden'); const btn = document.getElementById('addTxSubmitBtn'); btn.className = 'flex-1 bg-emerald-700 hover:bg-emerald-800 text-white text-sm font-medium py-2 rounded-lg transition-colors'; document.getElementById('addTransactionModal').classList.remove('hidden'); } function closeAddTransactionModal() { document.getElementById('addTransactionModal').classList.add('hidden'); } async function submitAddTransaction() { const type = document.getElementById('txType').value; const date = document.getElementById('txDate').value; const description = document.getElementById('txDescription').value.trim(); const errEl = document.getElementById('addTxError'); errEl.classList.add('hidden'); if (!date) { errEl.textContent = 'Trade date is required'; errEl.classList.remove('hidden'); return; } const btn = document.getElementById('addTxSubmitBtn'); btn.disabled = true; try { let payload; if (type === 'buy') { const portfolioId = parseInt(document.getElementById('txPortfolioSelect').value) || null; if (!portfolioId) { errEl.textContent = 'Select a portfolio for this trade'; errEl.classList.remove('hidden'); btn.disabled=false; return; } if (!txBuySelectedSecurity) { errEl.textContent = 'Select a security to buy'; errEl.classList.remove('hidden'); btn.disabled = false; return; } const qty = parseFloat(document.getElementById('txBuyQty').value); const price = parseFloat(document.getElementById('txBuyPrice').value); const brok = parseFloat(document.getElementById('txBuyBrokerage').value) || 0; if (!qty || qty <= 0) { errEl.textContent = 'Enter a valid quantity'; errEl.classList.remove('hidden'); btn.disabled=false; return; } if (!price|| price<= 0) { errEl.textContent = 'Enter a valid price per unit'; errEl.classList.remove('hidden'); btn.disabled=false; return; } payload = { type, date, portfolio_id: portfolioId, description: description || undefined, security_ticker: txBuySelectedSecurity.ticker, security_name: txBuySelectedSecurity.name, security_exchange: txBuySelectedSecurity.exchange || 'ASX', quantity: qty, price_per_unit: price, brokerage: brok }; } else if (type === 'sell') { const holdingId = parseInt(document.getElementById('txSellHoldingSelect').value); if (!holdingId) { errEl.textContent = 'Select a holding to sell'; errEl.classList.remove('hidden'); btn.disabled=false; return; } const qty = parseFloat(document.getElementById('txSellQty').value); const price = parseFloat(document.getElementById('txSellPrice').value); const brok = parseFloat(document.getElementById('txSellBrokerage').value) || 0; if (!qty || qty <= 0) { errEl.textContent = 'Enter a valid quantity'; errEl.classList.remove('hidden'); btn.disabled=false; return; } if (!price|| price<= 0) { errEl.textContent = 'Enter a valid price per unit'; errEl.classList.remove('hidden'); btn.disabled=false; return; } payload = { type, date, description: description || undefined, holding_id: holdingId, quantity: qty, price_per_unit: price, brokerage: brok }; } else if (type === 'dividend') { const amount = parseFloat(document.getElementById('txAmount').value); if (!amount || amount <= 0) { errEl.textContent = 'Enter a valid amount'; errEl.classList.remove('hidden'); btn.disabled=false; return; } const frankingPct = parseFloat(document.getElementById('txDividendFrankingPct').value); const securityId = document.getElementById('txDividendSecurityId').value.trim(); payload = { type, amount, date, description: description || undefined, franking_percentage: isNaN(frankingPct) ? 100 : frankingPct, metadata: securityId ? { security_id: securityId } : undefined, }; } else { const amount = parseFloat(document.getElementById('txAmount').value); if (!amount || amount <= 0) { errEl.textContent = 'Enter a valid amount'; errEl.classList.remove('hidden'); btn.disabled=false; return; } payload = { type, amount, date, description: description || undefined }; } const r = await apiFetch(`/api/clients/${currentClientId}/transactions`, { method: 'POST', body: JSON.stringify(payload) }); const d = await r.json(); if (!r.ok) { errEl.textContent = d.error || 'Failed to add transaction'; errEl.classList.remove('hidden'); return; } closeAddTransactionModal(); showToast('Transaction added'); loadClientTransactions(currentClientId); // Refresh portfolio holdings if on portfolios tab (buy/sell changes holdings) if ((type === 'buy' || type === 'sell') && currentPortfolioId) { loadPortfolioHoldings(currentPortfolioId); } } catch(e) { errEl.textContent = e.message; errEl.classList.remove('hidden'); } finally { btn.disabled = false; } } // === Providers === async function loadProviders() { const emptyEl = document.getElementById('providersEmptyState'); const listEl = document.getElementById('providersListContainer'); try { const r = await apiFetch('/api/providers'); if(!r.ok) { emptyEl.classList.remove('hidden'); listEl.classList.add('hidden'); return; } const data = await r.json(); providersCache = data || []; const badge = document.getElementById('providersBadge'); if(providersCache.length>0){badge.textContent=providersCache.length;badge.classList.remove('hidden');} else{badge.classList.add('hidden');} if(!providersCache.length) { emptyEl.classList.remove('hidden'); listEl.classList.add('hidden'); return; } emptyEl.classList.add('hidden'); listEl.classList.remove('hidden'); listEl.innerHTML = providersCache.map(p => `
${esc(p.name)}
${p.code?esc(p.code)+' · ':''}${p.contact_person?esc(p.contact_person)+' · ':''}${parseInt(p.client_count)||0} client${parseInt(p.client_count)===1?'':'s'}
`).join(''); } catch(e) { console.error('[Providers]', e.message); emptyEl.classList.remove('hidden'); listEl.classList.add('hidden'); } } async function toggleProviderClients(providerId, headerEl) { const container = document.getElementById(`provider-clients-${providerId}`); const chevron = headerEl.querySelector('.provider-chevron'); if(!container.classList.contains('hidden')) { container.classList.add('hidden'); if(chevron) chevron.style.transform=''; return; } container.classList.remove('hidden'); if(chevron) chevron.style.transform='rotate(180deg)'; try { const r = await apiFetch(`/api/providers/${providerId}/clients`); if(!r.ok) { container.innerHTML='
Failed to load clients
'; return; } const clients = await r.json(); if(!clients.length) { container.innerHTML='
No clients under this provider yet
'; return; } container.innerHTML = '
'+clients.map(c=>`
${initials(c.name)}
${esc(c.name)}
${esc(c.email||'—')}
${formatCurrency(c.total_value||0)}
`).join('')+'
'; } catch(e) { container.innerHTML='
Error: '+esc(e.message)+'
'; } } function openAddProviderModal() { document.getElementById('addProviderModal').classList.remove('hidden'); document.getElementById('addProviderError').classList.add('hidden'); document.getElementById('newProviderName').value=''; document.getElementById('newProviderCode').value=''; document.getElementById('newProviderContact').value=''; document.getElementById('newProviderEmail').value=''; document.getElementById('newProviderPhone').value=''; } function closeAddProviderModal() { document.getElementById('addProviderModal').classList.add('hidden'); } async function submitAddProvider() { const name=document.getElementById('newProviderName').value.trim(); if(!name){document.getElementById('addProviderError').textContent='Provider name is required';document.getElementById('addProviderError').classList.remove('hidden');return;} try { document.getElementById('addProviderSubmitBtn').disabled=true; const body = { name, code: document.getElementById('newProviderCode').value.trim()||undefined, contact_person: document.getElementById('newProviderContact').value.trim()||undefined, email: document.getElementById('newProviderEmail').value.trim()||undefined, phone: document.getElementById('newProviderPhone').value.trim()||undefined }; const r = await apiFetch('/api/providers', {method:'POST', body:JSON.stringify(body)}); const d = await r.json(); if(!r.ok) throw new Error(d.message||d.error||'Failed to add provider'); closeAddProviderModal(); showToast(`${name} added`); providersCache = []; // clear cache to force reload loadProviders(); } catch(e) { document.getElementById('addProviderError').textContent=e.message; document.getElementById('addProviderError').classList.remove('hidden'); } finally { document.getElementById('addProviderSubmitBtn').disabled=false; } } // === Hierarchy === async function loadHierarchy() { const tree = document.getElementById('hierarchyTree'); try { const r = await apiFetch('/api/dealer-groups'); if(!r.ok){tree.innerHTML='
Failed to load hierarchy
';return;} const d = await r.json(); dealerGroupsCache = d.dealer_groups||d||[]; if(!dealerGroupsCache.length){tree.innerHTML='

No dealer groups yet. Create one to start building your hierarchy.

';return;} let html = ''; for(const dg of dealerGroupsCache){ html += `
${esc(dg.name)}
AFSL: ${esc(dg.afsl_number||'—')}
`; // Load practices for this DG try { const pr = await apiFetch(`/api/dealer-groups/${dg.id}/practices`); if(pr.ok){ const pd = await pr.json(); const practices = pd.practices||pd||[]; if(!practices.length){ html += '
No practices yet
'; } for(const p of practices){ html += `
${esc(p.name)}
${p.adviser_count||0} adviser${(p.adviser_count||0)===1?'':'s'}
`; } } } catch(e){} html += '
'; } tree.innerHTML = html; } catch(e) { tree.innerHTML='
Error loading hierarchy
'; console.error('[Hierarchy]', e.message); } } function openAddDGModal() { document.getElementById('addDGModal').classList.remove('hidden'); document.getElementById('addDGError').classList.add('hidden'); document.getElementById('newDGName').value=''; document.getElementById('newDGAfsl').value=''; } function closeAddDGModal() { document.getElementById('addDGModal').classList.add('hidden'); } async function submitAddDG() { const name=document.getElementById('newDGName').value.trim(); if(!name){document.getElementById('addDGError').textContent='Name is required';document.getElementById('addDGError').classList.remove('hidden');return;} try { const r = await apiFetch('/api/dealer-groups',{method:'POST',body:JSON.stringify({name, afsl_number:document.getElementById('newDGAfsl').value.trim()||undefined})}); const d = await r.json(); if(!r.ok) throw new Error(d.message||'Failed'); closeAddDGModal(); showToast('Dealer group created'); loadHierarchy(); } catch(e){document.getElementById('addDGError').textContent=e.message;document.getElementById('addDGError').classList.remove('hidden');} } let addPracticeDgId = null; function openAddPracticeModal(dgId) { addPracticeDgId=dgId||null; document.getElementById('addPracticeModal').classList.remove('hidden'); document.getElementById('addPracticeError').classList.add('hidden'); document.getElementById('newPracticeName').value=''; const sel=document.getElementById('newPracticeDG'); sel.innerHTML=''+dealerGroupsCache.map(dg=>``).join(''); } function closeAddPracticeModal() { document.getElementById('addPracticeModal').classList.add('hidden'); } async function submitAddPractice() { const name=document.getElementById('newPracticeName').value.trim(); const dgId=document.getElementById('newPracticeDG').value; if(!name||!dgId){document.getElementById('addPracticeError').textContent='Name and dealer group are required';document.getElementById('addPracticeError').classList.remove('hidden');return;} try { const r = await apiFetch('/api/practices',{method:'POST',body:JSON.stringify({name, dealer_group_id:parseInt(dgId)})}); const d = await r.json(); if(!r.ok) throw new Error(d.message||'Failed'); closeAddPracticeModal(); showToast('Practice created'); loadHierarchy(); } catch(e){document.getElementById('addPracticeError').textContent=e.message;document.getElementById('addPracticeError').classList.remove('hidden');} } // === KYC Workflow === async function loadClientKyc(clientId) { const cont = document.getElementById('kycContent'); try { const r = await apiFetch(`/api/clients/${clientId}/kyc`); if(!r.ok){cont.innerHTML='
No KYC data available
';return;} const d = await r.json(); const kyc = d.kyc||d; const steps = kyc.steps||[]; let html = `

KYC Status

${capitalize(kyc.status||'draft')}
`; if(steps.length){ html += '
'; steps.forEach((s,i)=>{ const done = s.completed||s.status==='completed'; html += `
${done?'✓':i+1}
${esc(s.name||s.step_name||`Step ${i+1}`)}
${esc(s.description||'')}
`; }); html += '
'; } else { html += '

No KYC steps configured yet.

'; } if(kyc.status==='draft'||!kyc.status){ html += `
`; } html += '
'; cont.innerHTML = html; } catch(e) { cont.innerHTML='
Error loading KYC
'; } } // === Tax Report === let _currentTaxReport = null; async function loadClientTax(clientId) { const cont = document.getElementById('clientTaxContent'); const fy = document.getElementById('taxFySelect') ? document.getElementById('taxFySelect').value : new Date().getFullYear(); cont.innerHTML = '
Loading tax report for FY ' + fy + '/'+ (parseInt(fy)+1).toString().slice(2) + '...
'; try { const r = await apiFetch('/api/clients/' + clientId + '/tax-report?fy=' + fy); if (!r.ok) { if (r.status === 403) { cont.innerHTML = '
Tax Engine Required
Upgrade to a plan with Tax Engine access to generate CGT reports.
'; } else { cont.innerHTML = '
Failed to load tax report (HTTP ' + r.status + ')
'; } return; } const d = await r.json(); _currentTaxReport = d; renderTaxReport(d, cont); } catch(e) { cont.innerHTML = '
Error: ' + esc(e.message) + '
'; } } function renderTaxReport(d, cont) { const fy = d.financial_year; const fyLabel = 'FY ' + fy + '/' + (parseInt(fy)+1).toString().slice(2); const primaryColor = d.branding && d.branding.primary_color ? d.branding.primary_color : '#1e40af'; // Summary stats row const realisedNet = d.realised.net_position || 0; const unrealisedGain = d.unrealised.total_unrealised_gain || 0; const estCGT = (d.realised.estimated_cgt || 0) + (d.unrealised.estimated_cgt || 0); const appCount = (d.approaching_threshold || []).length; let html = ''; // --- Summary Cards --- const totalFranking = (d.dividend_income && d.dividend_income.total_franking_credits) || 0; const totalGrossedDiv = (d.dividend_income && d.dividend_income.total_grossed_up) || 0; html += '
'; // Realised net gains const realisedClass = realisedNet >= 0 ? 'text-emerald-600' : 'text-red-600'; html += '
Realised Net Gain/Loss
' + (realisedNet >= 0 ? '+' : '') + formatDollar(realisedNet) + '
' + (d.realised.parcels||[]).length + ' parcel(s) disposed
'; // Unrealised gains const unrealClass = unrealisedGain >= 0 ? 'text-emerald-600' : 'text-red-600'; html += '
Unrealised G/L
' + (unrealisedGain >= 0 ? '+' : '') + formatDollar(unrealisedGain) + '
' + (d.unrealised.holding_count||0) + ' holdings
'; // Estimated CGT html += '
Est. CGT Payable
' + formatDollar(estCGT) + '
at ' + (d.client.marginal_tax_rate||32.5) + '% marginal rate
'; // Franking credits html += '
Franking Credits
' + formatDollar(totalFranking) + '
grossed-up: ' + formatDollar(totalGrossedDiv) + '
'; // Threshold alerts const alertColor = appCount > 0 ? 'bg-amber-50 border-amber-200' : 'bg-slate-50 border-slate-100'; html += '
Discount Alerts
' + appCount + '
' + (appCount > 0 ? 'within 30 days of eligibility' : 'none this FY') + '
'; html += '
'; // --- CGT Discount Breakdown --- const discount = d.realised.cgt_discount_amount || 0; if (d.realised.parcels && d.realised.parcels.length > 0) { html += '
'; html += '

CGT Discount Summary (' + fyLabel + ')

'; html += '
'; html += '
Gross Gain
' + formatDollar(d.realised.gross_gain || 0) + '
'; html += '
CGT Discount (50%)
-' + formatDollar(discount) + '
'; html += '
Net After Discount
' + formatDollar(d.realised.net_gain_after_discount || 0) + '
'; html += '
Est. CGT
' + formatDollar(d.realised.estimated_cgt || 0) + '
'; html += '
'; html += '
'; } // --- Dividend Income & Franking Credits --- const divIncome = d.dividend_income || {}; const divHoldings = divIncome.holdings || []; html += '
'; html += '

Dividend Income & Franking Credits (' + fyLabel + ')

'; if (divIncome.total_franking_credits > 0) { html += 'Franking offsets tax payable'; } html += '
'; if (!divHoldings.length) { html += '
No dividend income recorded for this financial year. Add dividend transactions to see franking credit calculations.
'; } else { html += '
'; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; divHoldings.forEach(function(dh) { html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; }); html += '
SecurityPaymentsNet DividendFranking CreditsGrossed-Up Total
' + esc(dh.security_label||'') + '' + (dh.payment_count||0) + '' + formatDollar(dh.net_dividends||0) + '+' + formatDollar(dh.franking_credits||0) + '' + formatDollar(dh.grossed_up||0) + '
'; // Totals footer html += '
'; html += '
Total Net Dividends: ' + formatDollar(divIncome.total_net_dividends||0) + '
'; html += '
Total Franking Credits: +' + formatDollar(divIncome.total_franking_credits||0) + '
'; html += '
Total Grossed-Up: ' + formatDollar(divIncome.total_grossed_up||0) + '
'; html += '
'; } html += '
'; // --- Income Summary --- const incomeSummary = d.income_summary || {}; if ((incomeSummary.total_assessable || 0) > 0) { html += '
'; html += '

Income Summary (' + fyLabel + ')

'; html += '
'; html += '
Interest Income
' + formatDollar(incomeSummary.interest||0) + '
'; html += '
Net Dividends
' + formatDollar(incomeSummary.dividends_net||0) + '
'; html += '
Franking Credits
+' + formatDollar(incomeSummary.franking_credits||0) + '
offsets tax payable
'; html += '
Total Assessable Income
' + formatDollar(incomeSummary.total_assessable||0) + '
'; html += '
'; html += '
'; } // --- Realised Gains Table --- const parcels = d.realised.parcels || []; html += '
'; html += '

Realised Gains/Losses (' + fyLabel + ')

'; if (!parcels.length) { html += '
No disposals in this financial year.
'; } else { html += '
'; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; parcels.forEach(function(p) { const gross = parseFloat(p.gross_gain || 0); const net = parseFloat(p.net_gain_after_discount || 0); const days = parseInt(p.holding_days || 0); const eligible = days >= 365; const cgt = Math.max(0, net) * (d.client.marginal_tax_rate / 100); const gainClass = gross >= 0 ? 'text-emerald-600' : 'text-red-600'; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; }); html += '
TickerAcquiredDisposedUnitsCost BaseProceedsGross G/L50% Disc?Net G/LEst. CGT
' + esc(p.ticker||'') + '' + (p.acquisition_date ? new Date(p.acquisition_date).toLocaleDateString('en-AU') : '---') + '' + (p.disposal_date ? new Date(p.disposal_date).toLocaleDateString('en-AU') : '---') + '' + parseFloat(p.quantity||0).toLocaleString('en-AU', {maximumFractionDigits:4}) + '' + formatDollar(parseFloat(p.cost_base||0)) + '' + formatDollar(parseFloat(p.disposal_price||0) * parseFloat(p.quantity||0)) + '' + (gross>=0?'+':'') + formatDollar(gross) + '' + (eligible?'Yes':'No') + '' + (net>=0?'+':'') + formatDollar(net) + '' + formatDollar(cgt) + '
'; } html += '
'; // --- Unrealised Gains Table --- const holdings = d.unrealised.holdings || []; html += '
'; html += '

Unrealised Gains/Losses

'; if (!holdings.length) { html += '
No open holdings found.
'; } else { html += '
'; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; holdings.forEach(function(h) { const gain = parseFloat(h.unrealised_gain || 0); const gainClass = gain >= 0 ? 'text-emerald-600' : 'text-red-600'; const discountedGain = h.discount_eligible ? gain * 0.5 : gain; const estCGTHolding = Math.max(0, discountedGain) * (d.client.marginal_tax_rate / 100); const eligible = h.discount_eligible === true; const days = h.holding_days; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; }); html += '
TickerPortfolioUnitsCost BaseMkt ValueUnrl. G/L50% Disc?Est. CGT
' + esc(h.ticker||'') + '
' + (h.acquisition_date ? new Date(h.acquisition_date).toLocaleDateString('en-AU') : '') + (days ? ' · ' + days + 'd held' : '') + '
' + esc(h.portfolio_name||'') + '' + h.quantity.toLocaleString('en-AU', {maximumFractionDigits:4}) + '' + formatDollar(parseFloat(h.cost_base||0)) + '' + formatDollar(parseFloat(h.current_value||0)) + '' + (gain>=0?'+':'') + formatDollar(gain) + ''; if (days !== null && days !== undefined) { html += '' + (eligible?'Eligible':'Held ' + days + 'd') + ''; } else { html += 'N/A'; } html += '' + formatDollar(estCGTHolding) + '
'; // Totals row html += '
'; html += '
Total Market Value: ' + formatDollar(d.unrealised.total_market_value||0) + '
'; html += '
Total Cost Base: ' + formatDollar(d.unrealised.total_cost_base||0) + '
'; html += '
Total Unrealised: ' + (unrealisedGain>=0?'+':'') + formatDollar(unrealisedGain) + '
'; html += '
'; } html += '
'; // --- Threshold Alerts --- const approaching = d.approaching_threshold || []; if (approaching.length > 0) { html += '
'; html += '
'; html += ''; html += '

Holdings Approaching 50% CGT Discount Eligibility

'; html += '
'; html += '
'; approaching.forEach(function(p) { html += '
'; html += '
' + esc(p.ticker||'') + ' ' + (p.acquisition_date ? new Date(p.acquisition_date).toLocaleDateString('en-AU') : '') + '
'; html += '
'; html += '
+' + p.days_until_discount + ' days until eligible
'; html += '
Unrealised: ' + (parseFloat(p.unrealised_gain||0)>=0?'+':'') + formatDollar(parseFloat(p.unrealised_gain||0)) + ' · Est. CGT: ' + formatDollar(p.estimated_future_cgt||0) + '
'; html += '
'; html += '
'; }); html += '
'; html += '
'; } // --- Portfolio Summaries --- const portfolios = d.portfolios || []; if (portfolios.length > 0) { html += '
'; html += '

Portfolio Summary

'; html += '
'; html += ''; html += ''; html += ''; html += ''; html += ''; portfolios.forEach(function(p) { const ug = parseFloat(p.total_unrealised_gain||0); const ugClass = ug >= 0 ? 'text-emerald-600' : 'text-red-600'; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; }); html += '
PortfolioHoldingsMarket ValueCost BaseUnrl. G/LEst. CGT
' + esc(p.name||'Portfolio') + '(' + esc(p.portfolio_type||'') + ')' + p.holding_count + '' + formatDollar(parseFloat(p.total_market_value||0)) + '' + formatDollar(parseFloat(p.total_cost_base||0)) + '' + (ug>=0?'+':'') + formatDollar(ug) + '' + formatDollar(p.estimated_cgt||0) + '
'; html += '
'; } // Disclaimer html += '
This report is generated for informational purposes only and does not constitute financial advice. Capital gains tax calculations are based on FIFO parcel matching and the 50% CGT discount for assets held 12+ months. Franking credits are calculated using the 30% corporate tax rate formula: credit = (net dividend × franking %) × (30/70). Grossed-up dividend income is the sum of cash dividends received plus attached franking credits. Please consult a licensed financial adviser and registered tax agent before making investment or tax decisions.
'; cont.innerHTML = html; } function printTaxReport() { if (!_currentTaxReport) { showToast('No tax report loaded', true); return; } window.open('/api/clients/' + currentClientId + '/tax-report/pdf?fy=' + (document.getElementById('taxFySelect') ? document.getElementById('taxFySelect').value : new Date().getFullYear()), '_blank'); } function downloadTaxPdf() { if (!currentClientId) { showToast('No client selected', true); return; } const fy = document.getElementById('taxFySelect') ? document.getElementById('taxFySelect').value : new Date().getFullYear(); window.open('/api/clients/' + currentClientId + '/tax-report/pdf?fy=' + fy, '_blank'); } async function submitKycForReview(clientId) { try { const r = await apiFetch(`/api/clients/${clientId}/kyc/submit`,{method:'POST'}); if(!r.ok){const d=await r.json();throw new Error(d.message||'Failed');} showToast('KYC submitted for review'); loadClientKyc(clientId); } catch(e) { showToast(e.message,true); } } async function loadPendingKyc() { const list = document.getElementById('pendingKycList'); try { const r = await apiFetch('/api/kyc/pending'); if(!r.ok){list.innerHTML='
Failed to load
';return;} const d = await r.json(); const items = d.pending||d.submissions||d||[]; document.getElementById('kycPendingCount').textContent = items.filter(i=>i.status==='pending'||i.status==='submitted').length; document.getElementById('kycApprovedCount').textContent = items.filter(i=>i.status==='approved').length; document.getElementById('kycTotalCount').textContent = items.length; const badge = document.getElementById('kycBadge'); const pc = items.filter(i=>i.status==='pending'||i.status==='submitted').length; if(pc>0){badge.textContent=pc;badge.classList.remove('hidden');}else{badge.classList.add('hidden');} const pending = items.filter(i=>i.status==='pending'||i.status==='submitted'); if(!pending.length){list.innerHTML='
No pending reviews 🎉
';return;} list.innerHTML = pending.map(i=>`
${initials(i.client_name||'?')}
${esc(i.client_name||'Unknown')}
Submitted ${i.submitted_at?new Date(i.submitted_at).toLocaleDateString():'recently'}
`).join(''); } catch(e) { list.innerHTML='
Error loading KYC data
'; console.error('[KYC]', e.message); } } async function approveKyc(clientId) { try { const r=await apiFetch(`/api/clients/${clientId}/kyc/approve`,{method:'POST'}); if(!r.ok){const d=await r.json();throw new Error(d.message||'Failed');} showToast('KYC approved'); loadPendingKyc(); } catch(e){showToast(e.message,true);} } async function rejectKyc(clientId) { const reason = prompt('Rejection reason (optional):'); try { const r=await apiFetch(`/api/clients/${clientId}/kyc/reject`,{method:'POST',body:JSON.stringify({reason:reason||undefined})}); if(!r.ok){const d=await r.json();throw new Error(d.message||'Failed');} showToast('KYC rejected'); loadPendingKyc(); } catch(e){showToast(e.message,true);} } // === Portfolios === async function loadPortfolios() { try { const r = await apiFetch('/api/portfolios'); if(!r.ok) return; const d = await r.json(); const portfolios = d.portfolios||d||[]; const empty = document.getElementById('portfoliosEmptyState'); const body = document.getElementById('portfoliosListBody'); // Update sidebar badge const pBadge = document.getElementById('portfoliosBadge'); if(pBadge){pBadge.textContent=portfolios.length;pBadge.classList.toggle('hidden',portfolios.length===0);} if(!portfolios.length){empty.classList.remove('hidden');body.classList.add('hidden');return;} empty.classList.add('hidden'); body.classList.remove('hidden'); body.innerHTML = portfolios.map(p=>`
${initials(p.name||'P')}
${esc(p.name||'Portfolio')}
${esc(p.client_name||'—')} · ${p.holding_count||0} holdings
${formatCurrency(p.total_value||p.market_value||0)}
${esc(p.account_type||'Investment')}
`).join(''); portfoliosLoaded = true; } catch(e) { console.error('[Portfolios]', e.message); } } async function showPortfolioDetail(portfolioId, name, originClientId) { currentPortfolioId = portfolioId; portfolioOriginClientId = originClientId || null; showPanel('portfolio-detail',null); document.getElementById('portfolioDetailName').textContent = name||'Portfolio'; // Show account breadcrumb if navigated from account detail if (originClientId) { const account = clientsCache.find(c => c.id === originClientId); const accountName = account ? account.name : 'Account'; document.getElementById('portfolioDetailMeta').textContent = accountName + ' · Portfolio'; } else { document.getElementById('portfolioDetailMeta').textContent = `Portfolio #${portfolioId}`; } // Reset to Summary tab, clear tx cache _portfolioTxCache = []; switchPortfolioTab('summary'); // Load holdings (populates metrics strip + SMSF compliance) await loadPortfolioHoldings(portfolioId); // Load charts in parallel loadPortfolioPerformance(portfolioId); loadPortfolioAllocation(portfolioId); } async function loadPortfolioHoldings(portfolioId) { const table = document.getElementById('portfolioHoldingsTable'); try { const r = await apiFetch(`/api/portfolios/${portfolioId}/holdings`); if(!r.ok){table.innerHTML='
Failed to load holdings
';return;} const d = await r.json(); const holdings = d.holdings||d||[]; let totalMV=0,totalCB=0; holdings.forEach(h=>{totalMV+=parseFloat(h.market_value||0);totalCB+=parseFloat(h.cost_base||h.purchase_price*h.units||0);}); const pnl = totalMV-totalCB; document.getElementById('pdMarketVal').textContent = formatDollar(totalMV); document.getElementById('pdCostBase').textContent = formatDollar(totalCB); document.getElementById('pdPnl').textContent = (pnl>=0?'+':'')+formatDollar(pnl); document.getElementById('pdPnl').className = `text-xl font-bold ${pnl>=0?'text-emerald-600':'text-red-600'}`; document.getElementById('pdHoldingCount').textContent = holdings.length; // Show SMSF compliance section only for SMSF accounts (backend omits compliance for non-SMSF) const complianceSection = document.getElementById('portfolioComplianceSection'); const compliance = d.compliance; if (compliance) { if (complianceSection) complianceSection.classList.remove('hidden'); renderComplianceResults(compliance); } else { if (complianceSection) complianceSection.classList.add('hidden'); } if(!holdings.length){table.innerHTML='
No holdings yet — import CSV or add manually
';return;} table.innerHTML = `${holdings.map(h=>{ const mv=parseFloat(h.market_value||0);const cb=parseFloat(h.cost_base||h.purchase_price*h.units||0);const pl=mv-cb;const w=totalMV>0?(mv/totalMV*100):0; return ``; }).join('')}
Ticker Category Units Buy Price Market Val P&L Weight
${esc(h.ticker||h.security_code||'—')}${h.sector?`${esc(h.sector)}`:''}${parseFloat(h.units||0).toLocaleString()}${formatDollar(h.purchase_price||0)}${formatDollar(mv)}${(pl>=0?'+':'')+formatDollar(pl)}${w.toFixed(1)}%
`; } catch(e) { table.innerHTML='
Error loading holdings
'; } } // Render SMSF compliance checks in portfolio detail panel function renderComplianceResults(c) { const dots = { pass: { bg: 'bg-emerald-500', label: 'text-emerald-600' }, fail: { bg: 'bg-red-500', label: 'text-red-600' }, warning: { bg: 'bg-amber-500', label: 'text-amber-600' } }; const rules = [ { key: 'concentration', label: 'Concentration', unit: '%' }, { key: 'liquidity', label: 'Liquidity', unit: '%' }, { key: 'diversification', label: 'Diversification', unit: '' }, { key: 'allocation_drift', label: 'Allocation Drift', unit: '%' } ]; rules.forEach(r => { const check = c[r.key]; if (!check) return; const dot = document.getElementById(`cc-${r.key}-dot`); const status = document.getElementById(`cc-${r.key}-status`); const card = document.getElementById(`cc-${r.key}`); if (dot) { dot.className = `w-2.5 h-2.5 rounded-full ${dots[check.status]?.bg || 'bg-slate-300'}`; } if (status) { const extra = check.liquidity_pct !== undefined ? ` (${check.liquidity_pct}${r.unit})` : check.distinct_count !== undefined ? ` (${check.distinct_count})` : check.flagged && check.flagged[0] ? ` (${check.flagged[0].weight_pct}%)` : ''; status.textContent = check.message; status.className = `text-sm font-semibold ${dots[check.status]?.label || 'text-slate-500'}`; } }); // Overall status icon const iconEl = document.getElementById('complianceStatusIcon'); if (iconEl) { const overall = c.concentration.status === 'pass' && c.liquidity.status === 'pass' && c.diversification.status === 'pass' && c.allocation_drift.status === 'pass' ? 'pass' : 'fail'; const dotConfig = dots[overall] || { bg: 'bg-slate-300' }; iconEl.className = `w-7 h-7 rounded-full ${dotConfig.bg} flex items-center justify-center`; iconEl.innerHTML = overall === 'pass' ? '' : ''; } // Populate expandable detail section with flagged items const detail = document.getElementById('complianceDetail'); if (detail) { const failingChecks = rules.filter(r => c[r.key] && c[r.key].status === 'fail'); if (failingChecks.length === 0) { detail.innerHTML = '
All checks passing. No compliance issues detected.
'; } else { detail.innerHTML = failingChecks.map(r => { const check = c[r.key]; if (r.key === 'concentration' && check.flagged && check.flagged.length > 0) { return `
${check.rule}
${check.message}
${check.flagged.map(f => `
${f.weight_pct}%${esc(f.ticker || 'Unknown')}${esc(f.company_name || '')}
`).join('')}
`; } if (r.key === 'liquidity') { return `
${check.rule}
${check.message}. Required: 10% minimum.
`; } if (r.key === 'diversification') { return `
${check.rule}
${check.message}. Recommended: at least 5 distinct holdings.
`; } if (r.key === 'allocation_drift' && check.flagged && check.flagged.length > 0) { return `
${check.rule}
${check.message}
${check.flagged.map(f => `
${f.weight_pct}%${esc(f.sector)}
`).join('')}
`; } return `
${check.rule}
${check.message}
`; }).join(''); } } } let complianceDetailOpen = false; function toggleComplianceDetail() { complianceDetailOpen = !complianceDetailOpen; const detail = document.getElementById('complianceDetail'); const btn = document.getElementById('toggleComplianceDetail'); if (!detail) return; detail.classList.toggle('hidden', !complianceDetailOpen); if (btn) btn.textContent = complianceDetailOpen ? 'Hide details' : 'Show details'; } async function loadPortfolioPerformance(portfolioId) { try { const r = await apiFetch(`/api/portfolios/${portfolioId}/performance`); if(!r.ok) return; const d = await r.json(); const perf = d.performance||d.data||d||[]; if(perfChartInstance){perfChartInstance.destroy();perfChartInstance=null;} const canvas = document.getElementById('perfChart'); if(!perf.length||!Array.isArray(perf)){canvas.parentElement.querySelector('h3').insertAdjacentHTML('afterend','
No performance data yet
');return;} perfChartInstance = new Chart(canvas, { type:'line', data:{labels:perf.map(p=>p.date||p.period||''), datasets:[{label:'Portfolio Value',data:perf.map(p=>p.value||p.total_value||0),borderColor:'#1e4a7a',backgroundColor:'rgba(30,74,122,0.08)',fill:true,tension:0.3,pointRadius:2}]}, options:{responsive:true,plugins:{legend:{display:false}},scales:{y:{ticks:{callback:v=>formatCurrency(v)}},x:{display:true,ticks:{maxTicksToShow:6}}}} }); } catch(e) { console.error('[Perf Chart]', e.message); } } async function loadPortfolioAllocation(portfolioId) { try { const r = await apiFetch(`/api/portfolios/${portfolioId}/allocation`); if(!r.ok) return; const d = await r.json(); const bySector = d.by_sector||[]; if(allocChartInstance){allocChartInstance.destroy();allocChartInstance=null;} const canvas = document.getElementById('allocChart'); const breakdown = document.getElementById('allocSectorBreakdown'); if(!bySector.length){ canvas.style.display='none'; if(breakdown) breakdown.innerHTML='
No allocation data
'; return; } canvas.style.display=''; // Render doughnut chart using sector data const chartColors = bySector.map(s=>s.color||'#94a3b8'); allocChartInstance = new Chart(canvas, { type:'doughnut', data:{labels:bySector.map(s=>s.sector||'Other'),datasets:[{data:bySector.map(s=>s.value||0),backgroundColor:chartColors,borderWidth:2,borderColor:'#fff'}]}, options:{responsive:true,plugins:{legend:{display:false}}} }); // Render sector breakdown rows below chart if(breakdown){ breakdown.innerHTML = bySector.map(s=>`
${esc(s.sector||'Other')} ${s.pct||0}% ${formatCurrency(s.value||0)}
`).join(''); } } catch(e) { console.error('[Alloc Chart]', e.message); } } // === Portfolio Tabs === let _portfolioTxCache = []; // raw transactions for current portfolio (for filter) function switchPortfolioTab(tab) { ['summary','holdings','transactions'].forEach(t => { document.getElementById(`ptab-${t}`).classList.toggle('active', t === tab); document.getElementById(`ptab-${t}`).classList.toggle('hidden', t !== tab); const btn = document.getElementById(`ptab-btn-${t}`); if (btn) btn.classList.toggle('active', t === tab); }); if (tab === 'transactions' && currentPortfolioId) loadPortfolioTransactions(currentPortfolioId); } async function loadPortfolioTransactions(portfolioId) { const table = document.getElementById('portfolioTransactionsTable'); table.innerHTML = '
Loading…
'; try { const r = await apiFetch(`/api/portfolios/${portfolioId}/transactions`); if (!r.ok) { table.innerHTML = '
Failed to load transactions
'; return; } const rows = await r.json(); _portfolioTxCache = Array.isArray(rows) ? rows : (rows.transactions || []); renderPortfolioTransactions(_portfolioTxCache); } catch(e) { table.innerHTML = '
Error loading transactions
'; } } function filterPortfolioTransactions() { const typeFilter = (document.getElementById('ptxFilterType').value || '').toLowerCase(); const fromFilter = document.getElementById('ptxFilterFrom').value; const toFilter = document.getElementById('ptxFilterTo').value; const assetFilter = (document.getElementById('ptxFilterAsset').value || '').trim().toLowerCase(); let filtered = _portfolioTxCache; if (typeFilter) filtered = filtered.filter(t => (t.transaction_type||'').toLowerCase() === typeFilter); if (fromFilter) filtered = filtered.filter(t => (t.transaction_date||'') >= fromFilter); if (toFilter) filtered = filtered.filter(t => (t.transaction_date||'') <= toFilter); if (assetFilter) filtered = filtered.filter(t => (t.ticker||'').toLowerCase().includes(assetFilter)); renderPortfolioTransactions(filtered); } function renderPortfolioTransactions(txns) { const table = document.getElementById('portfolioTransactionsTable'); if (!txns.length) { table.innerHTML = '
No transactions found
'; return; } const typeBadge = t => { if (t === 'buy') return 'Buy'; if (t === 'sell') return 'Sell'; return `${esc(t)}`; }; table.innerHTML = ` ${txns.map(t => { const units = parseFloat(t.units||0); const price = parseFloat(t.price_per_unit||0); const brok = parseFloat(t.brokerage||0); const total = units * price + (t.transaction_type === 'buy' ? brok : -brok); const dateStr = t.transaction_date ? t.transaction_date.toString().substring(0,10) : '—'; return ``; }).join('')}
Date Type Asset Units Price Brokerage Total
${dateStr} ${typeBadge(t.transaction_type)} ${esc(t.ticker||'—')} ${units.toLocaleString(undefined,{maximumFractionDigits:4})} ${formatDollar(price)} ${brok > 0 ? formatDollar(brok) : '—'} ${formatDollar(Math.abs(total))}
`; } // === Tax Engine === async function populateTaxPortfolioSelect() { const sel = document.getElementById('taxPortfolioSelect'); try { const r = await apiFetch('/api/portfolios'); if(!r.ok) return; const d = await r.json(); const portfolios = d.portfolios||d||[]; sel.innerHTML = '' + portfolios.map(p=>``).join(''); } catch(e) { console.error('[Tax Portfolios]', e.message); } } async function loadTaxSummary() { const pid = document.getElementById('taxPortfolioSelect').value; const fy = document.getElementById('taxYearSelect').value; const summarySection = document.getElementById('taxSummarySection'); const emptyState = document.getElementById('taxEmptyState'); if(!pid){summarySection.classList.add('hidden');emptyState.classList.remove('hidden');return;} emptyState.classList.add('hidden'); summarySection.classList.remove('hidden'); try { const r = await apiFetch(`/api/portfolios/${pid}/tax-summary?fy=${fy}`); if(!r.ok){showToast('Failed to load tax summary',true);return;} const d = await r.json(); const s = d.summary||d; document.getElementById('taxNetGains').textContent = formatDollar(s.net_capital_gains||s.net_gains||0); document.getElementById('taxDiscount').textContent = formatDollar(s.cgt_discount||s.discount_amount||0); document.getElementById('taxFranking').textContent = formatDollar(s.franking_credits||0); document.getElementById('taxLiability').textContent = formatDollar(s.estimated_tax||s.tax_liability||0); // CGT Events table const events = s.events||s.cgt_events||[]; const evTable = document.getElementById('cgtEventsTable'); if(!events.length){evTable.innerHTML='
No CGT events for this period
';return;} evTable.innerHTML = `${events.map(e=>{ const gl=parseFloat(e.gain||e.capital_gain||0); return ``; }).join('')}
Security Units Proceeds Cost Base Gain/Loss Discount
${esc(e.ticker||e.security||'')}${e.units||0}${formatDollar(e.proceeds||e.sale_amount||0)}${formatDollar(e.cost_base||0)}${(gl>=0?'+':'')+formatDollar(gl)}${e.discount_eligible?'50%':'—'}
`; } catch(e) { showToast('Tax calculation error',true); console.error('[Tax]', e.message); } } async function runScenario() { const pid = document.getElementById('taxPortfolioSelect').value; if(!pid){showToast('Select a portfolio first',true);return;} const ticker = document.getElementById('scenarioTicker').value.trim().toUpperCase(); const units = parseFloat(document.getElementById('scenarioUnits').value); const price = parseFloat(document.getElementById('scenarioPrice').value); if(!ticker||!units||!price){showToast('Fill in ticker, units, and price',true);return;} const results = document.getElementById('scenarioResults'); results.classList.remove('hidden'); results.innerHTML = '
Calculating…
'; try { const r = await apiFetch(`/api/portfolios/${pid}/tax-scenarios`,{method:'POST',body:JSON.stringify({scenarios:[{ticker,units,sell_price:price}]})}); if(!r.ok){const d=await r.json();throw new Error(d.message||'Failed');} const d = await r.json(); const sc = (d.scenarios||d.results||[d])[0]||d; const gl = parseFloat(sc.capital_gain||sc.gain||0); results.innerHTML = `
Proceeds
${formatDollar(sc.proceeds||units*price)}
Cost Base
${formatDollar(sc.cost_base||0)}
Capital Gain
${(gl>=0?'+':'')+formatDollar(gl)}
After CGT Discount
${formatDollar(sc.after_discount||sc.taxable_gain||gl*0.5)}
${sc.discount_eligible?'
✓ Held >12 months — 50% CGT discount applied
':'
⚠ Held <12 months — no CGT discount
'}`; } catch(e) { results.innerHTML=`
${esc(e.message)}
`; } } async function loadHarvestingOpportunities() { const pid = document.getElementById('taxPortfolioSelect').value; if(!pid){showToast('Select a portfolio first',true);return;} const list = document.getElementById('harvestingList'); list.innerHTML = '
Scanning…
'; try { const r = await apiFetch(`/api/portfolios/${pid}/tax-loss-harvest`); if(!r.ok){list.innerHTML='
No opportunities found
';return;} const d = await r.json(); const opps = d.opportunities||d||[]; if(!opps.length){list.innerHTML='
No tax-loss harvesting opportunities found — your portfolio is doing well! 🎉
';return;} list.innerHTML = opps.map(o=>`
${esc(o.ticker||o.security||'')}
${o.units||0} units · Cost: ${formatDollar(o.cost_base||0)}
Loss: ${formatDollar(Math.abs(o.unrealised_loss||o.loss||0))}
Tax saving: ~${formatDollar(o.tax_saving||0)}
`).join(''); } catch(e) { list.innerHTML='
Error scanning opportunities
'; } } // === CSV Import === function openCsvImportModal() { document.getElementById('csvImportModal').classList.remove('hidden'); document.getElementById('csvImportError').classList.add('hidden'); document.getElementById('csvImportSuccess').classList.add('hidden'); document.getElementById('csvImportText').value=''; } function closeCsvImportModal() { document.getElementById('csvImportModal').classList.add('hidden'); } async function submitCsvImport() { const csv = document.getElementById('csvImportText').value.trim(); if(!csv){document.getElementById('csvImportError').textContent='Paste CSV data';document.getElementById('csvImportError').classList.remove('hidden');return;} if(!currentPortfolioId){showToast('No portfolio selected',true);return;} try { document.getElementById('csvImportBtn').disabled=true; const rows = csv.split('\n').filter(r=>r.trim()).map(r=>{ const [ticker,units,purchase_price,purchase_date]=r.split(',').map(s=>s.trim()); return {ticker,units:parseFloat(units),purchase_price:parseFloat(purchase_price),purchase_date}; }); const r = await apiFetch(`/api/portfolios/${currentPortfolioId}/holdings/import`,{method:'POST',body:JSON.stringify({holdings:rows})}); const d = await r.json(); if(!r.ok) throw new Error(d.message||'Import failed'); document.getElementById('csvImportSuccess').textContent=`Imported ${d.imported||rows.length} holdings`; document.getElementById('csvImportSuccess').classList.remove('hidden'); showToast('Holdings imported'); setTimeout(()=>{closeCsvImportModal();loadPortfolioHoldings(currentPortfolioId);},1500); } catch(e){document.getElementById('csvImportError').textContent=e.message;document.getElementById('csvImportError').classList.remove('hidden');} finally{document.getElementById('csvImportBtn').disabled=false;} } // === Settings === async function saveFirmSettings() { try { const body = { name:document.getElementById('settingFirmName').value.trim(), afsl_number:document.getElementById('settingAfsl').value.trim(), abn:document.getElementById('settingAbn').value.trim(), contact_email:document.getElementById('settingEmail').value.trim() }; const r = await apiFetch('/api/tenant/settings',{method:'PUT',body:JSON.stringify(body)}); if(!r.ok){const d=await r.json();throw new Error(d.message||'Failed');} showToast('Settings saved'); loadBranding(); } catch(e){showToast(e.message,true);} } function handleSettingsLogo(ev) { const file = ev.target.files[0]; if(!file) return; const reader = new FileReader(); reader.onload = async function(e) { document.getElementById('settingsLogoPreview').src = e.target.result; document.getElementById('settingsLogoPreview').classList.remove('hidden'); document.getElementById('settingsLogoPlaceholder').classList.add('hidden'); try { const r = await fetch('/api/tenant/logo', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ logo_url: e.target.result }) }); if(!r.ok) throw new Error('Upload failed'); showToast('Logo uploaded'); loadBranding(); } catch(e){showToast(e.message,true);} }; reader.readAsDataURL(file); } async function saveBranding() { try { const gv = id => { const el=document.getElementById(id); return el?el.value||undefined:undefined; }; const body = { primary_color: gv('settingsBrandColor'), secondary_color: gv('settingsSecondaryColor'), accent_color: gv('settingsAccentColor'), app_name: gv('settingsAppName'), font_family: gv('settingsFontFamily'), custom_css: gv('settingsCustomCss'), }; // Remove undefined keys Object.keys(body).forEach(k => body[k] === undefined && delete body[k]); const r = await apiFetch('/api/tenant/branding',{method:'PUT',body:JSON.stringify(body)}); if(!r.ok){const d=await r.json();throw new Error(d.message||'Failed');} showToast('Branding saved'); loadBranding(); } catch(e){showToast(e.message,true);} } async function loadBillingStatus() { try { const r = await apiFetch('/api/billing/status'); if (!r.ok) return; const d = await r.json(); const sub = d.subscription || {}; const tier = sub.tier || d.tier || 'free'; const status = sub.status || d.subscription_status || 'trial'; const trialDays = sub.trial_days_remaining != null ? sub.trial_days_remaining : (d.trial_days_remaining || 0); // Update sidebar tier label const tierLabels = { free:'Free Trial', starter:'Starter', professional:'Professional', enterprise:'Enterprise' }; document.getElementById('sidebarTier').textContent = tierLabels[tier] || tier; // Show trial pill in header const trialPill = document.getElementById('trialPill'); if (status === 'trial' && trialDays > 0) { trialPill.classList.remove('hidden'); trialPill.querySelector('span').textContent = `${trialDays} day${trialDays===1?'':'s'} left`; } else if (status === 'trial' && trialDays <= 0) { trialPill.classList.remove('hidden'); trialPill.querySelector('span').className = 'text-xs font-medium bg-red-50 text-red-700 border border-red-200 px-2.5 py-1 rounded-full'; trialPill.querySelector('span').textContent = 'Trial expired'; } // Show billing badge when on free/trial or past_due const badge = document.getElementById('billingBadge'); if (tier === 'free' || status === 'trial' || status === 'past_due') { badge.textContent = status === 'past_due' ? '!' : '↑'; badge.classList.remove('hidden'); } } catch(e) { /* billing status optional — don't break dashboard */ } } async function init() { try { const meRes = await apiFetch('/api/auth/me'); if(!meRes.ok){logout();return;} const meData = await meRes.json(); currentUser = meData.user; const ini = (currentUser.name||currentUser.email||'?').split(' ').map(w=>w[0]).join('').toUpperCase().slice(0,2); document.getElementById('userAvatar').textContent = ini; document.getElementById('userName').textContent = currentUser.name||currentUser.email; document.getElementById('userRole').textContent = currentUser.role||'user'; const isAdmin = currentUser.role==='admin'; const isAdviser = currentUser.role==='adviser'; // If a client somehow lands on dashboard, redirect them to their portal if (currentUser.role === 'client') { window.location.href = '/client-portal'; return; } // Role-based nav visibility if (!isAdmin) { // Non-admins: hide settings, advisers, and billing nav document.querySelector('[data-panel=settings]')?.classList.add('hidden'); document.querySelector('[data-panel=advisers]')?.classList.add('hidden'); document.getElementById('billingNavLink')?.classList.add('hidden'); } if (!isAdmin && !isAdviser) { // Restrict user role further — hide providers and import document.querySelector('[data-panel=providers]')?.classList.add('hidden'); document.querySelector('a[href="/import"]')?.classList.add('hidden'); } await loadBranding(); await loadStats(); loadProviders(); // non-blocking — populates providersCache for Add Account dropdown + sidebar badge loadBillingStatus(); // non-blocking — updates sidebar tier label + trial pill if(isAdmin) await loadAdvisers(); } catch(err) { console.error('[Dashboard] Init error:', err.message); } // Handle hash-based navigation (e.g. /dashboard#providers from billing page) const hash = window.location.hash.replace('#',''); if(hash) { const valid = ['overview','providers','clients','portfolios','tax','compliance','advisers','settings']; if(valid.includes(hash)) showPanel(hash,null); } } init();