Files
AtreidesAssetsFree/client/pages/transfers.js
T
2026-10-29 11:57:29 +01:00

239 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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'));
}