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 = `
- |
- |
+ |
+ |
+ style="margin-top:4px;display:none" oninput="calcLine(${i});saveLines()">
@@ -801,7 +820,7 @@ function addLine() {
|
- |
+ |
0.00 |
| `;
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();