Five fixes to invoice form and PDF

- Date picker: change invoice date from month picker to full date picker
  (type=date, formatted as "19 May 2026" in output)

- Invoice currency: add currency selector under invoice date, populated
  from config.currencies list, saved to localStorage; shown in invoice
  meta block on both preview and PDF

- Recipient currency: add currency field to each charge-to entry in
  config.yml; selecting a predefined recipient auto-sets invoice currency

- Lock predefined recipients: selecting a predefined charge-to entry
  locks all its fields (pointer-events off + muted style via #ct-fields
  .locked); switching to Other or clearing unlocks them

- Fix foreign-currency exchange rate calculation: the formula was
  inverted (per / rate instead of per * rate). If 1 USD = 32 local and
  per-item is USD 100, local price is now correctly 100 × 32 = 3200,
  not 100 / 32 = 3.125. Fix applied in calcFxFromPer, calcLine display,
  and gatherData (foreignTot = per × qty, the foreign-currency total).
  Updated fx note text to the specified format:
  "Converted from USD: 1 USD = 32.00000 THB. Per item: USD 100.00,
  line total: USD 500.00"

https://claude.ai/code/session_015iyCBgoTXNNqaErR287U1u
This commit is contained in:
Claude 2026-05-19 08:09:39 +00:00
parent f39eed979a
commit f90718ba34
No known key found for this signature in database
2 changed files with 90 additions and 38 deletions

View file

@ -100,6 +100,7 @@ charge-to:
phone: "+1-212-555-0100"
email: accounts@acmecorp.example
vat-id: "US-EIN-12-3456789"
currency: USD
- display: Example NGO
name: Example Non-Profit Organisation
address1: 45 Charity Lane
@ -110,6 +111,7 @@ charge-to:
phone: "+44 20 7123 4567"
email: finance@examplengo.example
vat-id: "GB123456789"
currency: GBP
# ── Project codes ──────────────────────────────────────────────────────────────
project-codes:
@ -389,6 +391,16 @@ translations:
de: "Nein"
fr: "Non"
"no": "Nei"
invoice-currency:
en: Invoice currency
de: Rechnungswährung
fr: Devise de facturation
"no": Fakturavaluta
converted-from:
en: Converted from
de: Umgerechnet aus
fr: Converti depuis
"no": Konvertert fra
download-pdf:
en: "Download PDF"
de: "PDF herunterladen"

View file

@ -266,6 +266,16 @@
.d-tots .fin td { background: var(--navy); color: white; font-size: 14px; font-weight: 700; border-top: 2px solid var(--navy); }
.d-tots .fin .tl { color: rgba(255,255,255,.75); }
/* ── Locked charge-to fields ────────────────────────────────────────────── */
#ct-fields.locked input,
#ct-fields.locked select {
background: #f8f9fa;
color: var(--text-muted);
border-color: var(--border-light);
pointer-events: none;
cursor: not-allowed;
}
/* ── Error / loading ────────────────────────────────────────────────────── */
#loading { padding: 48px; text-align: center; color: var(--text-muted); font-size: 14px; }
.error-box { background: #fef2f2; border: 1px solid #fca5a5; color: #991b1b; padding: 16px 20px; border-radius: var(--radius); margin: 20px 0; font-size: 13px; }
@ -338,10 +348,10 @@ function fmt(n) {
function pn(s) { return parseFloat(String(s ?? 0).replace(/,/g, "")) || 0; }
// ── Date ─────────────────────────────────────────────────────────────────────
function fmtMonth(v) {
function fmtDate(v) {
if (!v) return "";
const [y, m] = v.split("-");
return new Date(+y, +m - 1, 1).toLocaleDateString("en-US", { year: "numeric", month: "long" });
const [y, m, d] = v.split("-");
return new Date(+y, +m - 1, +d).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
}
// ── HTML escape ───────────────────────────────────────────────────────────────
@ -425,8 +435,10 @@ function buildLangBar() {
function buildForm() {
document.getElementById("inv-title").textContent = t("invoice");
const today = new Date();
const monthDef = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}`;
const today = new Date();
const dateDef = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
const curOpts = (cfg.currencies || []).map((c, i) =>
`<option value="${h(c)}" ${i === 0 ? "selected" : ""}>${h(c)}</option>`).join("");
const pcOpts = (cfg["project-codes"] || []).map(pc => `<option value="${h(pc)}">${h(pc)}</option>`).join("");
const ctOpts = (cfg["charge-to"] || []).map((ct, i) => `<option value="${i}">${h(ct.display || ct.name)}</option>`).join("");
@ -458,7 +470,9 @@ function buildForm() {
<div class="card">
<div class="card-title" id="sec-invdet">${t("invoice-details-section")}</div>
<div class="fg"><label id="lbl-idate" for="idate">${t("invoice-date")}</label>
<input id="idate" type="month" value="${monthDef}"></div>
<input id="idate" type="date" value="${dateDef}"></div>
<div class="fg"><label id="lbl-icur" for="icur">${t("invoice-currency")}</label>
<select id="icur" data-ls="icur">${curOpts}</select></div>
<div class="fg"><label id="lbl-pcode" for="pcode">${t("project-code")}</label>
<select id="pcode">
<option value="">${t("select")}</option>
@ -483,7 +497,7 @@ function buildForm() {
<option value="__other__">${t("other")}</option>
</select>
</div>
<div class="two-col">
<div id="ct-fields" class="two-col">
<div>
<div class="fg"><label id="lbl-ctn" for="ctn">${t("charge-to-name")}</label>
<input id="ctn" type="text"></div>
@ -562,9 +576,12 @@ function buildForm() {
// ── Fill charge-to ────────────────────────────────────────────────────────────
function fillChargeTo(v) {
const f = (id, val) => { const el = document.getElementById(id); if (el) el.value = val ?? ""; };
const f = (id, val) => { const el = document.getElementById(id); if (el) el.value = val ?? ""; };
const fields = document.getElementById("ct-fields");
if (v === "" || v === "__other__") {
["ctn","ca1","ca2","ca3","ca4","cc","cph","cem","cvat"].forEach(id => f(id, ""));
if (v === "") ["ctn","ca1","ca2","ca3","ca4","cc","cph","cem","cvat"].forEach(id => f(id, ""));
fields?.classList.remove("locked");
return;
}
const ct = (cfg["charge-to"] || [])[+v];
@ -574,6 +591,13 @@ function fillChargeTo(v) {
f("ca4", ct.address4); f("cc", ct.country);
f("cph", ct.phone); f("cem", ct.email);
f("cvat", ct["vat-id"]);
fields?.classList.add("locked");
// Auto-set invoice currency from recipient config
if (ct.currency) {
const icurEl = document.getElementById("icur");
if (icurEl) { icurEl.value = ct.currency; saveStorage(); }
}
}
// ── Select option helpers ─────────────────────────────────────────────────────
@ -702,7 +726,8 @@ function calcFxFromPer(i) {
const per = pn(document.getElementById(`fper-${i}`)?.value);
const rate = pn(document.getElementById(`frate-${i}`)?.value) || 1;
const prEl = document.getElementById(`price-${i}`);
if (prEl) prEl.value = (per / rate).toFixed(6);
// rate = "1 foreign = rate local", so local price = per * rate
if (prEl) prEl.value = (per * rate).toFixed(6);
calcLine(i);
}
@ -714,10 +739,10 @@ function calcLine(i) {
if (el) el.textContent = fmt(qty * price);
if (document.getElementById(`fx-${i}`)?.value === "yes") {
const rate = pn(document.getElementById(`frate-${i}`)?.value) || 1;
const per = pn(document.getElementById(`fper-${i}`)?.value);
const ltot = document.getElementById(`fltot-${i}`);
if (ltot) ltot.textContent = fmt((per / rate) * qty);
const per = pn(document.getElementById(`fper-${i}`)?.value);
const ltot = document.getElementById(`fltot-${i}`);
// Show foreign-currency line total (per * qty, still in foreign currency)
if (ltot) ltot.textContent = fmt(per * qty);
}
calcTotals();
}
@ -783,7 +808,7 @@ function relabel() {
"lbl-sn":"sender-name","lbl-sa1":"sender-address1","lbl-sa2":"sender-address2",
"lbl-sa3":"sender-address3","lbl-sa4":"sender-address4","lbl-sc":"sender-country",
"lbl-sp":"sender-phone","lbl-se":"sender-email",
"lbl-idate":"invoice-date","lbl-pcode":"project-code","lbl-ino":"invoice-no",
"lbl-idate":"invoice-date","lbl-icur":"invoice-currency","lbl-pcode":"project-code","lbl-ino":"invoice-no",
"lbl-ctn":"charge-to-name","lbl-ca1":"charge-to-address1","lbl-ca2":"charge-to-address2",
"lbl-ca3":"charge-to-address3","lbl-ca4":"charge-to-address4","lbl-cc":"charge-to-country",
"lbl-cph":"charge-to-phone","lbl-cem":"charge-to-email","lbl-cvat":"vat-id",
@ -869,6 +894,7 @@ function gatherData(renderLang) {
const iDate = g("idate");
const pCode = g("pcode") === "__other__" ? g("pcode-other") : g("pcode");
const iNo = g("ino");
const iCur = g("icur");
const ctName = g("ctn");
const ctAddr = [g("ca1"),g("ca2"),g("ca3"),g("ca4")].filter(Boolean);
@ -900,11 +926,11 @@ function gatherData(renderLang) {
const isFx = document.getElementById(`fx-${i}`)?.value === "yes";
let fxNote = null;
if (isFx) {
const cur = document.getElementById(`fcur-${i}`)?.value || "";
const rate = pn(document.getElementById(`frate-${i}`)?.value) || 1;
const per = pn(document.getElementById(`fper-${i}`)?.value);
const ltot = (per / rate) * qty;
fxNote = { cur, rate, per, ltot, td };
const cur = document.getElementById(`fcur-${i}`)?.value || "";
const rate = pn(document.getElementById(`frate-${i}`)?.value) || 1;
const per = pn(document.getElementById(`fper-${i}`)?.value);
const foreignTot = per * qty; // line total in foreign currency
fxNote = { cur, rate, per, foreignTot, td };
}
rows.push({ qty, uomLbl, desc, price, tot, fxNote });
});
@ -916,22 +942,23 @@ function gatherData(renderLang) {
const taxRateObj = (cfg["tax-rates"]||[]).find(r => r.rate == taxRate);
const taxLabel = taxRateObj ? tTax(taxRateObj, dl) : `${td("tax")} ${taxRate}%`;
return { dl, td, sName, sAddr, sCntry, sPh, sEm, iDate, pCode, iNo,
return { dl, td, sName, sAddr, sCntry, sPh, sEm, iDate, pCode, iNo, iCur,
ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat,
rows, sub, taxRate, taxAmt, taxLabel, paid, toPay };
}
// ── Build HTML preview ────────────────────────────────────────────────────────
function buildPreviewHTML() {
const { td, sName, sAddr, sCntry, sPh, sEm, iDate, pCode, iNo,
const { td, sName, sAddr, sCntry, sPh, sEm, iDate, pCode, iNo, iCur,
ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat,
rows, sub, taxRate, taxAmt, taxLabel, paid, toPay } = gatherData();
const linesHTML = rows.map(row => {
const fxLine = row.fxNote
? `<div class="d-fx-note">${h(row.fxNote.td("foreign-currency"))}: `
+ `${h(row.fxNote.cur)} ${fmt(row.fxNote.per)} / ${row.fxNote.rate} `
+ `× ${row.qty} = ${fmt(row.fxNote.ltot)}</div>`
? `<div class="d-fx-note">${h(row.fxNote.td("converted-from"))} ${h(row.fxNote.cur)}: `
+ `1 ${h(row.fxNote.cur)} = ${(+row.fxNote.rate).toFixed(5)} ${h(iCur)}. `
+ `${h(row.fxNote.td("per-item"))}: ${h(row.fxNote.cur)} ${fmt(row.fxNote.per)}, `
+ `${h(row.fxNote.td("line-total")).toLowerCase()}: ${h(row.fxNote.cur)} ${fmt(row.fxNote.foreignTot)}</div>`
: "";
const qStr = row.qty % 1 === 0 ? row.qty : fmt(row.qty);
return `<tr>
@ -955,8 +982,9 @@ function buildPreviewHTML() {
<h1>${h(td("invoice"))}</h1>
<table class="d-meta">
${iNo ? `<tr><td class="ml">${h(td("invoice-no"))}</td><td class="mv">${h(iNo)}</td></tr>` : ""}
${iDate ? `<tr><td class="ml">${h(td("invoice-date"))}</td><td class="mv">${h(fmtMonth(iDate))}</td></tr>` : ""}
${iDate ? `<tr><td class="ml">${h(td("invoice-date"))}</td><td class="mv">${h(fmtDate(iDate))}</td></tr>` : ""}
${pCode ? `<tr><td class="ml">${h(td("project-code"))}</td><td class="mv">${h(pCode)}</td></tr>` : ""}
${iCur ? `<tr><td class="ml">${h(td("invoice-currency"))}</td><td class="mv">${h(iCur)}</td></tr>` : ""}
</table>
</div>
</div>
@ -1021,7 +1049,7 @@ function buildPDF() {
const tR = (s,x,y) => doc.text(String(s??""), x, y, {align:"right"});
const sp = (s,w) => doc.splitTextToSize(String(s??""), w);
const { dl, td, sName, sAddr, sCntry, sPh, sEm, iDate, pCode, iNo,
const { dl, td, sName, sAddr, sCntry, sPh, sEm, iDate, pCode, iNo, iCur,
ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat,
rows, sub, taxRate, taxAmt, taxLabel, paid, toPay } = gatherData();
@ -1053,9 +1081,10 @@ function buildPDF() {
// Meta table (right-aligned)
const metaRows = [
iNo ? [td("invoice-no"), iNo] : null,
iDate ? [td("invoice-date"), fmtMonth(iDate)] : null,
pCode ? [td("project-code"), pCode] : null,
iNo ? [td("invoice-no"), iNo] : null,
iDate ? [td("invoice-date"), fmtDate(iDate)]: null,
pCode ? [td("project-code"), pCode] : null,
iCur ? [td("invoice-currency"), iCur] : null,
].filter(Boolean);
metaRows.forEach(([lbl, val]) => {
@ -1116,13 +1145,20 @@ function buildPDF() {
y += TH;
const ROW_H = 7.5;
const FX_H = 4.5;
rows.forEach((row, idx) => {
// Calculate row height (description may wrap)
const dLines = sp(row.desc, CD - 4);
const descH = Math.max(0, (dLines.length - 1) * 3.8);
const rh = ROW_H + descH + (row.fxNote ? FX_H : 0);
// Calculate row height (description and fx note may wrap)
const dLines = sp(row.desc, CD - 4);
const descH = Math.max(0, (dLines.length - 1) * 3.8);
let fxH = 0;
if (row.fxNote) {
const fxStr = `${td("converted-from")} ${row.fxNote.cur}: 1 ${row.fxNote.cur} = ${(+row.fxNote.rate).toFixed(5)} ${iCur}. `
+ `${td("per-item")}: ${row.fxNote.cur} ${fmt(row.fxNote.per)}, `
+ `${td("line-total").toLowerCase()}: ${row.fxNote.cur} ${fmt(row.fxNote.foreignTot)}`;
const fxLines = sp(fxStr, CD + CP - 4);
fxH = fxLines.length * 3.5 + 1;
}
const rh = ROW_H + descH + fxH;
if (y + rh > PH - 30) { doc.addPage(); y = MT; }
@ -1150,10 +1186,14 @@ function buildPDF() {
fb(8.5); tc(17,24,39); tR(fmt(row.tot), XR-2, yt);
if (row.fxNote) {
const fxStr = `${td("foreign-currency")}: ${row.fxNote.cur} ${fmt(row.fxNote.per)}`
+ ` / ${row.fxNote.rate} × ${row.qty} = ${fmt(row.fxNote.ltot)}`;
const fxStr = `${td("converted-from")} ${row.fxNote.cur}: `
+ `1 ${row.fxNote.cur} = ${(+row.fxNote.rate).toFixed(5)} ${iCur}. `
+ `${td("per-item")}: ${row.fxNote.cur} ${fmt(row.fxNote.per)}, `
+ `${td("line-total").toLowerCase()}: ${row.fxNote.cur} ${fmt(row.fxNote.foreignTot)}`;
fn(7); tc(107,114,128);
tL(fxStr, xD+2, y + ROW_H + descH + 3.2);
// Split if too long for the description column
const fxLines = sp(fxStr, CD + CP - 4);
fxLines.forEach((fl, fi) => tL(fl, xD+2, y + ROW_H + descH + 3.2 + fi * 3.5));
}
y += rh;