Files
AtreidesAssetsFree/client/actions/reconcile-widget.js
T

363 lines
17 KiB
JavaScript
Raw Permalink Normal View History

2026-10-29 11:57:29 +01:00
// Generated, do not edit. sha256:405d455ffaf6423b7f59a7abc882b3d6e788cead63eb4b179c23c015dd404720
import { listSelectableAccounts } from '../data/accounts.js';
import { dayNl, localDate, localDateTimeNl } from '../../shared/time.js';
import {
listPendingProposals, resolveProposalAsTrade, resolveProposalAsTransfer, resolveProposalAsInternal,
resolveProposalAsInterest, ignoreProposal,
deleteProposal, ignoreAllPendingProposals, deleteAllPendingProposals, revertProposal,
listHandledProposals
} from '../data/proposals.js';
import { syncAddressLinks } from '../data/tx-links.js';
import { listAllWalletSources } from '../data/wallet-sources.js';
import { t } from '../i18n/index.js';
import { assetLabelHtml } from '../ui/asset-label.js';
import * as dialog from '../ui/dialog.js';
import { fmtInt, val } from '../ui/format.js';
import { baseCurrency } from '../app-settings.js';
import { esc } from '../ui/escape.js';
import { taskCardHtml, nothingPendingHtml } from '../ui/task-card.js';
import { isOnchainSource, scanSource } from '../onchain.js';
import { openMatchPopover } from '../ui/match-popover.js';
import { onchainAsset } from '../data/tx-links.js';
import { spotOnDate, spotAtMoment } from '../ui/fiat.js';
import { priceUnitLabel, totalFromPrice, priceFromTotal, priceToBase, priceFromBase } from '../../shared/units.js';
import { expanders } from '../ui/expand.js';
import { bulkPlan, bulkCounts, isForward } from './reconcile-bulk.js';
import { memoLine } from '../lightning/memo-line.js';
let wizIdx = 0;
export async function renderReconcile(host, onChanged) {
const rerender = () => renderReconcile(host, onChanged);
const refresh = () => (onChanged ? onChanged() : rerender());
await syncAddressLinks();
const pending = await listPendingProposals();
const handled = await listHandledProposals();
const expand = await expanders('reconcile', { drawer: false });
if (wizIdx >= pending.length) wizIdx = Math.max(0, pending.length - 1);
const current = pending[wizIdx] || null;
const actions = `<button class="btn btn-primary btn-sm" id="rec-rematch">${t('home.rematch')}</button>`;
const wizard = pending.length === 0
? nothingPendingHtml()
: `<div class="rec-wiz-nav">
<button class="btn btn-ghost btn-xs" id="rec-prev" ${wizIdx === 0 ? 'disabled' : ''}>‹ ${t('home.recPrev')}</button>
<span class="t-body-sm t-muted">${wizIdx + 1} / ${pending.length}</span>
<button class="btn btn-ghost btn-xs" id="rec-next" ${wizIdx >= pending.length - 1 ? 'disabled' : ''}>${t('home.recNext')} ›</button>
</div>
${recRow(current, true, current.id)}`;
host.innerHTML = taskCardHtml({
title: t('home.reconcile'),
actions,
body: `<div id="rec-progress" class="t-body-sm t-muted" style="margin-bottom:0.6rem"></div>
${wizard}
${drawerSection(pending, handled, current, expand)}`,
});
const prev = host.querySelector('#rec-prev');
if (prev) prev.onclick = () => { wizIdx = Math.max(0, wizIdx - 1); rerender(); };
const next = host.querySelector('#rec-next');
if (next) next.onclick = () => { wizIdx = Math.min(pending.length - 1, wizIdx + 1); rerender(); };
expand.bind(host);
const igAll = host.querySelector('#rec-ignore-all');
if (igAll) igAll.onclick = async () => {
if (!(await dialog.confirm({ message: t('home.recIgnoreAllConfirm') }))) return;
await ignoreAllPendingProposals(); refresh();
};
const bookAll = host.querySelector('#rec-book-all');
if (bookAll) bookAll.onclick = () => bookAllLightning(host, pending, refresh);
const delAll = host.querySelector('#rec-delete-all');
if (delAll) delAll.onclick = async () => {
if (!(await dialog.confirm({ message: t('home.recDeleteAllConfirm'), danger: true }))) return;
await deleteAllPendingProposals(); refresh();
};
host.querySelectorAll('[data-prop]').forEach(row => {
const id = Number(row.dataset.prop);
const form = row.querySelector('.prop-form');
row.querySelectorAll('[data-act]').forEach(btn => btn.onclick = async () => {
const act = btn.dataset.act;
if (act === 'ignore') { await ignoreProposal(id); refresh(); return; }
if (act === 'internal' || act === 'interest') {
try { await (act === 'internal' ? resolveProposalAsInternal(id) : resolveProposalAsInterest(id)); refresh(); }
catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); }
return;
}
if (act === 'link') {
openMatchPopover(btn, { kind: 'tx', id: Number(row.dataset.wtx), assetId: Number(row.dataset.asset), onChange: refresh });
return;
}
if (act === 'trade') { showTradeForm(refresh, pending.find(x => x.id === id), form); return; }
await showTransferForm(refresh, id, form);
});
});
host.querySelectorAll('[data-del-prop]').forEach(btn => btn.onclick = async () => {
if (!(await dialog.confirm({ message: t('home.recDeleteConfirm'), danger: true }))) return;
await deleteProposal(Number(btn.dataset.delProp)); refresh();
});
host.querySelectorAll('[data-revert]').forEach(btn => btn.onclick = async () => {
if (!(await dialog.confirm({ message: t('home.recRevertConfirm') }))) return;
try { await revertProposal(Number(btn.dataset.revert)); refresh(); }
catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); }
});
const rematch = host.querySelector('#rec-rematch');
if (rematch) rematch.onclick = () => runUpdate(host, refresh);
return pending.length;
}
function shortTx(h) { return h ? `${h.slice(0, 8)}…${h.slice(-4)}` : ''; }
function momentCel(p) {
const bevestigd = !!p.tx_block_time;
const wanneer = bevestigd ? p.tx_block_time : p.detectedAt;
const uitleg = bevestigd ? t('home.recTxAt') : t('home.recDetectedAt');
return `<span class="t-muted-cell" title="${esc(uitleg)}">(${esc(localDateTimeNl(wanneer))})</span>`;
}
function recRow(p, wiz, currentId) {
const dir = p.direction === 'in' ? t('home.recIn') : t('home.recOut');
const wtxAttrs = p.walletTransactionId ? ` data-wtx="${p.walletTransactionId}" data-asset="${p.assetId}"` : '';
const linkBtn = p.walletTransactionId ? `<button class="btn btn-secondary btn-xs" data-act="link">${t('home.recLink')}</button>` : '';
const cls = wiz ? 'rec-item rec-wiz-item' : `rec-item${p.id === currentId ? ' is-current' : ''}`;
const laag = p.tx_layer === 'lightning'
? ` · ${t('home.recLightning')}${p.via_wallet != null
? ` ${t('home.recVia', { wallet: p.via_wallet || t('home.recViaNwc') })}` : ''}`
: '';
const intern = p.tx_kind ? ` · ${t(`home.recInternal_${p.tx_kind}`)}` : '';
const internLabel = p.tx_node_account
? t('home.recAsChannelTransfer', { account: p.tx_node_account }) : t('home.recAsInternal');
const internBtn = p.tx_kind
? `<button class="btn btn-primary btn-xs" data-act="internal" title="${esc(t('home.recInternalHint'))}">${esc(internLabel)}</button>` : '';
const forward = isForward(p);
const renteBtn = forward
? `<button class="btn btn-primary btn-xs" data-act="interest" title="${esc(t('home.recInterestHint'))}">${t('home.recAsInterest')}</button>` : '';
const eerste = p.tx_kind || forward;
const memo = memoLine(p.tx_memo, p.direction, 120);
const memoHtml = memo ? `<div class="t-body-sm t-muted" title="${esc(memo.title)}">${esc(memo.text)}</div>` : '';
return `<div class="${cls}" data-prop="${p.id}"${wtxAttrs}>
<div class="rec-item-row">
<div class="rec-item-meta"><strong>${val(`${fmtInt(p.amount)} sat`)}</strong> ${assetLabelHtml(p)} ${dir}${esc(laag)}${esc(intern)} · ${esc(p.account_name)}
${momentCel(p)}${p.transactionHash ? ` <span class="t-muted-cell" title="${esc(p.transactionHash)}">· ${esc(shortTx(p.transactionHash))}</span>` : ''}${memoHtml}</div>
<div class="row-actions rec-hover">
${linkBtn}
${internBtn}${renteBtn}
<button class="btn ${eerste ? 'btn-secondary' : 'btn-primary'} btn-xs" data-act="trade">${t('home.recAsTrade')}</button>
<button class="btn btn-secondary btn-xs" data-act="transfer">${t('home.recAsTransfer')}</button>
<button class="btn btn-ghost btn-xs" data-act="ignore">${t('home.recIgnore')}</button>
<button class="btn btn-ghost btn-xs" data-del-prop="${p.id}" title="${t('home.recDelete')}">✕</button>
</div>
</div>
<div class="prop-form mt-sm hidden"></div>
</div>`;
}
function drawerSection(pending, handled, current, expand) {
const total = pending.length + handled.length;
const teBoeken = bulkPlan(pending).filter(s => s.action !== 'skip').length;
const bulk = `<div class="rec-bulk">
${teBoeken ? `<button class="btn btn-primary btn-xs" id="rec-book-all">${t('home.recBookAll', { n: teBoeken })}</button>` : ''}
<button class="btn btn-secondary btn-xs" id="rec-ignore-all" ${pending.length ? '' : 'disabled'}>${t('home.recIgnoreAll')}</button>
<button class="btn btn-danger btn-xs" id="rec-delete-all" ${pending.length ? '' : 'disabled'}>${t('home.recDeleteAll')}</button>
</div>`;
const rows = total === 0
? `<div class="t-body-sm t-muted" style="padding:0.5rem 0">${t('home.recHandledNone')}</div>`
: pending.map(p => recRow(p, false, current ? current.id : 0)).join('') + handled.map(handledRow).join('');
return `<details class="mt-lg" ${expand.attr('drawer')}>
<summary class="t-label" style="cursor:pointer">${t('home.recAll')} (${total})</summary>
${bulk}
<div class="mt-sm">${rows}</div>
</details>`;
}
const STATUS_BADGE = {
accepted_trade: 'badge-pos',
accepted_transfer: 'badge-pos',
accepted_interest: 'badge-pos',
ignored: 'badge-neutral',
auto_linked: 'badge-outline',
};
const REVERTIBLE = new Set(['accepted_trade', 'accepted_transfer', 'accepted_interest', 'ignored']);
function handledRow(p) {
const dir = p.direction === 'in' ? t('home.recIn') : t('home.recOut');
const badge = `<span class="badge ${STATUS_BADGE[p.status] || 'badge-neutral'}">${t('enums.reconcile_status.' + p.status)}</span>`;
const revert = REVERTIBLE.has(p.status)
? `<button class="btn btn-ghost btn-xs" data-revert="${p.id}">${t('home.recRevert')}</button>` : '';
const del = p.status !== 'auto_linked'
? `<button class="btn btn-ghost btn-xs" data-del-prop="${p.id}" title="${t('home.recDelete')}">✕</button>` : '';
return `<div class="rec-item rec-handled">
<div class="rec-item-row">
<div class="rec-item-meta"><strong>${val(`${fmtInt(p.amount)} sat`)}</strong> ${assetLabelHtml(p)} ${dir} · ${esc(p.account_name)}
${momentCel(p)}${p.transactionHash ? ` <span class="t-muted-cell" title="${esc(p.transactionHash)}">· ${esc(shortTx(p.transactionHash))}</span>` : ''}</div>
<div class="row-actions"><span class="row-actions rec-hover">${revert}${del}</span>${badge}</div>
</div>
</div>`;
}
let updating = false;
async function runUpdate(host, refresh) {
if (updating) return;
updating = true;
const btn = host.querySelector('#rec-rematch');
const prog = host.querySelector('#rec-progress');
if (btn) btn.disabled = true;
try {
for (const s of (await listAllWalletSources()).filter(isOnchainSource)) {
const label = s.name ? `${s.account_name} · ${s.name}` : s.account_name;
if (prog) prog.textContent = `${t('home.updating')} ${label}`;
try { await scanSource(s, txt => { if (prog) prog.textContent = `${label}: ${txt}`; }); }
catch (e) { console.error('[reconcile-update]', e); }
}
await syncAddressLinks();
} finally {
updating = false;
}
refresh();
}
async function bookAllLightning(host, pending, refresh) {
const plan = bulkPlan(pending);
const n = bulkCounts(plan);
const ok = await dialog.confirm({
message: t('home.recBookAllConfirm', { trade: n.trade, internal: n.internal, interest: n.interest, skipped: n.onchain }),
});
if (!ok) return;
const prog = host.querySelector('#rec-progress');
const teller = { booked: 0, noPrice: 0, failed: 0 };
const werk = plan.filter(s => s.action !== 'skip');
for (const [i, s] of werk.entries()) {
if (prog) prog.textContent = t('home.recBookAllProgress', { i: i + 1, n: werk.length });
try {
if (s.action === 'internal') {
await resolveProposalAsInternal(s.id);
} else if (s.action === 'interest') {
await resolveProposalAsInterest(s.id);
} else {
const k = await koersVoor(s.proposal);
if (!k) { teller.noPrice++; continue; }
await resolveProposalAsTrade(s.id, { fiatPrice: k.price, priceOrigin: k.origin });
}
teller.booked++;
} catch (e) {
teller.failed++;
console.error('[reconcile-book-all]', e);
}
}
await dialog.alert({ title: t('home.recBookAllDoneTitle'), message: t('home.recBookAllDone', teller) });
refresh();
}
async function koersVoor(p) {
const asset = await onchainAsset();
if (!asset || asset.id !== p.assetId) return null;
const dag = localDate(p.tx_block_time || p.detectedAt);
const rond = n => Math.round(n * 100) / 100;
const moment = p.tx_block_time ? await spotAtMoment(asset, p.tx_block_time) : null;
if (moment) return { price: rond(moment.price), origin: 'market_moment', at: moment.at, dag, asset };
const koers = await spotOnDate(asset, dag);
return koers == null ? null : { price: rond(koers), origin: 'market_day', dag, asset };
}
async function vulKoers(p, form) {
const input = form.querySelector('[data-price]');
const hint = form.querySelector('[data-spot]');
hint.textContent = t('home.recPriceLoading');
try {
const k = await koersVoor(p);
if (!k) { hint.textContent = t('home.recPriceUnknown'); return null; }
input.value = String(k.price);
input.dataset.filled = input.value;
input.dataset.origin = k.origin;
input.dispatchEvent(new Event('input', { bubbles: true }));
hint.textContent = k.origin === 'market_moment'
? t('home.recPriceAt', { time: localDateTimeNl(new Date(k.at).toISOString()), unit: priceUnitLabel(k.asset) })
: t('home.recPriceFrom', { date: dayNl(k.dag), unit: priceUnitLabel(k.asset) });
return k.price;
} catch {
hint.textContent = t('home.recPriceUnknown');
return null;
}
}
function koppelBedragEnPrijs(p, form) {
const prijsEl = form.querySelector('[data-price]');
const totaalEl = form.querySelector('[data-total]');
onchainAsset().then(asset => {
if (!asset || asset.id !== p.assetId) return;
const dec = asset.decimals;
const rond = n => (n == null ? '' : String(Math.round(n * 100) / 100));
const uitPrijs = () => {
const basis = totalFromPrice(p.amount, dec, priceToBase(prijsEl.value, asset));
totaalEl.value = rond(basis);
};
const uitTotaal = () => {
const prijs = priceFromTotal(totaalEl.value, p.amount, dec);
prijsEl.value = prijs === null ? '' : rond(priceFromBase(prijs, asset));
};
prijsEl.addEventListener('input', uitPrijs);
totaalEl.addEventListener('input', uitTotaal);
if (prijsEl.value !== '') uitPrijs();
}).catch(() => { });
}
function showTradeForm(refresh, p, form) {
if (!p) return;
form.classList.remove('hidden');
form.innerHTML = `
<div class="flex gap-md" style="flex-wrap:wrap;align-items:flex-end">
<div class="form-group" style="margin-bottom:0">
<label class="form-label">${t('home.recTotal', { munt: baseCurrency() })}</label>
<input type="number" class="input" data-total min="0" step="any" style="min-width:140px">
</div>
<div class="form-group" style="margin-bottom:0">
<label class="form-label">${t('home.recPrice', { munt: baseCurrency() })}</label>
<input type="number" class="input" data-price min="0" step="any" style="min-width:160px">
<div class="form-hint" data-spot></div>
</div>
<button class="btn btn-primary btn-sm" data-confirm>${t('home.recConfirm')}</button>
</div>
<div class="form-error" data-err></div>`;
vulKoers(p, form);
koppelBedragEnPrijs(p, form);
form.querySelector('[data-confirm]').onclick = async () => {
const err = form.querySelector('[data-err]'); err.textContent = '';
const prijsEl = form.querySelector('[data-price]');
const veld = prijsEl.value;
const price = veld === '' ? await vulKoers(p, form) : parseFloat(veld);
if (!Number.isFinite(price)) { err.textContent = t('home.recPriceRequired'); return; }
const gevuld = prijsEl.dataset.filled;
const priceOrigin = gevuld !== undefined && Number(gevuld) === price ? (prijsEl.dataset.origin || 'market_day') : 'paid';
try { await resolveProposalAsTrade(p.id, { fiatPrice: price, priceOrigin }); refresh(); }
catch (e) { err.textContent = e.message; }
};
}
async function showTransferForm(refresh, id, form) {
form.classList.remove('hidden');
const opts = (await listSelectableAccounts()).map(a => `<option value="${a.id}">${esc(a.name)}</option>`).join('');
form.innerHTML = `
<div class="flex gap-md" style="flex-wrap:wrap;align-items:flex-end">
<div class="form-group" style="margin-bottom:0">
<label class="form-label">${t('home.recCounter')}</label>
<select class="input" data-counter style="min-width:180px">${opts}</select>
</div>
<button class="btn btn-primary btn-sm" data-confirm>${t('home.recConfirm')}</button>
</div>
<div class="form-error" data-err></div>`;
form.querySelector('[data-confirm]').onclick = async () => {
const err = form.querySelector('[data-err]'); err.textContent = '';
const other = Number(form.querySelector('[data-counter]').value);
try { await resolveProposalAsTransfer(id, { other_account_id: other }); refresh(); }
catch (e) { err.textContent = e.message; }
};
}