mirror of
https://github.com/kbenestad/reimburse.git
synced 2026-06-18 08:04:31 +00:00
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:
parent
0a84ba4628
commit
e371505323
1 changed files with 60 additions and 14 deletions
|
|
@ -521,7 +521,7 @@ function newItem() { return { id: uid(), name: '', lines: [newLine()], _subtotal
|
|||
function newLine() {
|
||||
return {
|
||||
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,
|
||||
programs: [{ program: '', percent: '', programOther: '' }]
|
||||
};
|
||||
|
|
@ -563,7 +563,7 @@ async function saveState() {
|
|||
id: item.id, name: item.name,
|
||||
lines: item.lines.map(ln => ({
|
||||
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,
|
||||
amount: ln.amount, account: ln.account, customCurrency: ln.customCurrency || false,
|
||||
programs: ln.programs,
|
||||
|
|
@ -599,7 +599,7 @@ async function loadState() {
|
|||
for (const ld of (id.lines || [])) {
|
||||
const ln = {
|
||||
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: [],
|
||||
noReceiptExplanation: ld.noReceiptExplanation,
|
||||
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'});
|
||||
vendIn.addEventListener('input', () => { ln.vendor = vendIn.value; });
|
||||
|
||||
// FX
|
||||
const fxIn = el('input', {className:'kb-input num', type:'text', placeholder:'0.00000', style:{minWidth:'0'}});
|
||||
fxIn.value = ln.fxRate;
|
||||
// FX — bidirectional rate entry
|
||||
// Canonical: ln.fxRate is always "foreign per 1 base" (divide to get base amount).
|
||||
// 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;
|
||||
if (fxIn.readOnly) fxIn.className = 'kb-input num';
|
||||
rebuildFxDirSel();
|
||||
showFxControls(ln.currency !== baseCur);
|
||||
|
||||
fxIn.addEventListener('input', () => {
|
||||
ln.fxRate = fxIn.value;
|
||||
const rate = parseFloat(fxIn.value);
|
||||
if (ln.currency && ln.currency !== baseCur && rate > 0) state.fxRateMemory[ln.currency] = fxIn.value;
|
||||
fxStoreFromDisplay(fxIn.value);
|
||||
const rate = parseFloat(ln.fxRate);
|
||||
if (ln.currency && ln.currency !== baseCur && rate > 0) state.fxRateMemory[ln.currency] = ln.fxRate;
|
||||
recalc();
|
||||
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) {
|
||||
const bn = getCurrencyName(baseCur);
|
||||
const isOther = !code || code === 'Other';
|
||||
|
|
@ -958,12 +999,17 @@ function renderLine(ln, item) {
|
|||
|
||||
function applyFxCurrency(code) {
|
||||
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 {
|
||||
fxIn.readOnly = false;
|
||||
const mem = state.fxRateMemory[code];
|
||||
if (mem) { ln.fxRate = mem; fxIn.value = mem; }
|
||||
else if (ln.fxRate === '1.00000') { ln.fxRate = ''; fxIn.value = ''; }
|
||||
if (mem) { ln.fxRate = mem; }
|
||||
else if (ln.fxRate === '1.00000') { ln.fxRate = ''; }
|
||||
rebuildFxDirSel();
|
||||
fxIn.value = fxDisplayVal();
|
||||
showFxControls(true);
|
||||
}
|
||||
recalc();
|
||||
if (progAreaEl) progAreaEl._refresh();
|
||||
|
|
@ -1019,7 +1065,7 @@ function renderLine(ln, item) {
|
|||
kbField('Date', dateIn),
|
||||
kbField('Vendor', vendIn),
|
||||
kbField('Currency', curWrap),
|
||||
kbField('FX rate', fxIn)
|
||||
kbField('FX rate', fxWrap)
|
||||
);
|
||||
blk.appendChild(row1);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue