2026-10-30 12:28:48 +01:00
|
|
|
|
// Generated, do not edit. sha256:ae28972ed451b65f2e773094a56cc2c886f8928f30dfae76134014f65aa7c8e8
|
2026-10-29 11:57:29 +01:00
|
|
|
|
import { listAccounts } from '../data/accounts.js';
|
|
|
|
|
|
import { listAssets } from '../data/assets.js';
|
|
|
|
|
|
import { accountHoldings } from '../data/positions.js';
|
|
|
|
|
|
import { listOpenValuationDiffs, listValuations } from '../data/valuations.js';
|
|
|
|
|
|
import { mountSparkline } from '../ui/sparkline.js';
|
|
|
|
|
|
import { activityItems, heartbeat } from '../ui/activity.js';
|
|
|
|
|
|
import { setRowFocus } from '../ui/row-focus.js';
|
|
|
|
|
|
import { listTrades, listTransfers } from '../data/events.js';
|
|
|
|
|
|
import { listAllSchedules, listInterest } from '../data/interest-schedules.js';
|
|
|
|
|
|
import { scheduleForecast } from '../data/interest-credits.js';
|
|
|
|
|
|
import { latestPricePoint } from '../data/price-history.js';
|
|
|
|
|
|
import { basePriceOf } from '../price/providers.js';
|
|
|
|
|
|
import { loadFiat } from '../ui/fiat.js';
|
|
|
|
|
|
import { t } from '../i18n/index.js';
|
|
|
|
|
|
import { assetLabelHtml, byKindAndLabel } from '../ui/asset-label.js';
|
|
|
|
|
|
import { fmtBase, fmtUnit, fromSubunit, isBaseAsset, isMetal, val } from '../ui/format.js';
|
|
|
|
|
|
import { esc } from '../ui/escape.js';
|
|
|
|
|
|
import { emptyStateHtml } from '../ui/empty-state.js';
|
|
|
|
|
|
import { create as createTabs } from '../ui/tabs.js';
|
|
|
|
|
|
import { create as createFilter, resolveSelection } from '../ui/filter.js';
|
|
|
|
|
|
import { getPref, setPref } from '../data/preferences.js';
|
|
|
|
|
|
import { setPageHint } from '../ui/page-hint.js';
|
|
|
|
|
|
import * as dialog from '../ui/dialog.js';
|
|
|
|
|
|
import * as loading from '../ui/loading.js';
|
|
|
|
|
|
import { localDate, localDateNl, localTime } from '../../shared/time.js';
|
|
|
|
|
|
import { ACCOUNT_ASSET_KINDS, CONFIG, ENUMS, EVENTS_PAGE, TARGETS, balanceCaptureOn, targetHash } from '../../shared/config.js';
|
|
|
|
|
|
import * as valuations from './valuations.js';
|
|
|
|
|
|
import { recordBalance } from './valuations.js';
|
|
|
|
|
|
import * as trades from './trades.js';
|
|
|
|
|
|
import * as transfers from './transfers.js';
|
|
|
|
|
|
import * as interest from './interest.js';
|
|
|
|
|
|
import * as reservations from './reservations.js';
|
|
|
|
|
|
import { listReservations } from '../data/reservations.js';
|
|
|
|
|
|
import { reservationAccount } from '../actions/reservation-book.js';
|
|
|
|
|
|
|
|
|
|
|
|
const TABS = EVENTS_PAGE.TABS;
|
|
|
|
|
|
const DETAIL = [
|
|
|
|
|
|
[TABS.VALUATIONS, valuations],
|
|
|
|
|
|
[TABS.TRADES, trades],
|
|
|
|
|
|
[TABS.TRANSFERS, transfers],
|
|
|
|
|
|
[TABS.INTEREST, interest],
|
|
|
|
|
|
[TABS.RESERVATIONS, reservations],
|
|
|
|
|
|
];
|
|
|
|
|
|
let lastDetailTab = TABS.VALUATIONS;
|
|
|
|
|
|
|
|
|
|
|
|
export async function render(el) {
|
|
|
|
|
|
el.innerHTML = '<div data-acc-cards></div>';
|
|
|
|
|
|
const host = el.querySelector('[data-acc-cards]');
|
|
|
|
|
|
loading.show(host, 'cards', { count: 3 });
|
|
|
|
|
|
setPageHint(el, { title: t('eventAccounts.hintTitle'), rows: [
|
|
|
|
|
|
t('eventAccounts.hintBalance'), t('eventAccounts.hintDetail'), t('eventAccounts.hintInterest'), t('reservations.hint'),
|
|
|
|
|
|
t('eventAccounts.hintActivity'), t('eventAccounts.hintCapture'),
|
|
|
|
|
|
{ heading: t('nav.valuations') }, t('valuations.rateNote'),
|
|
|
|
|
|
{ heading: t('nav.trades') }, t('trades.fiatNote'),
|
|
|
|
|
|
{ heading: t('nav.transfers') }, t('transfers.fiatNote'),
|
|
|
|
|
|
{ heading: t('nav.interest') }, t('interest.importNote'),
|
|
|
|
|
|
] });
|
|
|
|
|
|
try {
|
|
|
|
|
|
await renderCards(host);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
loading.error(host, err && err.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadModel() {
|
|
|
|
|
|
const none = () => [];
|
|
|
|
|
|
const [accounts, assets, open, schedules, interestRows, trades, transfers, valuationRows, reservationRows] = await Promise.all([
|
|
|
|
|
|
listAccounts(), listAssets(), listOpenValuationDiffs(),
|
|
|
|
|
|
listAllSchedules().catch(none), listInterest().catch(none),
|
|
|
|
|
|
listTrades().catch(none), listTransfers().catch(none), listValuations().catch(none),
|
|
|
|
|
|
listReservations().catch(none),
|
|
|
|
|
|
]);
|
|
|
|
|
|
const startYear = Number(await getPref('app', 'start_year').catch(() => null)) || CONFIG.START_YEAR.AUTO;
|
|
|
|
|
|
const fromIso = startYear !== CONFIG.START_YEAR.AUTO ? `${startYear}-01-01T12:00:00Z` : null;
|
|
|
|
|
|
const events = { accounts, schedules, interest: interestRows, trades, transfers, valuations: valuationRows,
|
|
|
|
|
|
reservations: reservationRows, interestDue: [], fromIso };
|
|
|
|
|
|
const holdings = await accountHoldings(accounts.map(a => a.id));
|
|
|
|
|
|
const assetById = new Map(assets.map(a => [a.id, a]));
|
|
|
|
|
|
|
|
|
|
|
|
const held = [...new Set(holdings.map(h => h.assetId))].map(id => assetById.get(id)).filter(Boolean);
|
|
|
|
|
|
const spot = await loadFiat(held.filter(a => !isBaseAsset(a))).catch(() => new Map());
|
|
|
|
|
|
const prices = new Map();
|
|
|
|
|
|
for (const a of held) {
|
|
|
|
|
|
if (isBaseAsset(a)) { prices.set(a.id, 1); continue; }
|
|
|
|
|
|
let price = basePriceOf(spot.get(a.id));
|
|
|
|
|
|
if (price == null) price = (await latestPricePoint(a.id).catch(() => null))?.fiatPrice ?? null;
|
|
|
|
|
|
prices.set(a.id, price);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const openPer = new Map();
|
|
|
|
|
|
for (const v of open) if (v.accountId != null) openPer.set(v.accountId, (openPer.get(v.accountId) || 0) + 1);
|
|
|
|
|
|
|
|
|
|
|
|
const today = localDate(new Date().toISOString());
|
|
|
|
|
|
const running = schedules.filter(s => !s.endsOn || s.endsOn >= today);
|
|
|
|
|
|
const forecasts = new Map(await Promise.all(running.filter(s => s.rateKind === 'fixed')
|
|
|
|
|
|
.map(async s => [s.id, await scheduleForecast(s).catch(() => null)])));
|
|
|
|
|
|
events.interestDue = running.flatMap(s => (forecasts.get(s.id)?.upcoming || [])
|
|
|
|
|
|
.map(p => ({ accountId: s.accountId, periodEnd: p.periodEnd })));
|
|
|
|
|
|
const interestOf = id => {
|
|
|
|
|
|
const own = running.filter(s => s.accountId === id);
|
|
|
|
|
|
const booked = interestRows.filter(r => r.toAccountId === id);
|
|
|
|
|
|
const next = own.map(s => {
|
|
|
|
|
|
const p = forecasts.get(s.id)?.upcoming[0];
|
|
|
|
|
|
return p ? { periodEnd: p.periodEnd, amount: p.projectedSub, meta: payoutMeta(s) } : null;
|
|
|
|
|
|
}).filter(Boolean);
|
|
|
|
|
|
return { agreements: own.length, booked: booked.length, lastAt: booked[0]?.occurredAt ?? null, next };
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const cards = accounts.map(account => {
|
|
|
|
|
|
const lines = holdings.filter(h => h.accountId === account.id).map(h => {
|
|
|
|
|
|
const asset = assetById.get(h.assetId);
|
|
|
|
|
|
const price = prices.get(h.assetId);
|
|
|
|
|
|
const value = asset && price != null ? (h.saldo / Math.pow(10, asset.decimals)) * price : null;
|
|
|
|
|
|
return { ...h, asset, value };
|
|
|
|
|
|
}).filter(l => l.asset).sort((a, b) => byKindAndLabel(a.asset, b.asset));
|
|
|
|
|
|
const total = lines.reduce((s, l) => s + (l.value ?? 0), 0);
|
|
|
|
|
|
const own = reservationRows.filter(r => reservationAccount(r).id === account.id);
|
|
|
|
|
|
return { account, lines, total, priced: lines.every(l => l.value != null), open: openPer.get(account.id) || 0,
|
|
|
|
|
|
interest: interestOf(account.id), reservations: { n: own.length, nextAt: own[0]?.occurredAt ?? null } };
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const kindRank = k => { const i = ENUMS.ACCOUNT_KIND.indexOf(k); return i < 0 ? ENUMS.ACCOUNT_KIND.length : i; };
|
|
|
|
|
|
return {
|
|
|
|
|
|
events,
|
|
|
|
|
|
cards: cards
|
|
|
|
|
|
.filter(c => c.account.isActive || c.lines.length)
|
|
|
|
|
|
.sort((a, b) => kindRank(a.account.kind) - kindRank(b.account.kind) || b.total - a.total
|
|
|
|
|
|
|| a.account.name.localeCompare(b.account.name)),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function dayLabel(day, x, names) {
|
|
|
|
|
|
const date = localDateNl(new Date(x).toISOString());
|
|
|
|
|
|
if (!day?.length) return date;
|
|
|
|
|
|
const counts = new Map();
|
|
|
|
|
|
for (const it of day) counts.set(it.kind, (counts.get(it.kind) || 0) + 1);
|
|
|
|
|
|
const what = [...counts].map(([k, n]) => `${t('eventAccounts.act.' + k)}${n > 1 ? ` ${n}×` : ''}`).join(', ');
|
|
|
|
|
|
const who = names ? [...new Set(day.map(it => names.get(it.accountId)))].join(', ') : '';
|
|
|
|
|
|
return `${date}: ${what}${who ? ` (${who})` : ''}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const MARK_VARIANT = { balance: 'balance', agreementStart: 'agreement', agreementEnd: 'agreement',
|
|
|
|
|
|
interestDue: 'agreement', reservation: 'reservation' };
|
|
|
|
|
|
|
|
|
|
|
|
function mountActivity(host, events, accountIds, { onPick = null, nowText = null } = {}) {
|
|
|
|
|
|
const nowIso = new Date().toISOString();
|
|
|
|
|
|
const items = activityItems({ accountIds, ...events });
|
|
|
|
|
|
const beat = heartbeat(items, nowIso, events.fromIso);
|
|
|
|
|
|
if (!beat) { host.innerHTML = ''; return false; }
|
|
|
|
|
|
const names = accountIds.length > 1 ? new Map(events.accounts.map(a => [a.id, a.name])) : null;
|
|
|
|
|
|
const d = localDate(nowIso);
|
|
|
|
|
|
const todayX = Date.UTC(Number(d.slice(0, 4)), Number(d.slice(5, 7)) - 1, Number(d.slice(8, 10)));
|
|
|
|
|
|
const label = x => {
|
|
|
|
|
|
const day = dayLabel(beat.byDay.get(x), x, names);
|
|
|
|
|
|
return nowText && x === todayX ? `${day} · ${nowText}` : day;
|
|
|
|
|
|
};
|
|
|
|
|
|
const marks = items.filter(it => it.dir === 0)
|
2026-10-30 12:28:48 +01:00
|
|
|
|
.map(it => ({ x: it.x, variant: MARK_VARIANT[it.kind] || 'account', label: label(it.x) }))
|
|
|
|
|
|
.sort((a, b) => (a.variant === 'balance') - (b.variant === 'balance'));
|
|
|
|
|
|
if (nowText) marks.unshift({ x: todayX, variant: 'now', label: label(todayX) });
|
2026-10-29 11:57:29 +01:00
|
|
|
|
const peak = Math.max(1, ...beat.points.map(p => Math.abs(p[1])));
|
|
|
|
|
|
mountSparkline(host, beat.points, {
|
|
|
|
|
|
yRange: [-peak, peak],
|
|
|
|
|
|
markers: marks,
|
|
|
|
|
|
snap: items.filter(it => it.dir !== 0).map(it => it.x),
|
|
|
|
|
|
formatValue: (_v, x) => label(x),
|
|
|
|
|
|
onPick: onPick && (x => {
|
|
|
|
|
|
const day = beat.byDay.get(x) || [];
|
|
|
|
|
|
const hit = day.find(it => it.table && it.dir !== 0) || day.find(it => it.table);
|
|
|
|
|
|
if (hit) onPick(hit);
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const PREF_SCOPE = EVENTS_PAGE.ROUTE;
|
|
|
|
|
|
const PREF_KINDS = 'accountKinds';
|
|
|
|
|
|
const PREF_EMPTY = 'accountsEmpty';
|
|
|
|
|
|
|
|
|
|
|
|
async function renderCards(host) {
|
|
|
|
|
|
const { cards, events } = await loadModel();
|
|
|
|
|
|
if (!cards.length) {
|
|
|
|
|
|
host.innerHTML = emptyStateHtml(t('eventAccounts.none'), {
|
|
|
|
|
|
why: t('eventAccounts.noneWhy'), href: targetHash(TARGETS.ACCOUNTS), linkText: t('eventAccounts.manage'),
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const kinds = ENUMS.ACCOUNT_KIND.filter(k => cards.some(c => c.account.kind === k));
|
|
|
|
|
|
const items = kinds.map(k => ({ id: k, label: t('enums.account_kind.' + k) }));
|
|
|
|
|
|
let selected = resolveSelection({ saved: await getPref(PREF_SCOPE, PREF_KINDS, null).catch(() => null), items });
|
|
|
|
|
|
let showEmpty = (await getPref(PREF_SCOPE, PREF_EMPTY, true).catch(() => true)) !== false;
|
|
|
|
|
|
const hasEmpty = cards.some(c => !c.lines.length);
|
|
|
|
|
|
|
|
|
|
|
|
const open = id => openDetail(id).then(() => { if (host.isConnected) renderCards(host); });
|
|
|
|
|
|
const draw = () => {
|
|
|
|
|
|
const shown = cards.filter(c => selected.has(c.account.kind) && (showEmpty || c.lines.length));
|
|
|
|
|
|
host.innerHTML = `<div class="acc-pulse" data-acc-pulse></div>
|
|
|
|
|
|
<div class="mx-filter acc-filter"><div data-filter></div>${hasEmpty
|
|
|
|
|
|
? `<div class="fbar fbar--chip"><button type="button" class="fbar-item${showEmpty ? ' is-on' : ''}" data-empty-toggle
|
|
|
|
|
|
aria-pressed="${showEmpty}">${t('eventAccounts.showEmpty')}</button></div>` : ''}</div>
|
|
|
|
|
|
<div class="wealth-grid acc-grid">${shown.map(c => cardHtml(c)).join('')}</div>`;
|
|
|
|
|
|
host.querySelectorAll('[data-acc-spark]').forEach(s => mountActivity(s, events, [Number(s.dataset.accSpark)]));
|
|
|
|
|
|
const total = shown.reduce((s, c) => s + c.total, 0);
|
|
|
|
|
|
const partial = shown.some(c => c.lines.length && !c.priced);
|
|
|
|
|
|
const pulse = host.querySelector('[data-acc-pulse]');
|
|
|
|
|
|
pulse.hidden = !mountActivity(pulse, events, shown.map(c => c.account.id), {
|
|
|
|
|
|
nowText: t(partial ? 'eventAccounts.nowTotalPartial' : 'eventAccounts.nowTotal', { bedrag: fmtBase(total) }),
|
|
|
|
|
|
});
|
|
|
|
|
|
createFilter(host.querySelector('[data-filter]'), {
|
|
|
|
|
|
items, selected,
|
|
|
|
|
|
onChange: sel => { selected = sel; setPref(PREF_SCOPE, PREF_KINDS, [...sel]).catch(() => {}); draw(); },
|
|
|
|
|
|
});
|
|
|
|
|
|
host.querySelector('[data-empty-toggle]')?.addEventListener('click', () => {
|
|
|
|
|
|
showEmpty = !showEmpty;
|
|
|
|
|
|
setPref(PREF_SCOPE, PREF_EMPTY, showEmpty).catch(() => {});
|
|
|
|
|
|
draw();
|
|
|
|
|
|
});
|
|
|
|
|
|
host.querySelectorAll('[data-account]').forEach(card => {
|
|
|
|
|
|
const id = Number(card.dataset.account);
|
|
|
|
|
|
card.addEventListener('click', () => open(id));
|
|
|
|
|
|
card.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(id); } });
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
draw();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function lineHtml(l, pick, chosen) {
|
|
|
|
|
|
const value = isBaseAsset(l.asset) ? '' : (l.value != null ? val(fmtBase(l.value)) : t('eventAccounts.noPrice'));
|
|
|
|
|
|
const anchor = l.anchor
|
|
|
|
|
|
? t('eventAccounts.lastBalance', { datum: localDateNl(l.anchor.rateAt) })
|
|
|
|
|
|
: t('eventAccounts.fromBookings');
|
|
|
|
|
|
const inner = `
|
|
|
|
|
|
<div class="acc-line-main"><span>${assetLabelHtml(l.asset)}</span><span>${val(fmtUnit(l.saldo, l.asset))}</span></div>
|
|
|
|
|
|
<div class="acc-line-sub t-muted-cell"><span>${anchor}</span><span>${value}</span></div>`;
|
|
|
|
|
|
return pick
|
|
|
|
|
|
? `<div class="acc-pick">
|
|
|
|
|
|
<button type="button" class="acc-line acc-line--pick${chosen ? ' is-on' : ''}" data-pick-asset="${l.assetId}"
|
|
|
|
|
|
aria-pressed="${chosen}" title="${esc(t('eventAccounts.pickAsset'))}">${inner}</button>
|
|
|
|
|
|
${anchorBtnHtml(l, 'acc-anchor-btn')}
|
|
|
|
|
|
</div>`
|
|
|
|
|
|
: `<div class="acc-line">${inner}</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const anchorBtnHtml = (l, cls = '') => (balanceCaptureOn(l.asset)
|
|
|
|
|
|
? `<button type="button" class="btn btn-ghost btn-xs ${cls}" data-anchor-asset="${l.assetId}"
|
|
|
|
|
|
title="${esc(t('eventAccounts.recordBalanceHint'))}">${t('eventAccounts.recordBalance')}</button>`
|
|
|
|
|
|
: '');
|
|
|
|
|
|
|
|
|
|
|
|
function badgesHtml(c) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
`<span class="badge">${esc(t('enums.account_kind.' + c.account.kind))}</span>`,
|
|
|
|
|
|
c.account.isActive ? '' : `<span class="badge badge-neutral">${t('eventAccounts.closed')}</span>`,
|
|
|
|
|
|
c.open ? `<span class="badge badge-accent">${t('eventAccounts.openGaps', { n: c.open })}</span>` : '',
|
|
|
|
|
|
].join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const totalHtml = c => (c.lines.length && c.priced ? `<div class="acc-total">${val(fmtBase(c.total))}</div>` : '');
|
|
|
|
|
|
|
|
|
|
|
|
const payoutMeta = s => ({ decimals: s.payout_decimals, unit: s.payout_unit, subunit: s.payout_subunit, displayUnit: s.payout_display_unit });
|
|
|
|
|
|
|
|
|
|
|
|
function interestHtml(c) {
|
|
|
|
|
|
const { agreements, booked, lastAt, next } = c.interest;
|
|
|
|
|
|
const parts = [
|
|
|
|
|
|
agreements === 1 ? t('eventAccounts.interestAgreement')
|
|
|
|
|
|
: agreements > 1 ? t('eventAccounts.interestAgreements', { n: agreements }) : '',
|
|
|
|
|
|
booked ? t('eventAccounts.interestBooked', { n: booked, datum: localDateNl(lastAt) }) : '',
|
|
|
|
|
|
...next.map(n => t('eventAccounts.interestNext', { datum: localDateNl(n.periodEnd), bedrag: fmtUnit(n.amount, n.meta) })),
|
|
|
|
|
|
].filter(Boolean);
|
|
|
|
|
|
return parts.length
|
|
|
|
|
|
? `<div class="acc-interest t-muted-cell">${parts.map(p => `<span>${esc(p)}</span>`).join(' · ')}</div>` : '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function reservationHtml(c) {
|
|
|
|
|
|
const { n, nextAt } = c.reservations;
|
|
|
|
|
|
if (!n) return '';
|
|
|
|
|
|
const parts = [n === 1 ? t('reservations.cardOne') : t('reservations.cardMany', { n }),
|
|
|
|
|
|
t('reservations.cardNext', { datum: localDateNl(nextAt) })];
|
|
|
|
|
|
return `<div class="acc-interest t-muted-cell">${parts.map(p => `<span>${esc(p)}</span>`).join(' · ')}</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function cardHtml(c) {
|
|
|
|
|
|
const { account } = c;
|
|
|
|
|
|
const lines = c.lines.length
|
|
|
|
|
|
? c.lines.map(l => lineHtml(l, false)).join('')
|
|
|
|
|
|
: `<div class="t-muted-cell">${t('eventAccounts.empty')}</div>`;
|
|
|
|
|
|
return `<div class="card wealth-card acc-card" data-account="${account.id}" role="button" tabindex="0"
|
|
|
|
|
|
aria-label="${esc(t('eventAccounts.open'))}: ${esc(account.name)}">
|
|
|
|
|
|
<div class="wealth-head">
|
|
|
|
|
|
<strong>${esc(account.name)}</strong>${badgesHtml(c)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
${totalHtml(c)}
|
|
|
|
|
|
${interestHtml(c)}
|
|
|
|
|
|
${reservationHtml(c)}
|
|
|
|
|
|
<div class="acc-lines">${lines}</div>
|
|
|
|
|
|
<div class="wealth-spark-wrap acc-spark-wrap"><div class="wealth-spark" data-acc-spark="${account.id}"></div></div>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function summaryHtml(c, assetId) {
|
|
|
|
|
|
if (!c.lines.length) return `<div class="t-muted-cell">${t('eventAccounts.empty')}</div>`;
|
|
|
|
|
|
if (c.lines.length === 1) {
|
|
|
|
|
|
const l = c.lines[0];
|
|
|
|
|
|
const anchor = l.anchor
|
|
|
|
|
|
? t('eventAccounts.lastBalance', { datum: localDateNl(l.anchor.rateAt) })
|
|
|
|
|
|
: t('eventAccounts.fromBookings');
|
|
|
|
|
|
return `<div class="acc-single">
|
|
|
|
|
|
<span>${assetLabelHtml(l.asset)}</span><span class="t-muted-cell">${anchor}</span>
|
|
|
|
|
|
${anchorBtnHtml(l)}
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return c.lines.map(l => lineHtml(l, true, l.assetId === assetId)).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
const asideHtml = c => `${badgesHtml(c)}${totalHtml(c)}`;
|
|
|
|
|
|
|
|
|
|
|
|
const unitLabel = a => {
|
|
|
|
|
|
if (isMetal(a)) return '';
|
|
|
|
|
|
const u = a.displayUnit === 'subunit' && a.subunit ? a.subunit : a.unit;
|
|
|
|
|
|
return u ? ` (${u})` : '';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
async function recordBalanceFor(line, accountId) {
|
|
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
|
|
const res = await dialog.form({
|
|
|
|
|
|
title: t('eventAccounts.recordBalanceTitle', { asset: line.asset.symbol }),
|
|
|
|
|
|
fields: [
|
|
|
|
|
|
{ key: 'valuedOn', label: t('valuations.colDate'), type: 'date', value: localDate(now) },
|
|
|
|
|
|
{ key: '_time', label: t('valuations.colTime'), type: 'time', value: localTime(now) },
|
|
|
|
|
|
{ key: 'balance', label: `${t('valuations.colBalance')}${unitLabel(line.asset)}`, type: 'number',
|
|
|
|
|
|
value: String(fromSubunit(line.saldo, line.asset)) },
|
|
|
|
|
|
],
|
|
|
|
|
|
confirmLabel: t('eventAccounts.recordBalance'),
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!res) return false;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await recordBalance({ ...res, accountId, assetId: line.assetId });
|
|
|
|
|
|
return true;
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
await dialog.alert({ title: t('dialog.error'), message: err.message });
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function tabFor(line, accountId) {
|
|
|
|
|
|
if (line.anchor) return TABS.VALUATIONS;
|
|
|
|
|
|
const [tr, tf, ir] = await Promise.all([listTrades(), listTransfers(), listInterest()]).catch(() => [[], [], []]);
|
|
|
|
|
|
const own = r => r.assetId === line.assetId;
|
|
|
|
|
|
if (tr.some(r => own(r) && (r.type === 'buy' ? r.toAccountId : r.fromAccountId) === accountId)) return TABS.TRADES;
|
|
|
|
|
|
if (tf.some(r => own(r) && (r.fromAccountId === accountId || r.toAccountId === accountId))) return TABS.TRANSFERS;
|
|
|
|
|
|
if (ir.some(r => own(r) && r.toAccountId === accountId)) return TABS.INTEREST;
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function openDetail(accountId) {
|
|
|
|
|
|
const model = await loadModel();
|
|
|
|
|
|
const c = model.cards.find(x => x.account.id === accountId);
|
|
|
|
|
|
if (!c) return;
|
|
|
|
|
|
let assetId = null;
|
|
|
|
|
|
let tabs = null;
|
|
|
|
|
|
const closed = dialog.detail({
|
|
|
|
|
|
title: c.account.name, size: 'xl',
|
|
|
|
|
|
html: `<div class="wealth-detail acc-detail"><div class="acc-summary" data-acc-summary></div>
|
|
|
|
|
|
<div class="wealth-detail-tabs" data-acc-tabs></div>
|
|
|
|
|
|
<div class="wealth-detail-spark"><div class="acc-detail-plot" data-acc-activity></div></div></div>`,
|
|
|
|
|
|
});
|
|
|
|
|
|
const root = document.querySelector('.dialog-overlay .acc-detail');
|
|
|
|
|
|
if (!root) return closed;
|
|
|
|
|
|
const summary = root.querySelector('[data-acc-summary]');
|
|
|
|
|
|
const activity = root.querySelector('[data-acc-activity]');
|
|
|
|
|
|
const aside = document.querySelector('.dialog-overlay [data-dialog-aside]');
|
|
|
|
|
|
|
|
|
|
|
|
const pickEvent = it => {
|
|
|
|
|
|
if (assetId != null && it.assetId !== assetId) { assetId = null; draw(); }
|
|
|
|
|
|
setRowFocus(it.table, it.id);
|
|
|
|
|
|
if (tabs.active !== it.table) tabs.select(it.table);
|
|
|
|
|
|
else tabs.redraw();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let current = c;
|
|
|
|
|
|
let events = model.events;
|
|
|
|
|
|
const draw = () => {
|
|
|
|
|
|
summary.innerHTML = reservationHtml(current) + summaryHtml(current, assetId);
|
|
|
|
|
|
if (aside) aside.innerHTML = asideHtml(current);
|
|
|
|
|
|
};
|
|
|
|
|
|
const drawActivity = () => { mountActivity(activity, events, [accountId], { onPick: pickEvent }); };
|
|
|
|
|
|
const refreshSummary = async () => {
|
|
|
|
|
|
const fresh = await loadModel().catch(() => null);
|
|
|
|
|
|
const card = fresh?.cards.find(x => x.account.id === accountId);
|
|
|
|
|
|
if (card) { current = card; events = fresh.events; }
|
|
|
|
|
|
if (summary.isConnected) { draw(); drawActivity(); }
|
|
|
|
|
|
};
|
|
|
|
|
|
draw();
|
|
|
|
|
|
drawActivity();
|
|
|
|
|
|
summary.addEventListener('click', async e => {
|
|
|
|
|
|
const anchorBtn = e.target.closest('[data-anchor-asset]');
|
|
|
|
|
|
if (anchorBtn) {
|
|
|
|
|
|
const line = current.lines.find(l => l.assetId === Number(anchorBtn.dataset.anchorAsset));
|
|
|
|
|
|
if (line && await recordBalanceFor(line, accountId)) {
|
|
|
|
|
|
assetId = line.assetId;
|
|
|
|
|
|
await refreshSummary();
|
|
|
|
|
|
if (tabs.active !== TABS.VALUATIONS) tabs.select(TABS.VALUATIONS);
|
|
|
|
|
|
else tabs.redraw();
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const b = e.target.closest('[data-pick-asset]');
|
|
|
|
|
|
if (!b) return;
|
|
|
|
|
|
const id = Number(b.dataset.pickAsset);
|
|
|
|
|
|
assetId = assetId === id ? null : id;
|
|
|
|
|
|
draw();
|
|
|
|
|
|
const line = current.lines.find(l => l.assetId === id);
|
|
|
|
|
|
const tab = assetId != null && line ? await tabFor(line, accountId) : null;
|
|
|
|
|
|
if (tab && tab !== tabs.active) tabs.select(tab);
|
|
|
|
|
|
else { tabs.redraw(); refreshSummary(); }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const assetKinds = [...new Set([...(ACCOUNT_ASSET_KINDS[c.account.kind] || ENUMS.ASSET_KIND),
|
|
|
|
|
|
...c.lines.map(l => l.asset.kind)])];
|
|
|
|
|
|
tabs = createTabs(root.querySelector('[data-acc-tabs]'), {
|
|
|
|
|
|
active: lastDetailTab,
|
|
|
|
|
|
items: DETAIL.map(([id]) => ({ id, label: t(`nav.${id}`) })),
|
|
|
|
|
|
render: (id, panel) => DETAIL.find(([x]) => x === id)[1].render(panel, { accountId, assetId, assetKinds }),
|
|
|
|
|
|
onChange: id => { lastDetailTab = id; refreshSummary(); },
|
|
|
|
|
|
});
|
|
|
|
|
|
const onLedger = () => { if (summary.isConnected) refreshSummary(); };
|
|
|
|
|
|
document.addEventListener('ledger-changed', onLedger);
|
|
|
|
|
|
closed.finally(() => document.removeEventListener('ledger-changed', onLedger));
|
|
|
|
|
|
return closed;
|
|
|
|
|
|
}
|