From ff5f4bdb19dd0437de731f1269f3be4b0639cbdb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 16:09:12 +0000 Subject: [PATCH] Apply UX feedback: field locking, dynamic FX labels, line persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Charge to: - All fields locked when "Select"; fill+lock on predefined entity; unlock all fields only when "Other" is chosen Invoice lines: - QTY, UOM, Price start disabled; Description dropdown is always active - Predefined item: fills UOM (locked) and rate (editable) from config - "Other": unlocks all three fields Foreign currency: - Unit Price locked and calculated-only when FX is enabled - Re-enables Unit Price when FX is toggled back to No - Exchange rate label dynamically shows "Exchange rate (X USD = 1 EUR)" using the selected foreign currency and invoice currency - Per-item label shows "Price per item in USD" (selected FCY) - Label updates when either currency changes Invoice output: - FX note shows "Price per item (foreign currency): USD …" — the "Converted from …" prefix is removed from preview and PDF Invoice lines persistence: - Lines saved to localStorage on every change (desc, qty, UOM, price, FX toggle, FCY, rate, per-item) - Lines fully restored on page reload including FX state - "Reset invoice lines" button added to the INVOICE LINES card header --- app/config.yml | 5 ++ app/index.html | 238 +++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 207 insertions(+), 36 deletions(-) diff --git a/app/config.yml b/app/config.yml index 0ae2ed2..9178ce3 100644 --- a/app/config.yml +++ b/app/config.yml @@ -510,3 +510,8 @@ translations: de: Bitte Referenz angeben fr: "Merci d'indiquer la référence" "no": Vennligst oppgi referanse + reset-lines: + en: Reset invoice lines + de: Positionen zurücksetzen + fr: Réinitialiser les lignes + "no": Tilbakestill linjer diff --git a/app/index.html b/app/index.html index 62ea4ff..0593276 100644 --- a/app/index.html +++ b/app/index.html @@ -49,6 +49,10 @@ } input:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 2px #dbeafe; } input[type="number"] { text-align: right; } + input:disabled, select:disabled { + background: #f8f9fa; color: var(--text-muted); + border-color: var(--border-light); cursor: not-allowed; + } button { cursor: pointer; border: none; border-radius: var(--radius); font-size: 13px; } @@ -158,6 +162,8 @@ .btn-remove:hover { background: #fef2f2; } .btn-add-line { background: none; color: var(--accent); font-size: 13px; font-weight: 500; padding: 5px 10px; border: 1px dashed var(--accent); border-radius: var(--radius); } .btn-add-line:hover { background: #eff6ff; } + #btn-reset-lines { background: none; border: 1px solid var(--border); color: var(--text-muted); font-size: 11px; padding: 3px 10px; border-radius: 3px; } + #btn-reset-lines:hover { background: #fee2e2; border-color: var(--danger); color: var(--danger); } /* ── Totals ─────────────────────────────────────────────────────────────── */ #totals-card { background: var(--white); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 14px; } @@ -368,6 +374,7 @@ let lid = 0; const lines = {}; let tlid = 0; const tLines = {}; +let _loading = false; // ── i18n ────────────────────────────────────────────────────────────────────── function t(key) { @@ -461,7 +468,7 @@ function boot() { buildLangBar(); buildForm(); restoreStorage(); - addLine(); + if (!loadLines()) addLine(); document.getElementById("loading").style.display = "none"; } @@ -570,7 +577,7 @@ function buildForm() { -
+
@@ -621,7 +628,10 @@ function buildForm() {
-
${t("invoice-lines")}
+
+ ${t("invoice-lines")} + +
@@ -675,6 +685,11 @@ function buildForm() { document.getElementById("paid-inp").addEventListener("input", calcTotals); document.getElementById("the-form").addEventListener("submit", e => { e.preventDefault(); generateInvoice(); }); document.querySelectorAll("[data-ls]").forEach(el => el.addEventListener("change", saveStorage)); + document.getElementById("icur").addEventListener("change", () => { + Object.keys(lines).forEach(k => { + if (document.getElementById(`frate-lbl-${k}`)) updateFxLabels(+k); + }); + }); calcPayBy(); // compute initial pay-by from default 7-day term } @@ -686,8 +701,12 @@ function fillChargeTo(v) { const bsec = document.getElementById("bank-section"); if (bsec) bsec.style.display = v === "__other__" ? "" : "none"; - if (v === "" || v === "__other__") { - if (v === "") ["ctn","ca1","ca2","ca3","ca4","cc","cph","cem","cvat","creg"].forEach(id => f(id, "")); + if (v === "") { + ["ctn","ca1","ca2","ca3","ca4","cc","cph","cem","cvat","creg"].forEach(id => f(id, "")); + fields?.classList.add("locked"); + return; + } + if (v === "__other__") { fields?.classList.remove("locked"); return; } @@ -785,13 +804,13 @@ function addLine() { const tr = document.createElement("tr"); tr.className = "lr"; tr.id = `lr-${i}`; tr.innerHTML = ` - - + + - + `; tbody.appendChild(tr); @@ -813,6 +832,7 @@ function addLine() { `; tbody.appendChild(alNew); calcTotals(); + return i; } function removeLine(i) { @@ -820,32 +840,68 @@ function removeLine(i) { document.getElementById(`fx-row-${i}`)?.remove(); delete lines[i]; calcTotals(); + saveLines(); } function pickProduct(i) { - const v = document.getElementById(`dsel-${i}`)?.value; - const txt = document.getElementById(`dtxt-${i}`); + const v = document.getElementById(`dsel-${i}`)?.value; + const txt = document.getElementById(`dtxt-${i}`); + const qtyEl = document.getElementById(`qty-${i}`); + const uomEl = document.getElementById(`uom-${i}`); + const priceEl= document.getElementById(`price-${i}`); + const fxOn = document.getElementById(`fx-${i}`)?.value === "yes"; + txt.style.display = v === "__other__" ? "block" : "none"; - if (v !== "" && v !== "__other__") { + + if (v === "") { + // Nothing selected — lock all three + if (qtyEl) { qtyEl.disabled = true; qtyEl.value = ""; } + if (uomEl) { uomEl.disabled = true; uomEl.value = ""; } + if (priceEl) { priceEl.disabled = true; priceEl.value = ""; } + } else if (v === "__other__") { + // Free text — unlock all three (price stays locked if FX is on) + if (qtyEl) qtyEl.disabled = false; + if (uomEl) uomEl.disabled = false; + if (!fxOn && priceEl) priceEl.disabled = false; + } else { + // Predefined — fill UOM + price from config; lock UOM, unlock qty + price const p = (cfg.products || [])[+v]; if (p) { - const uomEl = document.getElementById(`uom-${i}`); - const priceEl = document.getElementById(`price-${i}`); - if (uomEl && p.uom) uomEl.value = p.uom; + if (uomEl && p.uom) uomEl.value = p.uom; if (priceEl && p.price != null) priceEl.value = p.price; } + if (qtyEl) qtyEl.disabled = false; + if (uomEl) uomEl.disabled = true; // UOM locked for predefined items + if (!fxOn && priceEl) priceEl.disabled = false; } calcLine(i); + saveLines(); } // ── Foreign currency ────────────────────────────────────────────────────────── function toggleFx(i) { - const on = document.getElementById(`fx-${i}`)?.value === "yes"; - const lr = document.getElementById(`lr-${i}`); + const on = document.getElementById(`fx-${i}`)?.value === "yes"; + const lr = document.getElementById(`lr-${i}`); + const priceEl = document.getElementById(`price-${i}`); document.getElementById(`fx-row-${i}`)?.remove(); - if (!on) { lr?.classList.remove("open"); calcTotals(); return; } + + if (!on) { + lr?.classList.remove("open"); + // Re-enable price if a description is selected + const dv = document.getElementById(`dsel-${i}`)?.value; + if (dv && dv !== "" && priceEl) priceEl.disabled = false; + calcTotals(); + saveLines(); + return; + } + + // Lock unit price — it will only be set by calcFxFromPer + if (priceEl) { priceEl.disabled = true; priceEl.value = ""; } lr?.classList.add("open"); + const lcy = document.getElementById("icur")?.value || ""; + const defaultFcy = (cfg.currencies || ["USD"])[0] || "USD"; + const fxTr = document.createElement("tr"); fxTr.className = "fx"; fxTr.id = `fx-row-${i}`; fxTr.innerHTML = ` @@ -853,21 +909,22 @@ function toggleFx(i) {
${t("currency-code")}
- +
-
${t("exchange-rate")}
- +
Exchange rate (X ${h(defaultFcy)} = 1 ${h(lcy)})
+
-
${t("per-item")}
- +
Price per item in ${h(defaultFcy)}
+
${t("total-local")}: 0.00
`; lr?.insertAdjacentElement("afterend", fxTr); calcLine(i); + saveLines(); } function calcFxFromPer(i) { @@ -876,6 +933,7 @@ function calcFxFromPer(i) { const prEl = document.getElementById(`price-${i}`); if (prEl) prEl.value = (per / rate).toFixed(6); calcLine(i); + saveLines(); } // ── Calculations ────────────────────────────────────────────────────────────── @@ -1031,6 +1089,9 @@ function relabel() { const atBtn = document.getElementById("btn-add-tax"); if (atBtn) atBtn.textContent = t("add-tax"); + const rstBtn = document.getElementById("btn-reset-lines"); + if (rstBtn) rstBtn.textContent = t("reset-lines"); + const ctPick = document.getElementById("ct-pick"); if (ctPick) { const cur = ctPick.value; @@ -1160,10 +1221,8 @@ function buildPreviewHTML() { const linesHTML = rows.map(row => { const fxLine = row.fxNote - ? `
${h(row.fxNote.td("converted-from"))} ${h(row.fxNote.cur)}: ` - + `${(+row.fxNote.rate).toFixed(5)} ${h(row.fxNote.cur)} = 1 ${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)}
` + ? `
${h(row.fxNote.td("per-item"))}: ${h(row.fxNote.cur)} ${fmt(row.fxNote.per)} ` + + `(${(+row.fxNote.rate).toFixed(5)} ${h(row.fxNote.cur)} = 1 ${h(iCur)})
` : ""; const qStr = row.qty % 1 === 0 ? row.qty : fmt(row.qty); return ` @@ -1396,9 +1455,8 @@ function buildPDF() { const descH = Math.max(0, (dLines.length - 1) * 3.8); let fxH = 0; if (row.fxNote) { - const fxStr = `${td("converted-from")} ${row.fxNote.cur}: ${(+row.fxNote.rate).toFixed(5)} ${row.fxNote.cur} = 1 ${iCur}. ` - + `${td("per-item")}: ${row.fxNote.cur} ${fmt(row.fxNote.per)}, ` - + `${td("line-total").toLowerCase()}: ${row.fxNote.cur} ${fmt(row.fxNote.foreignTot)}`; + const fxStr = `${td("per-item")}: ${row.fxNote.cur} ${fmt(row.fxNote.per)} ` + + `(${(+row.fxNote.rate).toFixed(5)} ${row.fxNote.cur} = 1 ${iCur})`; const fxLines = sp(fxStr, CD + CP - 4); fxH = fxLines.length * 3.5 + 1; } @@ -1430,12 +1488,9 @@ function buildPDF() { fb(8.5); tc(17,24,39); tR(fmt(row.tot), XR-2, yt); if (row.fxNote) { - const fxStr = `${td("converted-from")} ${row.fxNote.cur}: ` - + `${(+row.fxNote.rate).toFixed(5)} ${row.fxNote.cur} = 1 ${iCur}. ` - + `${td("per-item")}: ${row.fxNote.cur} ${fmt(row.fxNote.per)}, ` - + `${td("line-total").toLowerCase()}: ${row.fxNote.cur} ${fmt(row.fxNote.foreignTot)}`; + const fxStr = `${td("per-item")}: ${row.fxNote.cur} ${fmt(row.fxNote.per)} ` + + `(${(+row.fxNote.rate).toFixed(5)} ${row.fxNote.cur} = 1 ${iCur})`; fn(7); tc(107,114,128); - // 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)); } @@ -1478,6 +1533,117 @@ function buildPDF() { doc.save(iNo ? `invoice-${iNo}.pdf` : "invoice.pdf"); } +// ── Update FX labels when currency or invoice currency changes ──────────────── +function updateFxLabels(i) { + const fcy = document.getElementById(`fcur-${i}`)?.value || "FCY"; + const lcy = document.getElementById("icur")?.value || ""; + const rl = document.getElementById(`frate-lbl-${i}`); + const pl = document.getElementById(`fper-lbl-${i}`); + if (rl) rl.textContent = `Exchange rate (X ${fcy} = 1 ${lcy})`; + if (pl) pl.textContent = `Price per item in ${fcy}`; +} + +// ── Save / load invoice lines ────────────────────────────────────────────────── +const LS_LINES = "inv_lines_v1"; + +function saveLines() { + if (_loading) return; + const data = []; + Object.keys(lines).sort((a, b) => +a - +b).forEach(k => { + const i = +k; + data.push({ + dsel: document.getElementById(`dsel-${i}`)?.value || "", + dtxt: document.getElementById(`dtxt-${i}`)?.value || "", + qty: document.getElementById(`qty-${i}`)?.value || "", + uom: document.getElementById(`uom-${i}`)?.value || "", + price: document.getElementById(`price-${i}`)?.value || "", + fx: document.getElementById(`fx-${i}`)?.value || "no", + fcur: document.getElementById(`fcur-${i}`)?.value || "", + frate: document.getElementById(`frate-${i}`)?.value || "", + fper: document.getElementById(`fper-${i}`)?.value || "", + }); + }); + localStorage.setItem(LS_LINES, JSON.stringify(data)); +} + +function loadLines() { + try { + const raw = localStorage.getItem(LS_LINES); + if (!raw) return false; + const data = JSON.parse(raw); + if (!Array.isArray(data) || data.length === 0) return false; + + // Remove the default first empty line added by boot() + const firstKey = Object.keys(lines)[0]; + if (firstKey !== undefined) { + document.getElementById(`lr-${firstKey}`)?.remove(); + document.getElementById(`fx-row-${firstKey}`)?.remove(); + delete lines[+firstKey]; + } + + _loading = true; + data.forEach(ld => { + const i = addLine(); + + // Restore description selection + if (ld.dsel) { + const dsel = document.getElementById(`dsel-${i}`); + if (dsel) { + dsel.value = ld.dsel; + pickProduct(i); // enables/disables fields, fills UOM+price from config + if (ld.dsel === "__other__" && ld.dtxt) { + const dtxt = document.getElementById(`dtxt-${i}`); + if (dtxt) dtxt.value = ld.dtxt; + } + } + } + + // Restore UOM (covers "Other" lines where user picked their own) + if (ld.uom) { const el = document.getElementById(`uom-${i}`); if (el) el.value = ld.uom; } + // Restore qty + if (ld.qty) { const el = document.getElementById(`qty-${i}`); if (el) el.value = ld.qty; } + + if (ld.fx === "yes") { + const fxEl = document.getElementById(`fx-${i}`); + if (fxEl) { + fxEl.value = "yes"; + toggleFx(i); // creates FX row and locks price + if (ld.fcur) { + const el = document.getElementById(`fcur-${i}`); + if (el) { el.value = ld.fcur; updateFxLabels(i); } + } + 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; } + calcFxFromPer(i); + } + } else { + if (ld.price) { const el = document.getElementById(`price-${i}`); if (el) el.value = ld.price; } + calcLine(i); + } + }); + + _loading = false; + saveLines(); + calcTotals(); + return true; + } catch (e) { + _loading = false; + return false; + } +} + +function resetLines() { + if (!confirm(t("reset-lines") + "?")) return; + Object.keys(lines).forEach(k => { + document.getElementById(`lr-${+k}`)?.remove(); + document.getElementById(`fx-row-${+k}`)?.remove(); + delete lines[+k]; + }); + localStorage.removeItem(LS_LINES); + addLine(); + calcTotals(); +} + // ── Start ───────────────────────────────────────────────────────────────────── loadCfg();
+ style="margin-top:4px;display:none" oninput="calcLine(${i});saveLines()">
@@ -801,7 +820,7 @@ function addLine() {
0.00