From f90718ba34c363710a183dc6b29413ffde671d45 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 08:09:39 +0000 Subject: [PATCH] Five fixes to invoice form and PDF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/config.yml | 12 +++++ app/index.html | 116 +++++++++++++++++++++++++++++++++---------------- 2 files changed, 90 insertions(+), 38 deletions(-) diff --git a/app/config.yml b/app/config.yml index 0aff37b..749c178 100644 --- a/app/config.yml +++ b/app/config.yml @@ -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" diff --git a/app/index.html b/app/index.html index c575f50..084457f 100644 --- a/app/index.html +++ b/app/index.html @@ -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) => + ``).join(""); const pcOpts = (cfg["project-codes"] || []).map(pc => ``).join(""); const ctOpts = (cfg["charge-to"] || []).map((ct, i) => ``).join(""); @@ -458,7 +470,9 @@ function buildForm() {
${t("invoice-details-section")}
-
+
+
+
-
+
@@ -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 - ? `
${h(row.fxNote.td("foreign-currency"))}: ` - + `${h(row.fxNote.cur)} ${fmt(row.fxNote.per)} / ${row.fxNote.rate} ` - + `× ${row.qty} = ${fmt(row.fxNote.ltot)}
` + ? `
${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)}
` : ""; const qStr = row.qty % 1 === 0 ? row.qty : fmt(row.qty); return ` @@ -955,8 +982,9 @@ function buildPreviewHTML() {

${h(td("invoice"))}

${iNo ? `` : ""} - ${iDate ? `` : ""} + ${iDate ? `` : ""} ${pCode ? `` : ""} + ${iCur ? `` : ""}
${h(td("invoice-no"))}${h(iNo)}
${h(td("invoice-date"))}${h(fmtMonth(iDate))}
${h(td("invoice-date"))}${h(fmtDate(iDate))}
${h(td("project-code"))}${h(pCode)}
${h(td("invoice-currency"))}${h(iCur)}
@@ -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;