Add bidirectional FX rate entry

Users can now enter the exchange rate from either direction:
- "35 THB per USD" (foreign per base, the canonical form)
- "0.02857 USD per THB" (base per foreign, inverted display)

A compact inline select next to the rate field lets the user choose
which currency is in the numerator; the "per X" label updates to show
the denominator. Switching direction re-displays the current rate
inverted — the stored canonical (foreign/base) is unchanged, so
recalc(), fxRateMemory, and PDF output are unaffected.

Persists fxDir alongside fxRate in localStorage/IndexedDB.

https://claude.ai/code/session_01JyuActqTJG5tuRQNLmT7fZ
This commit is contained in:
Claude 2026-06-08 04:40:02 +00:00
parent 0a84ba4628
commit e371505323
No known key found for this signature in database

View file

@ -521,7 +521,7 @@ function newItem() { return { id: uid(), name: '', lines: [newLine()], _subtotal
function newLine() { function newLine() {
return { return {
id: uid(), date: state.periodFrom || '', description: '', currency: state.baseCurrency, id: uid(), date: state.periodFrom || '', description: '', currency: state.baseCurrency,
fxRate: '1.00000', vendor: '', hasReceipt: true, receipts: [], fxRate: '1.00000', fxDir: 'foreign', vendor: '', hasReceipt: true, receipts: [],
noReceiptExplanation: '', amount: '', account: '', customCurrency: false, noReceiptExplanation: '', amount: '', account: '', customCurrency: false,
programs: [{ program: '', percent: '', programOther: '' }] programs: [{ program: '', percent: '', programOther: '' }]
}; };
@ -563,7 +563,7 @@ async function saveState() {
id: item.id, name: item.name, id: item.id, name: item.name,
lines: item.lines.map(ln => ({ lines: item.lines.map(ln => ({
id: ln.id, date: ln.date, description: ln.description, id: ln.id, date: ln.date, description: ln.description,
currency: ln.currency, fxRate: ln.fxRate, vendor: ln.vendor, currency: ln.currency, fxRate: ln.fxRate, fxDir: ln.fxDir || 'foreign', vendor: ln.vendor,
hasReceipt: ln.hasReceipt, noReceiptExplanation: ln.noReceiptExplanation, hasReceipt: ln.hasReceipt, noReceiptExplanation: ln.noReceiptExplanation,
amount: ln.amount, account: ln.account, customCurrency: ln.customCurrency || false, amount: ln.amount, account: ln.account, customCurrency: ln.customCurrency || false,
programs: ln.programs, programs: ln.programs,
@ -599,7 +599,7 @@ async function loadState() {
for (const ld of (id.lines || [])) { for (const ld of (id.lines || [])) {
const ln = { const ln = {
id: ld.id, date: ld.date, description: ld.description, id: ld.id, date: ld.date, description: ld.description,
currency: ld.currency, fxRate: ld.fxRate, vendor: ld.vendor, currency: ld.currency, fxRate: ld.fxRate, fxDir: ld.fxDir || 'foreign', vendor: ld.vendor,
hasReceipt: ld.hasReceipt, receipts: [], hasReceipt: ld.hasReceipt, receipts: [],
noReceiptExplanation: ld.noReceiptExplanation, noReceiptExplanation: ld.noReceiptExplanation,
amount: ld.amount, account: ld.account, amount: ld.amount, account: ld.account,
@ -933,19 +933,60 @@ function renderLine(ln, item) {
const vendIn = el('input', {className:'kb-input', type:'text', value: ln.vendor, placeholder:'Vendor name'}); const vendIn = el('input', {className:'kb-input', type:'text', value: ln.vendor, placeholder:'Vendor name'});
vendIn.addEventListener('input', () => { ln.vendor = vendIn.value; }); vendIn.addEventListener('input', () => { ln.vendor = vendIn.value; });
// FX // FX — bidirectional rate entry
const fxIn = el('input', {className:'kb-input num', type:'text', placeholder:'0.00000', style:{minWidth:'0'}}); // Canonical: ln.fxRate is always "foreign per 1 base" (divide to get base amount).
fxIn.value = ln.fxRate; // ln.fxDir controls display direction: 'foreign' = show canonical, 'base' = show 1/canonical.
const fxIn = el('input', {className:'kb-input num', type:'text', placeholder:'0.00000', style:{minWidth:'0', width:'90px'}});
const fxDirSel = el('select', {className:'kb-select', style:{padding:'0 20px 0 6px', fontSize:'var(--fs-small)', minWidth:'0', width:'auto'}});
const fxPerLbl = el('span', {style:{fontSize:'var(--fs-small)', color:'var(--text-muted)', whiteSpace:'nowrap', userSelect:'none'}});
const fxWrap = el('div', {style:{display:'flex', alignItems:'center', gap:'4px'}});
fxWrap.append(fxIn, fxDirSel, fxPerLbl);
function fxDisplayVal() {
if (!ln.fxRate || ln.fxRate === '' || ln.fxRate === '1.00000') return ln.fxRate;
const r = parseFloat(ln.fxRate);
if (ln.fxDir === 'base' && r > 0) return (1 / r).toFixed(5);
return ln.fxRate;
}
function fxStoreFromDisplay(displayVal) {
const v = parseFloat(displayVal);
if (isNaN(v) || v <= 0) { ln.fxRate = displayVal; return; }
ln.fxRate = ln.fxDir === 'base' ? (1 / v).toFixed(5) : displayVal;
}
function rebuildFxDirSel() {
const fcy = ln.currency || '???';
fxDirSel.innerHTML = '';
fxDirSel.append(
el('option', {value:'foreign'}, fcy),
el('option', {value:'base'}, baseCur)
);
fxDirSel.value = ln.fxDir || 'foreign';
fxPerLbl.textContent = 'per ' + (fxDirSel.value === 'foreign' ? baseCur : fcy);
}
function showFxControls(show) {
fxDirSel.style.display = show ? '' : 'none';
fxPerLbl.style.display = show ? '' : 'none';
}
fxIn.value = fxDisplayVal();
fxIn.readOnly = !ln.customCurrency && ln.currency === baseCur; fxIn.readOnly = !ln.customCurrency && ln.currency === baseCur;
if (fxIn.readOnly) fxIn.className = 'kb-input num'; rebuildFxDirSel();
showFxControls(ln.currency !== baseCur);
fxIn.addEventListener('input', () => { fxIn.addEventListener('input', () => {
ln.fxRate = fxIn.value; fxStoreFromDisplay(fxIn.value);
const rate = parseFloat(fxIn.value); const rate = parseFloat(ln.fxRate);
if (ln.currency && ln.currency !== baseCur && rate > 0) state.fxRateMemory[ln.currency] = fxIn.value; if (ln.currency && ln.currency !== baseCur && rate > 0) state.fxRateMemory[ln.currency] = ln.fxRate;
recalc(); recalc();
if (progAreaEl) progAreaEl._refresh(); if (progAreaEl) progAreaEl._refresh();
}); });
fxDirSel.addEventListener('change', () => {
ln.fxDir = fxDirSel.value;
fxIn.value = fxDisplayVal();
fxPerLbl.textContent = 'per ' + (ln.fxDir === 'foreign' ? baseCur : ln.currency || '???');
});
async function showFxModal(code) { async function showFxModal(code) {
const bn = getCurrencyName(baseCur); const bn = getCurrencyName(baseCur);
const isOther = !code || code === 'Other'; const isOther = !code || code === 'Other';
@ -958,12 +999,17 @@ function renderLine(ln, item) {
function applyFxCurrency(code) { function applyFxCurrency(code) {
if (code === baseCur) { if (code === baseCur) {
ln.fxRate = '1.00000'; fxIn.value = '1.00000'; fxIn.readOnly = true; ln.fxRate = '1.00000'; ln.fxDir = 'foreign';
fxIn.value = '1.00000'; fxIn.readOnly = true;
showFxControls(false);
} else { } else {
fxIn.readOnly = false; fxIn.readOnly = false;
const mem = state.fxRateMemory[code]; const mem = state.fxRateMemory[code];
if (mem) { ln.fxRate = mem; fxIn.value = mem; } if (mem) { ln.fxRate = mem; }
else if (ln.fxRate === '1.00000') { ln.fxRate = ''; fxIn.value = ''; } else if (ln.fxRate === '1.00000') { ln.fxRate = ''; }
rebuildFxDirSel();
fxIn.value = fxDisplayVal();
showFxControls(true);
} }
recalc(); recalc();
if (progAreaEl) progAreaEl._refresh(); if (progAreaEl) progAreaEl._refresh();
@ -1019,7 +1065,7 @@ function renderLine(ln, item) {
kbField('Date', dateIn), kbField('Date', dateIn),
kbField('Vendor', vendIn), kbField('Vendor', vendIn),
kbField('Currency', curWrap), kbField('Currency', curWrap),
kbField('FX rate', fxIn) kbField('FX rate', fxWrap)
); );
blk.appendChild(row1); blk.appendChild(row1);