366 lines
22 KiB
JavaScript
366 lines
22 KiB
JavaScript
// Generated, do not edit. sha256:b3c098ab83c22c91c33b24c5b6d1f67c7d41af1244da6963ffe67e33da9270d3
|
|||
|
|
import { listAccounts, listSelectableAccounts } from '../data/accounts.js';
|
||
|
|
import { dayNl, localDate, localDateNl, localTime, toUtcIso } from '../../shared/time.js';
|
||
|
|
import { listAssets, listBookableAssets } from '../data/assets.js';
|
||
|
|
import { createEvent, updateEvent, deleteEvent } from '../data/events.js';
|
||
|
|
import { listInterest, listAllSchedules, createSchedule, updateSchedule, deleteSchedule,
|
||
|
|
listPeriods, createPeriod, updatePeriod, deletePeriod } from '../data/interest-schedules.js';
|
||
|
|
import { tableViewCfg } from '../data/preferences.js';
|
||
|
|
import { t } from '../i18n/index.js';
|
||
|
|
import { assetLabelHtml } from '../ui/asset-label.js';
|
||
|
|
import * as dialog from '../ui/dialog.js';
|
||
|
|
import { renderTable } from '../ui/table.js';
|
||
|
|
import { takeRowFocus } from '../ui/row-focus.js';
|
||
|
|
import { fmtUnit, val, subline, toSubunit, fromSubunit, pct } from '../ui/format.js';
|
||
|
|
import { fiatAtPrice, fiatHistorical, fiatHistoricalOrigin, loadHistorical, loadFiat, fiatValue, fiatValueOrigin } from '../ui/fiat.js';
|
||
|
|
import { bookingOriginHtml, originHtml } from '../ui/price-origin.js';
|
||
|
|
import { renderScheduleStatus } from '../actions/interest-widget.js';
|
||
|
|
import { compoundsViaBalance, looksLikeDepositWithoutPrincipal } from '../actions/interest-compounding.js';
|
||
|
|
import { CONFIG, ENUMS, ACCOUNT_INTEREST_KINDS, TARGETS, targetHash } from '../../shared/config.js';
|
||
|
|
import { esc } from '../ui/escape.js';
|
||
|
|
import { emptyStateHtml } from '../ui/empty-state.js';
|
||
|
|
import { setPageHint } from '../ui/page-hint.js';
|
||
|
|
|
||
|
|
let recordsApi = null;
|
||
|
|
let onlyAccount = null;
|
||
|
|
let onlyAsset = null;
|
||
|
|
let onlyKinds = null;
|
||
|
|
const assetAllowed = a => (onlyAsset == null || a.id === onlyAsset) && (onlyKinds == null || onlyKinds.includes(a.kind));
|
||
|
|
const accountAllowed = a => onlyAccount == null || a.id === onlyAccount;
|
||
|
|
const withoutAccount = c => onlyAccount == null || (c.key !== 'accountId' && c.key !== 'toAccountId');
|
||
|
|
const ownSchedules = async () => (await listAllSchedules()).filter(s => (onlyAccount == null || s.accountId === onlyAccount)
|
||
|
|
&& (onlyAsset == null || s.principalAssetId === onlyAsset || s.payoutAssetId === onlyAsset));
|
||
|
|
const ownInterest = async () => (await listInterest()).filter(r => (onlyAccount == null || r.toAccountId === onlyAccount)
|
||
|
|
&& (onlyAsset == null || r.assetId === onlyAsset));
|
||
|
|
async function herlaadRecords() { if (recordsApi) await recordsApi.refresh(await ownInterest()); }
|
||
|
|
let schedApi = null;
|
||
|
|
let herlaadStatus = () => {};
|
||
|
|
|
||
|
|
export async function render(el, { accountId = null, assetId = null, assetKinds = null } = {}) {
|
||
|
|
onlyAccount = accountId;
|
||
|
|
onlyAsset = assetId;
|
||
|
|
onlyKinds = assetKinds;
|
||
|
|
if ((await listAssets()).length === 0) {
|
||
|
|
el.innerHTML = `
|
||
|
|
${emptyStateHtml(t('interest.none'), {
|
||
|
|
why: t('interest.needAsset'), href: targetHash(TARGETS.ASSETS), linkText: t('common.manageAssets'),
|
||
|
|
})}`;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
el.innerHTML = `
|
||
|
|
<div id="sched"></div>
|
||
|
|
<div id="sched-status"></div>
|
||
|
|
<div id="tbl" class="mt-lg"></div>`;
|
||
|
|
if (onlyAccount == null) setPageHint(el, { title: t('nav.interest'), rows: [t('interest.importNote')] });
|
||
|
|
buildSchedules(el);
|
||
|
|
buildRecords(el);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function interestAccountOpts(huidigeId) {
|
||
|
|
return (await listSelectableAccounts({ ookId: Number(huidigeId) || null }))
|
||
|
|
.filter(a => ACCOUNT_INTEREST_KINDS.includes(a.kind) && accountAllowed(a)).map(a => ({ value: a.id, label: a.name }));
|
||
|
|
}
|
||
|
|
|
||
|
|
async function buildSchedules(el) {
|
||
|
|
const alleAssets = await listAssets();
|
||
|
|
const assetOpts = async () => (await listBookableAssets()).filter(assetAllowed).map(a => ({ value: a.id, label: a.symbol }));
|
||
|
|
const rateKindOpts = () => ENUMS.INTEREST_RATE_KIND.map(rk => ({ value: rk, label: t('enums.interest_rate_kind.' + rk) }));
|
||
|
|
const cadenceOpts = () => ENUMS.INTEREST_CADENCE.map(c => ({ value: c, label: t('enums.interest_cadence.' + c) }));
|
||
|
|
const weekdayOpts = () => [0, 1, 2, 3, 4, 5, 6].map(d => ({ value: d, label: t('accounts.wd' + d) }));
|
||
|
|
const cadenceLabel = r => t('enums.interest_cadence.' + r.creditCadence);
|
||
|
|
const principalMeta = r => ({ decimals: r.principal_decimals, unit: r.principal_unit, subunit: r.principal_subunit, displayUnit: r.principal_display_unit });
|
||
|
|
|
||
|
|
const toSched = async v => {
|
||
|
|
const accountId = Number(v.accountId ?? onlyAccount);
|
||
|
|
if (!accountId) throw new Error(t('interest.schAccountRequired'));
|
||
|
|
const payoutAssetId = Number(v.payoutAssetId);
|
||
|
|
if (!payoutAssetId) throw new Error(t('accounts.schPayoutRequired'));
|
||
|
|
const base = {
|
||
|
|
accountId, rateKind: v.rateKind, payoutAssetId,
|
||
|
|
startsOn: v.startsOn, endsOn: v.endsOn || null,
|
||
|
|
note: (v.note || '').trim() || null,
|
||
|
|
};
|
||
|
|
if (v.rateKind === 'variable') {
|
||
|
|
return { ...base, principalAssetId: null, principalAmount: null, annualRate: null,
|
||
|
|
creditCadence: null, creditWeekday: null, creditDayOfMonth: null, compounding: 0 };
|
||
|
|
}
|
||
|
|
const asset = (await listAssets()).find(a => a.id === Number(v.principalAssetId));
|
||
|
|
if (!asset) throw new Error(t('accounts.schAssetRequired'));
|
||
|
|
const annualRate = Number(v.annualRate) / 100;
|
||
|
|
if (!Number.isFinite(annualRate)) throw new Error(t('accounts.schRateInvalid'));
|
||
|
|
return {
|
||
|
|
...base,
|
||
|
|
principalAssetId: Number(v.principalAssetId),
|
||
|
|
principalAmount: String(v.principalAmount ?? '').trim() === '' ? null : toSubunit(v.principalAmount, asset),
|
||
|
|
annualRate,
|
||
|
|
creditCadence: v.creditCadence,
|
||
|
|
creditWeekday: v.creditCadence === 'weekly' ? Number(v.creditWeekday) : null,
|
||
|
|
creditDayOfMonth: v.creditCadence === 'monthly' ? Number(v.creditDayOfMonth) : null,
|
||
|
|
compounding: Number(v.compounding) === 1 ? 1 : 0,
|
||
|
|
};
|
||
|
|
};
|
||
|
|
const yesNoOpts = () => [{ value: 0, label: t('accounts.no') }, { value: 1, label: t('accounts.yes') }];
|
||
|
|
const confirmDeposit = async v => {
|
||
|
|
if (!looksLikeDepositWithoutPrincipal(v)) return;
|
||
|
|
const ok = await dialog.confirm({ title: t('interest.depositTitle'), message: t('interest.depositWarning'),
|
||
|
|
confirmLabel: t('interest.depositSaveAnyway'), cancelLabel: t('interest.depositBack') });
|
||
|
|
if (!ok) throw new Error(t('interest.depositNotSaved'));
|
||
|
|
};
|
||
|
|
|
||
|
|
const reloadStatus = async () => {
|
||
|
|
const host = el.querySelector('#sched-status');
|
||
|
|
const accountIds = [...new Set((await ownSchedules()).map(s => s.accountId))];
|
||
|
|
if (!accountIds.length) { host.innerHTML = ''; return; }
|
||
|
|
const accounts = await listAccounts();
|
||
|
|
const naamHtml = id => onlyAccount != null ? '' : `<div class="ip-acc-name">${esc(accounts.find(a => a.id === id)?.name || '')}</div>`;
|
||
|
|
host.innerHTML = `<section class="card-flat ip-box">
|
||
|
|
<div class="ip-box-head"><span class="t-label">${t('accounts.projection')}</span>
|
||
|
|
<span class="t-body-sm t-muted">${t('accounts.projectionHint')}</span></div>
|
||
|
|
${accountIds.map(id => `<div class="ip-acc" data-acc="${id}">${naamHtml(id)}<div data-acc-status></div></div>`).join('')}
|
||
|
|
</section>`;
|
||
|
|
const getekend = await Promise.all(accountIds.map(id =>
|
||
|
|
renderScheduleStatus(host.querySelector(`[data-acc="${id}"] [data-acc-status]`), id, reloadAll)));
|
||
|
|
accountIds.forEach((id, i) => { if (!getekend[i]) host.querySelector(`[data-acc="${id}"]`).remove(); });
|
||
|
|
if (!getekend.some(Boolean)) host.innerHTML = '';
|
||
|
|
};
|
||
|
|
herlaadStatus = reloadStatus;
|
||
|
|
const reloadSched = async () => schedApi.refresh(await ownSchedules());
|
||
|
|
const reloadAll = () => { reloadSched(); reloadStatus(); herlaadRecords(); };
|
||
|
|
|
||
|
|
schedApi = renderTable(el.querySelector('#sched'), {
|
||
|
|
columns: [
|
||
|
|
{ key: 'accountId', label: t('interest.colAccount'), sortable: true, sortValue: r => r.account_name,
|
||
|
|
render: r => esc(r.account_name),
|
||
|
|
group: { value: r => r.account_name, label: v => v },
|
||
|
|
edit: { type: 'select', sticky: true, options: vals => interestAccountOpts(vals.accountId), value: r => r ? r.accountId : '' } },
|
||
|
|
{ key: 'rateKind', label: t('accounts.schRateType'), sortable: true,
|
||
|
|
render: r => t('enums.interest_rate_kind.' + r.rateKind),
|
||
|
|
group: { value: r => r.rateKind, label: v => t('enums.interest_rate_kind.' + v) },
|
||
|
|
edit: { type: 'select', sticky: true, reactive: true, options: rateKindOpts } },
|
||
|
|
{ key: 'principalAssetId', label: t('accounts.schPrincipalAsset'), sortable: true, sortValue: r => r.principal_symbol,
|
||
|
|
render: r => r.principal_symbol || '<span class="t-muted-cell">-</span>',
|
||
|
|
edit: { type: 'select', sticky: true, reactive: true, options: assetOpts, visible: v => v.rateKind === 'fixed' } },
|
||
|
|
{ key: 'principalAmount', label: t('accounts.schPrincipal'), align: 'num', sortable: true, sortValue: r => r.principalAmount,
|
||
|
|
render: r => {
|
||
|
|
if (r.principalAmount == null) return `<span class="t-muted-cell">${t('interest.principalFollowsBalance')}</span>`;
|
||
|
|
const asset = alleAssets.find(a => a.id === r.principalAssetId);
|
||
|
|
const fiat = asset ? fiatValue(r.principalAmount, asset) : '';
|
||
|
|
return `${val(fmtUnit(r.principalAmount, principalMeta(r)))}${subline(fiat ? val(fiat) + originHtml(fiatValueOrigin(asset), asset) : '')}`;
|
||
|
|
},
|
||
|
|
edit: { type: 'number', attrs: { min: 0, step: 'any' }, visible: v => v.rateKind === 'fixed',
|
||
|
|
value: r => r && r.principalAmount != null ? fromSubunit(r.principalAmount, principalMeta(r)) : '',
|
||
|
|
convert: vals => alleAssets.find(a => a.id === Number(vals.principalAssetId)) } },
|
||
|
|
{ key: 'annualRate', label: t('accounts.schRate'), align: 'num', sortable: true, sortValue: r => r.annualRate,
|
||
|
|
render: r => r.annualRate != null ? pct(r.annualRate) : '<span class="t-muted-cell">-</span>',
|
||
|
|
edit: { type: 'number', attrs: { min: 0, step: 'any' }, visible: v => v.rateKind === 'fixed', value: r => r && r.annualRate != null ? r.annualRate * 100 : '' } },
|
||
|
|
{ key: 'compounding', label: t('interest.colCompounding'),
|
||
|
|
render: r => r.rateKind !== 'fixed' ? '<span class="t-muted-cell">-</span>'
|
||
|
|
: compoundsViaBalance(r) ? `<span class="t-muted-cell">${t('interest.compoundingViaBalance')}</span>`
|
||
|
|
: t(r.compounding ? 'accounts.yes' : 'accounts.no'),
|
||
|
|
edit: { type: 'select', options: yesNoOpts, visible: v => v.rateKind === 'fixed', value: r => r ? r.compounding : 0 } },
|
||
|
|
{ key: 'payoutAssetId', label: t('accounts.schPayoutAsset'), sortable: true, sortValue: r => r.payout_symbol,
|
||
|
|
render: r => r.payout_symbol,
|
||
|
|
group: { value: r => r.payout_symbol, label: v => v },
|
||
|
|
edit: { type: 'select', sticky: true, options: assetOpts } },
|
||
|
|
{ key: 'startsOn', label: t('accounts.schStart'), sortable: true,
|
||
|
|
render: r => dayNl(r.startsOn),
|
||
|
|
edit: { type: 'date', sticky: true, value: r => r ? r.startsOn : new Date().toISOString().slice(0, 10) } },
|
||
|
|
{ key: 'endsOn', label: t('accounts.schEnd'),
|
||
|
|
render: r => dayNl(r.endsOn) || `<span class="t-muted-cell">${t('interest.endsOnOpen')}</span>`,
|
||
|
|
edit: { type: 'date', value: r => r && r.endsOn ? r.endsOn : '' } },
|
||
|
|
{ key: 'creditCadence', label: t('accounts.schCadence'),
|
||
|
|
render: r => r.creditCadence ? cadenceLabel(r) : '<span class="t-muted-cell">-</span>',
|
||
|
|
group: { value: r => r.creditCadence || '', label: v => v ? t('enums.interest_cadence.' + v) : t('interest.cadenceNone') },
|
||
|
|
edit: { type: 'select', sticky: true, options: cadenceOpts, reactive: true, visible: v => v.rateKind === 'fixed' } },
|
||
|
|
{ key: 'creditWeekday', label: t('accounts.schWeekday'),
|
||
|
|
render: r => r.creditCadence === 'weekly' ? t('accounts.wd' + r.creditWeekday) : '<span class="t-muted-cell">-</span>',
|
||
|
|
edit: { type: 'select', options: weekdayOpts, visible: vals => vals.creditCadence === 'weekly' } },
|
||
|
|
{ key: 'creditDayOfMonth', label: t('accounts.schDayOfMonth'),
|
||
|
|
render: r => r.creditCadence === 'monthly' ? String(r.creditDayOfMonth) : '<span class="t-muted-cell">-</span>',
|
||
|
|
edit: { type: 'number', attrs: { min: 1, max: 31, step: 1 }, visible: vals => vals.creditCadence === 'monthly' } },
|
||
|
|
{ key: 'note', label: t('accounts.schNote'), text: true,
|
||
|
|
render: r => `<span class="t-muted-cell">${esc(r.note || '')}</span>`,
|
||
|
|
edit: { type: 'textarea', expand: true } },
|
||
|
|
].filter(withoutAccount),
|
||
|
|
rows: await ownSchedules(),
|
||
|
|
title: t('interest.schedulesTitle'),
|
||
|
|
...(await tableViewCfg('interest.schedules', { key: 'startsOn', dir: 'desc' }, onlyAccount != null ? 'payoutAssetId' : null)),
|
||
|
|
emptyText: t('interest.noSchedules'),
|
||
|
|
newLabel: t('table.new'),
|
||
|
|
convertLabel: t('convert.title'),
|
||
|
|
collapsibleGroups: true,
|
||
|
|
newDefaults: { ...(onlyAccount != null ? { accountId: onlyAccount } : {}), rateKind: 'fixed', creditCadence: 'annual', principalAmount: '', annualRate: '', endsOn: '', note: '', creditWeekday: 1, creditDayOfMonth: 1, compounding: 0 },
|
||
|
|
saveLabel: t('accounts.save'), cancelLabel: t('accounts.cancel'),
|
||
|
|
onCreate: async v => { await confirmDeposit(v); await createSchedule(await toSched(v)); reloadAll(); },
|
||
|
|
onSave: async (id, v) => { await confirmDeposit(v); await updateSchedule(id, await toSched(v)); reloadAll(); },
|
||
|
|
onDelete: async id => {
|
||
|
|
if (!(await dialog.confirm({ message: t('accounts.confirmDeleteSchedule'), danger: true }))) return;
|
||
|
|
try { await deleteSchedule(id); reloadAll(); } catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); }
|
||
|
|
},
|
||
|
|
rowExpansion: () => '',
|
||
|
|
onExpansionMount: (r, host) => mountPeriods(host, r, reloadStatus),
|
||
|
|
});
|
||
|
|
|
||
|
|
reloadStatus();
|
||
|
|
const inleg = [...new Set((await ownSchedules()).filter(s => s.principalAmount != null).map(s => s.principalAssetId))]
|
||
|
|
.map(id => alleAssets.find(a => a.id === id)).filter(Boolean);
|
||
|
|
if (inleg.length) loadFiat(inleg).then(reloadSched).catch(() => {});
|
||
|
|
|
||
|
|
const focusAccount = Number(sessionStorage.getItem(CONFIG.FOCUS.INTEREST_ACCOUNT) || 0);
|
||
|
|
sessionStorage.removeItem(CONFIG.FOCUS.INTEREST_ACCOUNT);
|
||
|
|
if (focusAccount) schedApi.focusGroup('accountId', r => r.accountId === focusAccount);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function assetOptsFull() { return (await listBookableAssets()).filter(assetAllowed).map(a => ({ value: a.id, label: `${a.symbol}: ${a.name}` })); }
|
||
|
|
async function accountOpts(huidigeId) {
|
||
|
|
return (await listSelectableAccounts({ ookId: Number(huidigeId) || null })).filter(accountAllowed).map(a => ({ value: a.id, label: a.name }));
|
||
|
|
}
|
||
|
|
|
||
|
|
async function buildRecords(el) {
|
||
|
|
const alleAssets = await listAssets();
|
||
|
|
const rows = ownInterest;
|
||
|
|
const meta = r => ({ decimals: r.asset_decimals, unit: r.asset_unit, subunit: r.asset_subunit, displayUnit: r.asset_display_unit });
|
||
|
|
const assetOf = r => ({ ...meta(r), id: r.assetId, kind: r.asset_kind, symbol: r.asset_symbol });
|
||
|
|
const today = new Date().toISOString().slice(0, 10);
|
||
|
|
|
||
|
|
const columns = [
|
||
|
|
{ key: 'occurredAt', label: t('interest.colDate'), sortable: true,
|
||
|
|
render: r => localDateNl(r.occurredAt),
|
||
|
|
group: { value: r => localDate(r.occurredAt).slice(0, 4), label: v => v, order: 'desc' },
|
||
|
|
edit: { type: 'date', sticky: true, attrs: { max: today }, value: r => r ? localDate(r.occurredAt) : today } },
|
||
|
|
{ key: '_time', label: t('interest.time'),
|
||
|
|
render: r => localTime(r.occurredAt),
|
||
|
|
edit: { type: 'time', value: r => r ? localTime(r.occurredAt) : '00:00' } },
|
||
|
|
{ key: 'toAccountId', label: t('interest.colAccount'), sortable: true, sortValue: r => r.to_name,
|
||
|
|
render: r => r.to_name || '-', group: { value: r => r.to_name || '-', label: v => v },
|
||
|
|
edit: { type: 'select', sticky: true, options: vals => accountOpts(vals.toAccountId), value: r => r ? r.toAccountId ?? '' : '' } },
|
||
|
|
{ key: 'assetId', label: t('interest.colAsset'), sortable: true, sortValue: r => r.asset_symbol,
|
||
|
|
render: r => assetLabelHtml(r), group: { value: r => r.asset_symbol, label: v => v },
|
||
|
|
edit: { type: 'select', sticky: true, reactive: true, options: assetOptsFull } },
|
||
|
|
{ key: 'amount', label: t('interest.colAmount'), align: 'num', sortable: true, sortValue: r => r.amount,
|
||
|
|
render: r => {
|
||
|
|
const asset = assetOf(r);
|
||
|
|
if (r.fiatPrice != null) {
|
||
|
|
const fiat = fiatAtPrice(r.amount, meta(r).decimals, r.fiatPrice);
|
||
|
|
return `${val(fmtUnit(r.amount, meta(r)))}${subline(fiat ? val(fiat) + bookingOriginHtml(r, asset) : '')}`;
|
||
|
|
}
|
||
|
|
const dag = localDate(r.occurredAt);
|
||
|
|
const fiat = fiatHistorical(r.amount, asset, dag);
|
||
|
|
return `${val(fmtUnit(r.amount, meta(r)))}${subline(fiat ? val(fiat) + originHtml(fiatHistoricalOrigin(asset, dag), asset) : '')}`;
|
||
|
|
},
|
||
|
|
edit: { type: 'number', attrs: { min: 0, step: 'any' }, value: r => r ? fromSubunit(r.amount, meta(r)) : '', convert: vals => alleAssets.find(a => a.id === Number(vals.assetId)) } },
|
||
|
|
{ key: 'source', label: t('interest.colSource'), sortable: true,
|
||
|
|
render: r => r.source ? `<span class="badge ${r.source === 'manual' ? 'badge-accent' : 'badge-neutral'}">${t('enums.event_source.' + r.source)}</span>` : '',
|
||
|
|
group: { value: r => r.source, label: v => t('enums.event_source.' + v) } },
|
||
|
|
{ key: 'schedule', label: t('interest.colSchedule'), sortable: true, sortValue: r => r.schedule_label || '',
|
||
|
|
render: r => r.schedule_label || `<span class="t-muted-cell">-</span>`,
|
||
|
|
group: { value: r => r.schedule_label || t('interest.noSchedule'), label: v => v } },
|
||
|
|
{ key: 'note', label: t('interest.colNote'), text: true,
|
||
|
|
render: r => `<span class="t-muted-cell">${esc(r.note || '')}</span>`,
|
||
|
|
edit: { type: 'textarea', expand: true } },
|
||
|
|
].filter(withoutAccount);
|
||
|
|
|
||
|
|
async function toPayload(v, existing) {
|
||
|
|
const asset = (await listAssets()).find(a => a.id === Number(v.assetId));
|
||
|
|
const qty = parseFloat(v.amount);
|
||
|
|
const toAccountId = v.toAccountId ?? onlyAccount;
|
||
|
|
const account = toAccountId !== '' && toAccountId != null ? Number(toAccountId) : null;
|
||
|
|
if (!v.occurredAt || !asset || !account || !Number.isFinite(qty) || qty <= 0) {
|
||
|
|
throw new Error(t('interest.invalid'));
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
assetId: Number(v.assetId), type: 'interest',
|
||
|
|
occurredAt: toUtcIso(`${v.occurredAt}T${v._time || '00:00'}:00`),
|
||
|
|
amount: toSubunit(qty, asset),
|
||
|
|
fiatPrice: existing ? existing.fiatPrice ?? null : null,
|
||
|
|
fiatFee: null, unitFee: null,
|
||
|
|
fromAccountId: null, toAccountId: account,
|
||
|
|
source: existing ? existing.source : 'manual',
|
||
|
|
note: (v.note || '').trim() || null,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
const reload = () => { herlaadRecords(); herlaadStatus(); };
|
||
|
|
const editable = r => r.source === 'manual';
|
||
|
|
|
||
|
|
recordsApi = renderTable(el.querySelector('#tbl'), {
|
||
|
|
columns, rows: await rows(),
|
||
|
|
title: t('interest.recordsTitle'),
|
||
|
|
...(await tableViewCfg('interest', { key: 'occurredAt', dir: 'desc' }, onlyAccount != null ? 'assetId' : null)),
|
||
|
|
emptyText: t('interest.none'),
|
||
|
|
noneLabel: t('table.none'),
|
||
|
|
collapsibleGroups: true,
|
||
|
|
groupSummary: items => {
|
||
|
|
const first = items[0]; if (!first) return '';
|
||
|
|
if (!items.every(r => r.assetId === first.assetId)) return '';
|
||
|
|
const tot = items.reduce((s, r) => s + (r.amount || 0), 0);
|
||
|
|
return val(fmtUnit(tot, meta(first)));
|
||
|
|
},
|
||
|
|
newLabel: t('table.new'),
|
||
|
|
convertLabel: t('convert.title'),
|
||
|
|
...(onlyAccount != null ? { newDefaults: { toAccountId: onlyAccount } } : {}),
|
||
|
|
saveLabel: t('interest.save'), cancelLabel: t('interest.cancel'),
|
||
|
|
rowEditable: editable, rowDeletable: editable,
|
||
|
|
onCreate: async v => { const id = await createEvent(await toPayload(v, null)); recordsApi.refresh(await rows(), { locate: id }); herlaadStatus(); },
|
||
|
|
onSave: async (id, v) => {
|
||
|
|
const ex = (await listInterest()).find(x => x.id === id);
|
||
|
|
await updateEvent(id, await toPayload(v, ex)); reload();
|
||
|
|
},
|
||
|
|
onDelete: async id => {
|
||
|
|
if (!(await dialog.confirm({ message: t('interest.confirmDelete'), danger: true }))) return;
|
||
|
|
await deleteEvent(id); reload();
|
||
|
|
},
|
||
|
|
});
|
||
|
|
recordsApi.focusRow(takeRowFocus('interest'));
|
||
|
|
const zonderPrijs = (await rows()).filter(r => r.fiatPrice == null);
|
||
|
|
loadHistorical(zonderPrijs.map(r => ({ assetId: r.assetId, dateKey: localDate(r.occurredAt) })))
|
||
|
|
.then(async () => { if (recordsApi) recordsApi.refresh(await rows()); }).catch(() => {});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function mountPeriods(host, sched, naWijziging) {
|
||
|
|
if (sched.rateKind !== 'fixed') {
|
||
|
|
host.innerHTML = `<div class="src-drawer"><div class="t-body-sm t-muted">${t('interest.periodsVariable')}</div></div>`;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
host.innerHTML = `<div class="src-drawer">
|
||
|
|
<div data-periods></div>
|
||
|
|
<div class="t-body-sm t-muted mt-sm">${t('interest.periodsBase')} ${esc(sched.startsOn)}: ${pct(sched.annualRate)}</div>
|
||
|
|
</div>`;
|
||
|
|
|
||
|
|
const toPeriod = (v) => {
|
||
|
|
const rate = parseFloat(String(v.annualRate).replace(',', '.'));
|
||
|
|
if (!Number.isFinite(rate)) throw new Error(t('interest.periodRateRequired'));
|
||
|
|
return { startsOn: v.startsOn, annualRate: rate / 100 };
|
||
|
|
};
|
||
|
|
const dagErna = (iso) => {
|
||
|
|
const d = new Date(`${iso}T00:00:00Z`);
|
||
|
|
d.setUTCDate(d.getUTCDate() + 1);
|
||
|
|
return d.toISOString().slice(0, 10);
|
||
|
|
};
|
||
|
|
let perioden = await listPeriods(sched.id);
|
||
|
|
const volgendeStart = () => dagErna(perioden.length ? perioden[perioden.length - 1].startsOn : sched.startsOn);
|
||
|
|
const herlaad = async (api) => { perioden = await listPeriods(sched.id); api.refresh(perioden); };
|
||
|
|
|
||
|
|
const api = renderTable(host.querySelector('[data-periods]'), {
|
||
|
|
columns: [
|
||
|
|
{ key: 'startsOn', label: t('interest.periodStartsOn'), sortable: true,
|
||
|
|
render: r => dayNl(r.startsOn),
|
||
|
|
edit: { type: 'date', value: r => r ? r.startsOn : volgendeStart() } },
|
||
|
|
{ key: 'annualRate', label: t('accounts.schRate'), align: 'num',
|
||
|
|
render: r => pct(r.annualRate),
|
||
|
|
edit: { type: 'number', attrs: { min: 0, step: 'any' }, value: r => r ? r.annualRate * 100 : '' } },
|
||
|
|
],
|
||
|
|
rows: perioden,
|
||
|
|
title: t('interest.periods'),
|
||
|
|
sort: { key: 'startsOn', dir: 'asc' },
|
||
|
|
emptyText: t('interest.noPeriods'),
|
||
|
|
newLabel: t('table.new'),
|
||
|
|
newDefaults: { annualRate: '' },
|
||
|
|
saveLabel: t('accounts.save'), cancelLabel: t('accounts.cancel'),
|
||
|
|
onCreate: async v => { await createPeriod(sched.id, toPeriod(v)); await herlaad(api); naWijziging(); },
|
||
|
|
onSave: async (id, v) => { await updatePeriod(id, toPeriod(v)); await herlaad(api); naWijziging(); },
|
||
|
|
onDelete: async id => {
|
||
|
|
if (!(await dialog.confirm({ message: t('interest.confirmDeletePeriod'), danger: true }))) return;
|
||
|
|
await deletePeriod(id); await herlaad(api); naWijziging();
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|