diff --git a/app/config.yml b/app/config.yml index 749c178..3b91aff 100644 --- a/app/config.yml +++ b/app/config.yml @@ -21,33 +21,45 @@ languages: name: Norsk direction: ltr -# ── Tax / VAT options ────────────────────────────────────────────────────────── -tax-rates: - - label-en: "No Tax (0%)" - label-de: "Keine Steuer (0%)" - label-fr: "Sans taxe (0%)" - label-no: "Ingen mva (0%)" - rate: 0 - - label-en: "VAT 5%" - label-de: "MwSt. 5%" - label-fr: "TVA 5%" - label-no: "Mva 5%" - rate: 5 - - label-en: "VAT 10%" - label-de: "MwSt. 10%" - label-fr: "TVA 10%" - label-no: "Mva 10%" - rate: 10 - - label-en: "VAT 20%" - label-de: "MwSt. 20%" - label-fr: "TVA 20%" - label-no: "Mva 20%" - rate: 20 - - label-en: "VAT 25%" - label-de: "MwSt. 25%" - label-fr: "TVA 25%" - label-no: "Mva 25%" - rate: 25 +# ── Date and paper format ───────────────────────────────────────────────────── +# Date tokens: d=day, dd=day(0-padded), M=month number, MM=month(0-padded), +# MMM=short month name, MMMM=full month name, YY=2-digit year, YYYY=4-digit year +date-format: "d MMMM YYYY" +# paper-format: a4 or letter +paper-format: a4 + +# ── Tax types (user builds tax lines; these are the label options) ───────────── +tax-types: + - key: vat + labels: + en: VAT + de: MwSt. + fr: TVA + "no": MVA + - key: gst + labels: + en: GST + de: GST + fr: TPS + "no": GST + - key: sales-tax + labels: + en: Sales Tax + de: Umsatzsteuer + fr: Taxe de vente + "no": Omsetningsavgift + - key: withholding + labels: + en: Withholding Tax + de: Quellensteuer + fr: "Retenue à la source" + "no": Kildeskatt + - key: other + labels: + en: Other Tax + de: Sonstige Steuer + fr: Autre taxe + "no": Annen skatt # ── Units of measure ────────────────────────────────────────────────────────── uom: @@ -396,6 +408,21 @@ translations: de: Rechnungswährung fr: Devise de facturation "no": Fakturavaluta + add-tax: + en: "+ Add new tax" + de: "+ Neue Steuer hinzufügen" + fr: "+ Ajouter une taxe" + "no": "+ Legg til skatt" + tax-pct: + en: "%" + de: "%" + fr: "%" + "no": "%" + tax-amount: + en: Amount + de: Betrag + fr: Montant + "no": Beløp converted-from: en: Converted from de: Umgerechnet aus diff --git a/app/index.html b/app/index.html index 084457f..e691181 100644 --- a/app/index.html +++ b/app/index.html @@ -160,8 +160,15 @@ .tot-tbl .val { text-align: right; width: 150px; font-size: 13px; font-weight: 600; } .tot-tbl .final td { background: var(--navy); color: var(--white); font-size: 15px; font-weight: 700; } .tot-tbl .final .lbl { color: rgba(255,255,255,.75); } - .tax-sel { width: auto !important; display: inline-block; } .paid-inp { width: 120px !important; text-align: right; } + .tax-inputs { display: flex; align-items: center; gap: 5px; justify-content: flex-end; } + .tax-inputs input[type="number"] { width: 68px; } + .tax-inputs select { width: auto; } + .btn-rm-tax { background: none; color: var(--danger); font-size: 16px; line-height: 1; padding: 1px 4px; border-radius: 3px; } + .btn-rm-tax:hover { background: #fef2f2; } + .tax-add-cell { text-align: right; padding: 5px 16px !important; } + .btn-add-tax { background: none; color: var(--accent); font-size: 12px; font-weight: 500; padding: 3px 8px; border: 1px dashed var(--accent); border-radius: var(--radius); } + .btn-add-tax:hover { background: #eff6ff; } /* ── Generate button ────────────────────────────────────────────────────── */ #btn-generate { @@ -325,7 +332,9 @@ let cfg = null; let lang = "en"; let lid = 0; -const lines = {}; +const lines = {}; +let tlid = 0; +const tLines = {}; // ── i18n ────────────────────────────────────────────────────────────────────── function t(key) { @@ -334,12 +343,6 @@ function t(key) { return e[lang] ?? e[cfg["default-code"]] ?? key; } -function tTax(tr, l) { - const lk = "label-" + (l || lang); - const fb = "label-" + (cfg["default-code"] || "en"); - return tr[lk] ?? tr[fb] ?? `Tax ${tr.rate}%`; -} - // ── Numbers ─────────────────────────────────────────────────────────────────── function fmt(n) { if (n === "" || n == null || isNaN(+n)) return "0.00"; @@ -348,10 +351,28 @@ function fmt(n) { function pn(s) { return parseFloat(String(s ?? 0).replace(/,/g, "")) || 0; } // ── Date ───────────────────────────────────────────────────────────────────── +const MONTHS_FULL = ["January","February","March","April","May","June", + "July","August","September","October","November","December"]; +const MONTHS_SHORT = ["Jan","Feb","Mar","Apr","May","Jun", + "Jul","Aug","Sep","Oct","Nov","Dec"]; + function fmtDate(v) { if (!v) return ""; - const [y, m, d] = v.split("-"); - return new Date(+y, +m - 1, +d).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }); + const [yr, mo, dy] = v.split("-").map(Number); + const pattern = cfg?.["date-format"] || "d MMMM YYYY"; + return pattern.replace(/YYYY|YY|MMMM|MMM|MM|M|dd|d/g, tok => { + switch (tok) { + case "YYYY": return yr; + case "YY": return String(yr).slice(-2); + case "MMMM": return MONTHS_FULL[mo - 1]; + case "MMM": return MONTHS_SHORT[mo - 1]; + case "MM": return String(mo).padStart(2, "0"); + case "M": return mo; + case "dd": return String(dy).padStart(2, "0"); + case "d": return dy; + default: return tok; + } + }); } // ── HTML escape ─────────────────────────────────────────────────────────────── @@ -440,10 +461,8 @@ function buildForm() { 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(""); - const taxOpts = (cfg["tax-rates"] || []).map((tr, i) => - ``).join(""); + const pcOpts = (cfg["project-codes"] || []).map(pc => ``).join(""); + const ctOpts = (cfg["charge-to"] || []).map((ct, i) => ``).join(""); document.getElementById("form-root").innerHTML = `
@@ -542,22 +561,28 @@ function buildForm() {
- - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + +
${t("subtotal")}0.00
0.00
${t("paid")}
${t("to-pay")}0.00
${t("subtotal")}0.00
+ +
${t("paid")}
${t("to-pay")}0.00
@@ -568,8 +593,7 @@ function buildForm() { document.getElementById("pcode-other-wrap").style.display = this.value === "__other__" ? "block" : "none"; }); document.getElementById("ct-pick").addEventListener("change", e => fillChargeTo(e.target.value)); - document.getElementById("tax-sel").addEventListener("change", calcTotals); - document.getElementById("paid-inp").addEventListener("input", calcTotals); + 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)); } @@ -626,6 +650,47 @@ function currOpts(sel) { ).join(""); } +function getTaxTypeOpts(sel) { + return (cfg["tax-types"] || []).map(tt => { + const lbl = tt.labels?.[lang] ?? tt.labels?.[cfg["default-code"]] ?? tt.key; + return ``; + }).join(""); +} + +function addTaxLine() { + const i = tlid++; + tLines[i] = {}; + const tbody = document.getElementById("tax-tbody"); + const defaultKey = (cfg["tax-types"]||[])[0]?.key || ""; + + const tr = document.createElement("tr"); + tr.className = "tax-row"; tr.id = `tlr-${i}`; + tr.innerHTML = ` + +
+ + + + +
+ + 0.00`; + tbody.appendChild(tr); + calcTotals(); +} + +function removeTaxLine(i) { + document.getElementById(`tlr-${i}`)?.remove(); + delete tLines[i]; + calcTotals(); +} + // ── Add / remove line ───────────────────────────────────────────────────────── function addLine() { const i = lid++; @@ -753,13 +818,23 @@ function calcTotals() { sub += pn(document.getElementById(`qty-${i}`)?.value) * pn(document.getElementById(`price-${i}`)?.value); }); - const taxRate = pn(document.getElementById("tax-sel")?.value) / 100; - const taxAmt = sub * taxRate; - const paid = pn(document.getElementById("paid-inp")?.value); - const toPay = sub + taxAmt - paid; + + let totalTax = 0; + Object.keys(tLines).forEach(i => { + const val = pn(document.getElementById(`tv-${i}`)?.value); + const type = document.getElementById(`tt-${i}`)?.value || "pct"; + const amt = type === "pct" ? sub * (val / 100) : val; + totalTax += amt; + const el = document.getElementById(`ta-${i}`); + if (el) el.textContent = fmt(amt); + }); + + const paid = pn(document.getElementById("paid-inp")?.value); + const toPay = sub + totalTax - paid; const set = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = fmt(v); }; - set("v-sub", sub); set("v-tax", taxAmt); set("v-topay", toPay); + set("v-sub", sub); + set("v-topay", toPay); } // ── LocalStorage ────────────────────────────────────────────────────────────── @@ -843,13 +918,19 @@ function relabel() { const genBtn = document.getElementById("btn-generate"); if (genBtn) genBtn.textContent = t("generate-invoice"); - const taxSel = document.getElementById("tax-sel"); - if (taxSel) { - const cur = taxSel.value; - taxSel.innerHTML = (cfg["tax-rates"] || []).map(tr => - `` - ).join(""); - } + // Rebuild dynamic tax line dropdowns + Object.keys(tLines).forEach(i => { + const ttEl = document.getElementById(`tt-${i}`); + if (ttEl) { + const sv = ttEl.value; + ttEl.innerHTML = ``; + ttEl.value = sv; + } + const tkEl = document.getElementById(`tk-${i}`); + if (tkEl) { const sv = tkEl.value; tkEl.innerHTML = getTaxTypeOpts(sv); } + }); + const atBtn = document.getElementById("btn-add-tax"); + if (atBtn) atBtn.textContent = t("add-tax"); const ctPick = document.getElementById("ct-pick"); if (ctPick) { @@ -935,23 +1016,33 @@ function gatherData(renderLang) { rows.push({ qty, uomLbl, desc, price, tot, fxNote }); }); - const taxRate = pn(document.getElementById("tax-sel")?.value); - const taxAmt = sub * (taxRate / 100); - const paid = pn(document.getElementById("paid-inp")?.value); - const toPay = sub + taxAmt - paid; - const taxRateObj = (cfg["tax-rates"]||[]).find(r => r.rate == taxRate); - const taxLabel = taxRateObj ? tTax(taxRateObj, dl) : `${td("tax")} ${taxRate}%`; + let totalTax = 0; + const taxes = []; + Object.keys(tLines).sort((a,b)=>+a-+b).forEach(i => { + const val = pn(document.getElementById(`tv-${i}`)?.value); + const type = document.getElementById(`tt-${i}`)?.value || "pct"; + const key = document.getElementById(`tk-${i}`)?.value || ""; + const ttObj = (cfg["tax-types"]||[]).find(x => x.key === key); + const label = ttObj?.labels?.[dl] ?? ttObj?.labels?.[cfg["default-code"]] ?? key; + const amt = type === "pct" ? sub * (val / 100) : val; + totalTax += amt; + const lineLabel = type === "pct" ? `${label} ${val}%` : `${label} (${fmt(val)})`; + taxes.push({ val, type, key, label, lineLabel, amt }); + }); + + const paid = pn(document.getElementById("paid-inp")?.value); + const toPay = sub + totalTax - paid; 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 }; + rows, sub, taxes, totalTax, paid, toPay }; } // ── Build HTML preview ──────────────────────────────────────────────────────── function buildPreviewHTML() { 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(); + rows, sub, taxes, paid, toPay } = gatherData(); const linesHTML = rows.map(row => { const fxLine = row.fxNote @@ -1016,8 +1107,8 @@ function buildPreviewHTML() { ${h(td("subtotal"))} ${fmt(sub)} - ${taxRate > 0 ? `${h(taxLabel)}${fmt(taxAmt)}` : ""} - ${paid > 0 ? `${h(td("paid"))}−${fmt(paid)}` : ""} + ${taxes.map(tx => `${h(tx.lineLabel)}${fmt(tx.amt)}`).join("")} + ${paid > 0 ? `${h(td("paid"))}−${fmt(paid)}` : ""} ${h(td("to-pay"))} @@ -1031,10 +1122,12 @@ function buildPDF() { if (!window.jspdf) { alert("PDF library not loaded — check your internet connection."); return; } const { jsPDF } = window.jspdf; - const doc = new jsPDF({ unit: "mm", format: "a4" }); + const paperFmt = (cfg["paper-format"] || "a4").toLowerCase(); + const doc = new jsPDF({ unit: "mm", format: paperFmt }); // Page geometry - const PW = 210, PH = 297; + const PW = paperFmt === "letter" ? 215.9 : 210; + const PH = paperFmt === "letter" ? 279.4 : 297; const ML = 15, MR = 15, MT = 15; const CW = PW - ML - MR; // 180mm content width const XR = PW - MR; // 195mm right edge @@ -1051,7 +1144,7 @@ function buildPDF() { 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(); + rows, sub, taxes, paid, toPay } = gatherData(); // ── HEADER ──────────────────────────────────────────────────────────────── let y = MT; @@ -1209,8 +1302,8 @@ function buildPDF() { const totRows = [ [td("subtotal"), fmt(sub)], - ...(taxRate > 0 ? [[taxLabel, fmt(taxAmt)]] : []), - ...(paid > 0 ? [[td("paid"), "−" + fmt(paid)]] : []), + ...taxes.map(tx => [tx.lineLabel, fmt(tx.amt)]), + ...(paid > 0 ? [[td("paid"), "−" + fmt(paid)]] : []), ]; totRows.forEach(([lbl, val]) => {