Apply UX feedback: field locking, dynamic FX labels, line persistence

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
This commit is contained in:
Claude 2026-05-24 16:09:12 +00:00
parent 89a310a7e5
commit ff5f4bdb19
No known key found for this signature in database
2 changed files with 207 additions and 36 deletions

View file

@ -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

View file

@ -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() {
<option value="__other__">${t("other")}</option>
</select>
</div>
<div id="ct-fields">
<div id="ct-fields" class="locked">
<div class="fg"><label id="lbl-ctn" for="ctn">${t("charge-to-name")}</label>
<input id="ctn" type="text"></div>
<div class="fg"><label id="lbl-ca1" for="ca1">${t("charge-to-address1")}</label>
@ -621,7 +628,10 @@ function buildForm() {
</div>
<div id="lines-card">
<div class="card-title" id="sec-lines">${t("invoice-lines")}</div>
<div class="card-title" style="display:flex;align-items:center;justify-content:space-between">
<span id="sec-lines">${t("invoice-lines")}</span>
<button type="button" id="btn-reset-lines" onclick="resetLines()">${t("reset-lines")}</button>
</div>
<table class="line-tbl">
<thead>
<tr>
@ -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 = `
<td class="col-qty"><input type="number" id="qty-${i}" value="1" min="0" step="any"
oninput="calcLine(${i})"></td>
<td class="col-uom"><select id="uom-${i}" onchange="calcLine(${i})">${uomOpts("")}</select></td>
<td class="col-qty"><input type="number" id="qty-${i}" value="" min="0" step="any"
oninput="calcLine(${i});saveLines()" disabled></td>
<td class="col-uom"><select id="uom-${i}" onchange="calcLine(${i});saveLines()" disabled>${uomOpts("")}</select></td>
<td class="col-desc">
<select id="dsel-${i}" onchange="pickProduct(${i})">${prodOpts("")}</select>
<input type="text" id="dtxt-${i}" placeholder="${h(t("description"))}"
style="margin-top:4px;display:none" oninput="calcLine(${i})">
style="margin-top:4px;display:none" oninput="calcLine(${i});saveLines()">
<div style="display:flex;align-items:center;gap:6px;margin-top:5px">
<label style="font-size:11px;color:var(--text-muted);white-space:nowrap"
id="lbl-fx-${i}">${t("foreign-currency")}:</label>
@ -801,7 +820,7 @@ function addLine() {
</select>
</div>
</td>
<td class="col-price"><input type="number" id="price-${i}" value="0" min="0" step="any" oninput="calcLine(${i})"></td>
<td class="col-price"><input type="number" id="price-${i}" value="" min="0" step="any" oninput="calcLine(${i});saveLines()" disabled></td>
<td class="col-tot"><div class="line-total-val" id="ltv-${i}">0.00</div></td>
<td class="col-act"><button type="button" class="btn-remove" onclick="removeLine(${i})">&#x00D7;</button></td>`;
tbody.appendChild(tr);
@ -813,6 +832,7 @@ function addLine() {
</td>`;
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 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 (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 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) {
<div class="fx-grid">
<div>
<div class="fx-label">${t("currency-code")}</div>
<select id="fcur-${i}" onchange="calcLine(${i})">${currOpts("USD")}</select>
<select id="fcur-${i}" onchange="updateFxLabels(${i});calcLine(${i});saveLines()">${currOpts(defaultFcy)}</select>
</div>
<div>
<div class="fx-label">${t("exchange-rate")}</div>
<input type="number" id="frate-${i}" value="1" min="0" step="any" oninput="calcLine(${i})">
<div class="fx-label" id="frate-lbl-${i}">Exchange rate (X ${h(defaultFcy)} = 1 ${h(lcy)})</div>
<input type="number" id="frate-${i}" value="" min="0" step="any" oninput="calcFxFromPer(${i})">
</div>
<div>
<div class="fx-label">${t("per-item")}</div>
<input type="number" id="fper-${i}" value="0" min="0" step="any" oninput="calcFxFromPer(${i})">
<div class="fx-label" id="fper-lbl-${i}">Price per item in ${h(defaultFcy)}</div>
<input type="number" id="fper-${i}" value="" min="0" step="any" oninput="calcFxFromPer(${i})">
</div>
</div>
<div class="fx-note">${t("total-local")}: <strong id="fltot-${i}">0.00</strong></div>
</td>`;
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
? `<div class="d-fx-note">${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)}</div>`
? `<div class="d-fx-note">${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)})</div>`
: "";
const qStr = row.qty % 1 === 0 ? row.qty : fmt(row.qty);
return `<tr>
@ -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();
</script>