mirror of
https://github.com/kbenestad/invoice.git
synced 2026-06-18 16:14:33 +00:00
Add bidirectional FX rate entry
Replace the fixed-convention exchange rate label with an inline expression:
[amount] <currency> per {other}, where the currency dropdown lets users
pick which side of the rate to enter. Covers both "35 THB per USD" and
"0.028 USD per THB" without requiring mental inversion.
Formula: price_local = per * rate when local currency is in numerator,
price_local = per / rate when foreign currency is in numerator.
rcur is persisted to localStorage and included in the PDF note.
https://claude.ai/code/session_0151QtsUhzXmgzEhSvXG2SDt
This commit is contained in:
parent
8cdb4bd72b
commit
501404ed7f
2 changed files with 39 additions and 13 deletions
|
|
@ -60,7 +60,7 @@ Do not break these behaviours:
|
||||||
- **`addLine()` must `return i`** — `loadLines()` depends on the returned ID.
|
- **`addLine()` must `return i`** — `loadLines()` depends on the returned ID.
|
||||||
- **`saveStorage()`** is triggered by `change` events on all `[data-ls]` elements; do not remove that listener.
|
- **`saveStorage()`** is triggered by `change` events on all `[data-ls]` elements; do not remove that listener.
|
||||||
- **Invoice number bump:** occurs on the next page load after Generate via the `inv_generated_v1` flag; do not bump on Generate itself.
|
- **Invoice number bump:** occurs on the next page load after Generate via the `inv_generated_v1` flag; do not bump on Generate itself.
|
||||||
- **FX rate convention:** 1 foreign unit = X local units (market-standard quote). `price_local = price_foreign * exchange_rate`.
|
- **FX rate convention:** User picks which currency goes in the numerator via `rcur` dropdown. If `rcur === lcy`: `price_local = per * rate`. If `rcur === fcy`: `price_local = per / rate`.
|
||||||
|
|
||||||
## Config structure (`config.yml`)
|
## Config structure (`config.yml`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -636,7 +636,7 @@ function buildForm() {
|
||||||
document.querySelectorAll("[data-ls]").forEach(el => el.addEventListener("change", saveStorage));
|
document.querySelectorAll("[data-ls]").forEach(el => el.addEventListener("change", saveStorage));
|
||||||
document.getElementById("icur").addEventListener("change", () => {
|
document.getElementById("icur").addEventListener("change", () => {
|
||||||
Object.keys(lines).forEach(k => {
|
Object.keys(lines).forEach(k => {
|
||||||
if (document.getElementById(`frate-lbl-${k}`)) updateFxLabels(+k);
|
if (document.getElementById(`rcur-${k}`)) updateFxLabels(+k);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
calcPayBy(); // compute initial pay-by from default 7-day term
|
calcPayBy(); // compute initial pay-by from default 7-day term
|
||||||
|
|
@ -881,8 +881,16 @@ function toggleFx(i) {
|
||||||
<select id="fcur-${i}" onchange="updateFxLabels(${i});calcLine(${i});saveLines()">${currOpts(defaultFcy)}</select>
|
<select id="fcur-${i}" onchange="updateFxLabels(${i});calcLine(${i});saveLines()">${currOpts(defaultFcy)}</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="fx-label" id="frate-lbl-${i}">Exchange rate (1 ${h(defaultFcy)} = X ${h(lcy)})</div>
|
<div class="fx-label">Exchange rate</div>
|
||||||
<input type="number" id="frate-${i}" value="" min="0" step="any" oninput="calcFxFromPer(${i})">
|
<div style="display:flex;align-items:center;gap:4px;flex-wrap:wrap">
|
||||||
|
<input type="number" id="frate-${i}" value="" min="0" step="any" style="width:80px" oninput="calcFxFromPer(${i})">
|
||||||
|
<select id="rcur-${i}" style="width:auto" onchange="updateFxLabels(${i});calcFxFromPer(${i});saveLines()">
|
||||||
|
<option value="${h(lcy)}">${h(lcy)}</option>
|
||||||
|
<option value="${h(defaultFcy)}">${h(defaultFcy)}</option>
|
||||||
|
</select>
|
||||||
|
<span class="fx-label" style="margin:0">per</span>
|
||||||
|
<span id="rother-${i}" style="font-size:11px;font-weight:600">${h(defaultFcy)}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="fx-label" id="fper-lbl-${i}">Price per item in ${h(defaultFcy)}</div>
|
<div class="fx-label" id="fper-lbl-${i}">Price per item in ${h(defaultFcy)}</div>
|
||||||
|
|
@ -899,8 +907,12 @@ function toggleFx(i) {
|
||||||
function calcFxFromPer(i) {
|
function calcFxFromPer(i) {
|
||||||
const per = pn(document.getElementById(`fper-${i}`)?.value);
|
const per = pn(document.getElementById(`fper-${i}`)?.value);
|
||||||
const rate = pn(document.getElementById(`frate-${i}`)?.value) || 1;
|
const rate = pn(document.getElementById(`frate-${i}`)?.value) || 1;
|
||||||
|
const rcur = document.getElementById(`rcur-${i}`)?.value;
|
||||||
|
const lcy = document.getElementById("icur")?.value || "";
|
||||||
const prEl = document.getElementById(`price-${i}`);
|
const prEl = document.getElementById(`price-${i}`);
|
||||||
if (prEl) prEl.value = (per * rate).toFixed(6);
|
if (prEl) prEl.value = rcur === lcy
|
||||||
|
? (per * rate).toFixed(6)
|
||||||
|
: (per / rate).toFixed(6);
|
||||||
calcLine(i);
|
calcLine(i);
|
||||||
saveLines();
|
saveLines();
|
||||||
}
|
}
|
||||||
|
|
@ -1145,10 +1157,11 @@ function gatherData(renderLang) {
|
||||||
let fxNote = null;
|
let fxNote = null;
|
||||||
if (isFx) {
|
if (isFx) {
|
||||||
const cur = document.getElementById(`fcur-${i}`)?.value || "";
|
const cur = document.getElementById(`fcur-${i}`)?.value || "";
|
||||||
|
const rcur = document.getElementById(`rcur-${i}`)?.value || "";
|
||||||
const rate = pn(document.getElementById(`frate-${i}`)?.value) || 1;
|
const rate = pn(document.getElementById(`frate-${i}`)?.value) || 1;
|
||||||
const per = pn(document.getElementById(`fper-${i}`)?.value);
|
const per = pn(document.getElementById(`fper-${i}`)?.value);
|
||||||
const foreignTot = per * qty; // line total in foreign currency
|
const foreignTot = per * qty; // line total in foreign currency
|
||||||
fxNote = { cur, rate, per, foreignTot, td };
|
fxNote = { cur, rcur, rate, per, foreignTot, td };
|
||||||
}
|
}
|
||||||
rows.push({ qty, uomLbl, desc, price, tot, fxNote });
|
rows.push({ qty, uomLbl, desc, price, tot, fxNote });
|
||||||
});
|
});
|
||||||
|
|
@ -1326,8 +1339,9 @@ function buildPDF() {
|
||||||
const descH = Math.max(0, (dLines.length - 1) * 3.8);
|
const descH = Math.max(0, (dLines.length - 1) * 3.8);
|
||||||
let fxH = 0;
|
let fxH = 0;
|
||||||
if (row.fxNote) {
|
if (row.fxNote) {
|
||||||
|
const fxOther = row.fxNote.rcur === iCur ? row.fxNote.cur : iCur;
|
||||||
const fxStr = `${td("per-item")}: ${row.fxNote.cur} ${fmt(row.fxNote.per)} `
|
const fxStr = `${td("per-item")}: ${row.fxNote.cur} ${fmt(row.fxNote.per)} `
|
||||||
+ `(1 ${row.fxNote.cur} = ${(+row.fxNote.rate).toFixed(5)} ${iCur})`;
|
+ `(${(+row.fxNote.rate).toFixed(5)} ${row.fxNote.rcur} per ${fxOther})`;
|
||||||
const fxLines = sp(fxStr, CD + CP - 4);
|
const fxLines = sp(fxStr, CD + CP - 4);
|
||||||
fxH = fxLines.length * 3.5 + 1;
|
fxH = fxLines.length * 3.5 + 1;
|
||||||
}
|
}
|
||||||
|
|
@ -1359,8 +1373,9 @@ function buildPDF() {
|
||||||
fb(8.5); tc(17,24,39); tR(fmt(row.tot), XR-2, yt);
|
fb(8.5); tc(17,24,39); tR(fmt(row.tot), XR-2, yt);
|
||||||
|
|
||||||
if (row.fxNote) {
|
if (row.fxNote) {
|
||||||
|
const fxOther = row.fxNote.rcur === iCur ? row.fxNote.cur : iCur;
|
||||||
const fxStr = `${td("per-item")}: ${row.fxNote.cur} ${fmt(row.fxNote.per)} `
|
const fxStr = `${td("per-item")}: ${row.fxNote.cur} ${fmt(row.fxNote.per)} `
|
||||||
+ `(1 ${row.fxNote.cur} = ${(+row.fxNote.rate).toFixed(5)} ${iCur})`;
|
+ `(${(+row.fxNote.rate).toFixed(5)} ${row.fxNote.rcur} per ${fxOther})`;
|
||||||
fn(7); tc(107,114,128);
|
fn(7); tc(107,114,128);
|
||||||
const fxLines = sp(fxStr, CD + CP - 4);
|
const fxLines = sp(fxStr, CD + CP - 4);
|
||||||
fxLines.forEach((fl, fi) => tL(fl, xD+2, y + ROW_H + descH + 3.2 + fi * 3.5));
|
fxLines.forEach((fl, fi) => tL(fl, xD+2, y + ROW_H + descH + 3.2 + fi * 3.5));
|
||||||
|
|
@ -1408,9 +1423,15 @@ function buildPDF() {
|
||||||
function updateFxLabels(i) {
|
function updateFxLabels(i) {
|
||||||
const fcy = document.getElementById(`fcur-${i}`)?.value || "FCY";
|
const fcy = document.getElementById(`fcur-${i}`)?.value || "FCY";
|
||||||
const lcy = document.getElementById("icur")?.value || "";
|
const lcy = document.getElementById("icur")?.value || "";
|
||||||
const rl = document.getElementById(`frate-lbl-${i}`);
|
const rcurEl = document.getElementById(`rcur-${i}`);
|
||||||
|
const rotherEl = document.getElementById(`rother-${i}`);
|
||||||
const pl = document.getElementById(`fper-lbl-${i}`);
|
const pl = document.getElementById(`fper-lbl-${i}`);
|
||||||
if (rl) rl.textContent = `Exchange rate (1 ${fcy} = X ${lcy})`;
|
if (rcurEl) {
|
||||||
|
const prev = rcurEl.value;
|
||||||
|
rcurEl.innerHTML = `<option value="${h(lcy)}">${h(lcy)}</option><option value="${h(fcy)}">${h(fcy)}</option>`;
|
||||||
|
rcurEl.value = (prev === lcy || prev === fcy) ? prev : lcy;
|
||||||
|
}
|
||||||
|
if (rotherEl) rotherEl.textContent = rcurEl?.value === lcy ? fcy : lcy;
|
||||||
if (pl) pl.textContent = `Price per item in ${fcy}`;
|
if (pl) pl.textContent = `Price per item in ${fcy}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1430,6 +1451,7 @@ function saveLines() {
|
||||||
price: document.getElementById(`price-${i}`)?.value || "",
|
price: document.getElementById(`price-${i}`)?.value || "",
|
||||||
fx: document.getElementById(`fx-${i}`)?.value || "no",
|
fx: document.getElementById(`fx-${i}`)?.value || "no",
|
||||||
fcur: document.getElementById(`fcur-${i}`)?.value || "",
|
fcur: document.getElementById(`fcur-${i}`)?.value || "",
|
||||||
|
rcur: document.getElementById(`rcur-${i}`)?.value || "",
|
||||||
frate: document.getElementById(`frate-${i}`)?.value || "",
|
frate: document.getElementById(`frate-${i}`)?.value || "",
|
||||||
fper: document.getElementById(`fper-${i}`)?.value || "",
|
fper: document.getElementById(`fper-${i}`)?.value || "",
|
||||||
});
|
});
|
||||||
|
|
@ -1483,6 +1505,10 @@ function loadLines() {
|
||||||
const el = document.getElementById(`fcur-${i}`);
|
const el = document.getElementById(`fcur-${i}`);
|
||||||
if (el) { el.value = ld.fcur; updateFxLabels(i); }
|
if (el) { el.value = ld.fcur; updateFxLabels(i); }
|
||||||
}
|
}
|
||||||
|
if (ld.rcur) {
|
||||||
|
const el = document.getElementById(`rcur-${i}`);
|
||||||
|
if (el) { el.value = ld.rcur; updateFxLabels(i); }
|
||||||
|
}
|
||||||
if (ld.frate) { const el = document.getElementById(`frate-${i}`); if (el) el.value = ld.frate; }
|
if (ld.frate) { const el = document.getElementById(`frate-${i}`); if (el) el.value = ld.frate; }
|
||||||
if (ld.fper) { const el = document.getElementById(`fper-${i}`); if (el) el.value = ld.fper; }
|
if (ld.fper) { const el = document.getElementById(`fper-${i}`); if (el) el.value = ld.fper; }
|
||||||
calcFxFromPer(i);
|
calcFxFromPer(i);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue