Files

238 lines
14 KiB
JavaScript
Raw Permalink Normal View History

2026-10-29 11:57:29 +01:00
// Generated, do not edit. sha256:691f92a0fa91d8a9f654cd1214fd7139438481faeed67130424bba664831b55c
import { listSelectableAccounts } from '../data/accounts.js';
import { localDate, localDateNl, localTime, toUtcIso } from '../../shared/time.js';
import { listAssets, listBookableAssets } from '../data/assets.js';
import { listTransfers, createEvent, updateEvent, deleteEvent } from '../data/events.js';
import { listMetalProducts, listMetalProductsForAsset } from '../data/metals.js';
import { tableViewCfg } from '../data/preferences.js';
import { eventLinkMap, onchainAsset } from '../data/tx-links.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, isMetal } from '../ui/format.js';
import { matchDot, wireMatchTriggers } from '../ui/match-popover.js';
import { loadHistorical, fiatHistorical, fiatHistoricalOrigin } from '../ui/fiat.js';
import { originHtml } from '../ui/price-origin.js';
import { copyButton } from '../ui/copy-button.js';
import { 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 tableApi = 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 shortAddr = a => (a && a.length > 16) ? `${a.slice(0, 8)}…${a.slice(-6)}` : (a || '');
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('transfers.none'), {
why: t('transfers.needAsset'), href: targetHash(TARGETS.ASSETS), linkText: t('common.manageAssets'),
})}`;
return;
}
el.innerHTML = `<div id="tbl"></div>`;
if (onlyAccount == null) setPageHint(el, { title: t('nav.transfers'), rows: [t('transfers.fiatNote')] });
buildTable(el);
}
async function assetOptsOfKind(kind) {
return (await listBookableAssets()).filter(a => (!kind || a.kind === kind) && assetAllowed(a))
.map(a => ({ value: a.id, label: `${a.symbol}: ${a.name}` }));
}
async function accountOpts(huidigeId) {
return [{ value: '', label: '-' },
...(await listSelectableAccounts({ ookId: Number(huidigeId) || null })).map(a => ({ value: a.id, label: a.name }))];
}
async function productOpts(assetId) {
const id = Number(assetId);
const opts = [{ value: '', label: t('transfers.looseWeight') }];
if (Number.isInteger(id)) for (const p of await listMetalProductsForAsset(id)) opts.push({ value: p.id, label: p.name });
return opts;
}
async function buildTable(el) {
const rows = async () => (await listTransfers())
.filter(r => (onlyAccount == null || r.fromAccountId === onlyAccount || r.toAccountId === onlyAccount)
&& (onlyAsset == null || r.assetId === onlyAsset));
const meta = r => ({ id: r.assetId, kind: r.asset_kind, decimals: r.asset_decimals, unit: r.asset_unit, subunit: r.asset_subunit, displayUnit: r.asset_display_unit });
const herkomst = r => originHtml(fiatHistoricalOrigin(meta(r), localDate(r.occurredAt)), { ...meta(r), symbol: r.asset_symbol });
const btc = await onchainAsset();
let linkMap = await eventLinkMap();
const today = new Date().toISOString().slice(0, 10);
const alleAssets = await listAssets();
const assetById = id => alleAssets.find(a => a.id === Number(id));
const firstOf = kind => { const a = alleAssets.find(x => x.kind === kind && assetAllowed(x)); return a ? a.id : ''; };
const convertAsset = vals => (vals.productId !== '' && vals.productId != null) ? null : (assetById(vals.assetId) || null);
const dirOf = r => (r.toAccountId === onlyAccount && r.fromAccountId !== onlyAccount ? 'in' : 'out');
const otherOf = r => (dirOf(r) === 'in'
? { id: r.fromAccountId, name: r.from_name }
: { id: r.toAccountId, name: r.to_name });
function productCell(r) {
if (r.productId && r.product_name) return `${esc(r.product_name)} <span class="t-muted-cell">×${r.productCount}</span>`;
if (r.asset_kind === 'metal') return `<span class="t-muted-cell">${t('transfers.looseWeight')}</span>`;
return '';
}
const columns = [
{ key: 'occurredAt', label: t('transfers.colDate'), sortable: true,
render: r => `${btc && r.assetId === btc.id ? matchDot({ matched: linkMap.has(r.id), method: linkMap.get(r.id), kind: 'event', id: r.id }) : ''}${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('transfers.time'),
render: r => localTime(r.occurredAt),
edit: { type: 'time', value: r => r ? localTime(r.occurredAt) : '12:00' } },
{ key: 'assetId', label: t('transfers.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: vals => assetOptsOfKind(vals.asset_kind) } },
{ key: 'asset_kind', label: t('transfers.colKind'), sortable: true,
render: r => t('enums.asset_kind.' + r.asset_kind),
group: { value: r => r.asset_kind, label: v => t('enums.asset_kind.' + v) },
edit: { value: r => r ? r.asset_kind : '', visible: () => false } },
{ key: 'productId', label: t('transfers.colProduct'), sortable: true, sortValue: r => r.product_name || '',
render: productCell,
edit: { type: 'select', sticky: true, reactive: true, visible: vals => vals.asset_kind === 'metal',
options: vals => productOpts(vals.assetId),
value: r => r ? (r.productId ?? '') : '' } },
{ key: 'amount', label: t('transfers.colAmount'), align: 'num', sortable: true, sortValue: r => r.amount,
render: r => { const fiat = fiatHistorical(r.amount, meta(r), localDate(r.occurredAt)); return `${val(fmtUnit(r.amount, meta(r)))}${subline(fiat ? val(fiat) + herkomst(r) : '')}`; },
edit: { type: 'number', attrs: { min: 0, step: 'any' },
value: r => r ? (r.productCount != null ? r.productCount : fromSubunit(r.amount, meta(r))) : '',
convert: convertAsset } },
...(onlyAccount == null ? [
{ key: 'fromAccountId', label: t('transfers.colFrom'), sortable: true, sortValue: r => r.from_name || '',
render: r => r.from_name || '-', group: { value: r => r.from_name || '-', label: v => v },
edit: { type: 'select', sticky: true, options: vals => accountOpts(vals.fromAccountId) } },
{ key: 'toAccountId', label: t('transfers.colTo'), 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) } },
] : [
{ key: '_dir', label: t('transfers.colDirection'), sortable: true, sortValue: r => dirOf(r),
render: r => t(dirOf(r) === 'in' ? 'transfers.dirIn' : 'transfers.dirOut'),
group: { value: r => dirOf(r), label: v => t(v === 'in' ? 'transfers.dirIn' : 'transfers.dirOut') },
edit: { type: 'select', sticky: true, value: r => (r ? dirOf(r) : 'in'),
options: () => [{ value: 'in', label: t('transfers.dirIn') }, { value: 'out', label: t('transfers.dirOut') }] } },
{ key: '_other', label: t('transfers.colOther'), sortable: true, sortValue: r => otherOf(r).name || '',
render: r => esc(otherOf(r).name || '-'),
group: { value: r => otherOf(r).name || '-', label: v => v },
edit: { type: 'select', sticky: true, options: vals => accountOpts(vals._other),
value: r => (r ? otherOf(r).id ?? '' : '') } },
]),
{ key: 'unitFee', label: t('transfers.colFee'), align: 'num', sortable: true, sortValue: r => r.unitFee ?? -1,
render: r => { if (r.unitFee == null) return `-${subline('')}`; const fiat = fiatHistorical(r.unitFee, meta(r), localDate(r.occurredAt)); return `${val(fmtUnit(r.unitFee, meta(r)))}${subline(fiat ? val(fiat) + herkomst(r) : '')}`; },
edit: { type: 'number', attrs: { min: 0, step: 'any' }, value: r => r && r.unitFee != null ? fromSubunit(r.unitFee, meta(r)) : '', convert: convertAsset } },
{ key: 'priceOrigin', label: t('transfers.colOrigin'),
render: r => (r.priceOrigin === 'none'
? `<span class="badge badge-neutral">${t('transfers.originNone')}</span>`
: `<span class="t-muted-cell">-</span>`),
group: { value: r => (r.priceOrigin === 'none' ? t('transfers.originNone') : '-'), label: v => v },
edit: { type: 'select', value: r => (r && r.priceOrigin === 'none' ? 'none' : ''),
options: () => ([
{ value: '', label: t('transfers.originPick') },
{ value: 'none', label: t('transfers.originNonePick') },
]) } },
{ key: 'note', label: t('transfers.colNote'), text: true,
render: r => `<span class="t-muted-cell">${esc(r.note || '')}</span>`,
edit: { type: 'textarea', expand: true } },
{ key: 'externalAddress', label: t('transfers.colAddress'),
render: r => r.externalAddress ? `<span class="t-muted-cell" title="${esc(r.externalAddress)}">${esc(shortAddr(r.externalAddress))}</span>${copyButton(r.externalAddress)}` : '',
edit: { type: 'text', expand: true, placeholder: t('transfers.addressPlaceholder') } },
{ key: 'source', label: t('transfers.colSource'), sortable: true,
render: r => `<span class="t-muted-cell">${r.source ? t('enums.event_source.' + r.source) : '-'}</span>`,
group: { value: r => r.source || 'manual', label: v => t('enums.event_source.' + v) } },
];
async function toPayload(v, existing) {
const asset = alleAssets.find(a => a.id === Number(v.assetId));
if (!v.occurredAt || !asset) throw new Error(t('transfers.fillDateAsset'));
let productId = null, productCount = null, amount;
const wantProduct = isMetal(asset) && v.productId !== '' && v.productId != null;
if (wantProduct) {
const product = (await listMetalProducts()).find(p => p.id === Number(v.productId) && p.assetId === asset.id);
if (!product) throw new Error(t('transfers.pickValidProduct'));
const cnt = parseInt(v.amount, 10);
if (!Number.isInteger(cnt) || cnt <= 0) throw new Error(t('transfers.fillCount'));
productId = product.id; productCount = cnt;
amount = product.fineGrams * cnt;
} else {
const qty = parseFloat(v.amount);
if (!Number.isFinite(qty) || qty <= 0) throw new Error(t('transfers.fillDateAssetQuantity'));
amount = toSubunit(qty, asset);
}
const idOf = x => (x !== '' && x != null ? Number(x) : null);
let from = idOf(v.fromAccountId), to = idOf(v.toAccountId);
if (onlyAccount != null) {
const other = idOf(v._other);
[from, to] = v._dir === 'in' ? [other, onlyAccount] : [onlyAccount, other];
}
if (!from && !to) throw new Error(t('transfers.fillOneAccount'));
if (onlyAccount != null && from !== onlyAccount && to !== onlyAccount) throw new Error(t('transfers.fillThisAccount'));
return {
assetId: asset.id, type: 'transfer',
occurredAt: toUtcIso(`${v.occurredAt}T${v._time || '00:00'}:00`),
amount, fiatPrice: null,
fiatFee: existing ? existing.fiatFee ?? null : null,
unitFee: v.unitFee === '' || v.unitFee == null ? null : toSubunit(parseFloat(v.unitFee), asset),
productId, productCount,
fromAccountId: from, toAccountId: to,
source: existing ? existing.source : 'manual',
note: (v.note || '').trim() || null,
externalAddress: (v.externalAddress || '').trim() || null,
priceOrigin: v.priceOrigin === 'none' ? 'none' : null,
};
}
const reload = async () => tableApi.refresh(await rows());
const historieRijen = await rows();
loadHistorical(historieRijen.map(r => ({ assetId: r.assetId, dateKey: localDate(r.occurredAt) })))
.then(async () => { if (tableApi) tableApi.refresh(await rows()); }).catch(() => {});
tableApi = renderTable(el.querySelector('#tbl'), {
columns, rows: await rows(),
...(await tableViewCfg('transfers', { key: 'occurredAt', dir: 'desc' }, onlyAccount != null ? 'assetId' : null)),
emptyText: t('transfers.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'),
newDefaults: { fromAccountId: '', toAccountId: '', unitFee: '' },
newOptions: [
{ key: 'crypto', label: t('transfers.addCrypto'), defaults: { asset_kind: 'crypto', assetId: firstOf('crypto') } },
{ key: 'metal', label: t('transfers.addMetal'), defaults: { asset_kind: 'metal', assetId: firstOf('metal') } },
{ key: 'fiat', label: t('transfers.addFiat'), defaults: { asset_kind: 'fiat', assetId: firstOf('fiat') } },
].filter(o => o.defaults.assetId !== ''),
saveLabel: t('transfers.save'), cancelLabel: t('transfers.cancel'),
onCreate: async v => { const id = await createEvent(await toPayload(v, null)); tableApi.refresh(await rows(), { locate: id }); },
onSave: async (id, v) => {
const ex = (await listTransfers()).find(x => x.id === id);
await updateEvent(id, await toPayload(v, ex)); reload();
},
onDelete: async id => {
if (!(await dialog.confirm({ message: t('transfers.confirmDelete'), danger: true }))) return;
await deleteEvent(id); reload();
},
});
wireMatchTriggers(el.querySelector('#tbl'), (kind, id) => ({
kind, id, assetId: btc ? btc.id : null,
onChange: async () => { linkMap = await eventLinkMap(); tableApi.refresh(await rows()); },
}));
tableApi.focusRow(takeRowFocus('transfers'));
}