Configurable date/paper format; multi-line dynamic tax system

Date format
- Replace hardcoded toLocaleDateString with a config-driven formatter
- date-format key in config (default "d MMMM YYYY") supports tokens:
  d, dd, M, MM, MMM, MMMM, YY, YYYY (regex replace, longest match first)

Paper format
- paper-format key in config: "a4" (default) or "letter"
- Passed directly to jsPDF; page dimensions adjusted accordingly

Tax system redesign
- Remove single-select tax-rates from config; add tax-types list, each
  with a key and per-language labels (VAT, GST, Sales Tax, Withholding,
  Other)
- Totals section now has a dynamic tax-tbody: each row has a number
  field, a % / Amount selector, and a tax-type label selector; rows
  are added with "+ Add new tax" and removed with ×
- calcTotals iterates tLines: % rows compute sub × (val/100), Amount
  rows use the fixed value directly; each row shows its calculated
  amount live
- gatherData collects all tax lines into a taxes[] array with lineLabel
  (e.g. "VAT 7%" or "GST (50.00)") and amt; both preview and PDF
  render one row per tax entry
- relabel() rebuilds tax-line dropdowns on language switch

https://claude.ai/code/session_015iyCBgoTXNNqaErR287U1u
This commit is contained in:
Claude 2026-05-19 08:26:52 +00:00
parent f90718ba34
commit 15addbeae8
No known key found for this signature in database
2 changed files with 206 additions and 86 deletions

View file

@ -21,33 +21,45 @@ languages:
name: Norsk name: Norsk
direction: ltr direction: ltr
# ── Tax / VAT options ────────────────────────────────────────────────────────── # ── Date and paper format ─────────────────────────────────────────────────────
tax-rates: # Date tokens: d=day, dd=day(0-padded), M=month number, MM=month(0-padded),
- label-en: "No Tax (0%)" # MMM=short month name, MMMM=full month name, YY=2-digit year, YYYY=4-digit year
label-de: "Keine Steuer (0%)" date-format: "d MMMM YYYY"
label-fr: "Sans taxe (0%)" # paper-format: a4 or letter
label-no: "Ingen mva (0%)" paper-format: a4
rate: 0
- label-en: "VAT 5%" # ── Tax types (user builds tax lines; these are the label options) ─────────────
label-de: "MwSt. 5%" tax-types:
label-fr: "TVA 5%" - key: vat
label-no: "Mva 5%" labels:
rate: 5 en: VAT
- label-en: "VAT 10%" de: MwSt.
label-de: "MwSt. 10%" fr: TVA
label-fr: "TVA 10%" "no": MVA
label-no: "Mva 10%" - key: gst
rate: 10 labels:
- label-en: "VAT 20%" en: GST
label-de: "MwSt. 20%" de: GST
label-fr: "TVA 20%" fr: TPS
label-no: "Mva 20%" "no": GST
rate: 20 - key: sales-tax
- label-en: "VAT 25%" labels:
label-de: "MwSt. 25%" en: Sales Tax
label-fr: "TVA 25%" de: Umsatzsteuer
label-no: "Mva 25%" fr: Taxe de vente
rate: 25 "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 ────────────────────────────────────────────────────────── # ── Units of measure ──────────────────────────────────────────────────────────
uom: uom:
@ -396,6 +408,21 @@ translations:
de: Rechnungswährung de: Rechnungswährung
fr: Devise de facturation fr: Devise de facturation
"no": Fakturavaluta "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: converted-from:
en: Converted from en: Converted from
de: Umgerechnet aus de: Umgerechnet aus

View file

@ -160,8 +160,15 @@
.tot-tbl .val { text-align: right; width: 150px; font-size: 13px; font-weight: 600; } .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 td { background: var(--navy); color: var(--white); font-size: 15px; font-weight: 700; }
.tot-tbl .final .lbl { color: rgba(255,255,255,.75); } .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; } .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 ────────────────────────────────────────────────────── */ /* ── Generate button ────────────────────────────────────────────────────── */
#btn-generate { #btn-generate {
@ -325,7 +332,9 @@
let cfg = null; let cfg = null;
let lang = "en"; let lang = "en";
let lid = 0; let lid = 0;
const lines = {}; const lines = {};
let tlid = 0;
const tLines = {};
// ── i18n ────────────────────────────────────────────────────────────────────── // ── i18n ──────────────────────────────────────────────────────────────────────
function t(key) { function t(key) {
@ -334,12 +343,6 @@ function t(key) {
return e[lang] ?? e[cfg["default-code"]] ?? 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 ─────────────────────────────────────────────────────────────────── // ── Numbers ───────────────────────────────────────────────────────────────────
function fmt(n) { function fmt(n) {
if (n === "" || n == null || isNaN(+n)) return "0.00"; 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; } function pn(s) { return parseFloat(String(s ?? 0).replace(/,/g, "")) || 0; }
// ── Date ───────────────────────────────────────────────────────────────────── // ── 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) { function fmtDate(v) {
if (!v) return ""; if (!v) return "";
const [y, m, d] = v.split("-"); const [yr, mo, dy] = v.split("-").map(Number);
return new Date(+y, +m - 1, +d).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }); 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 ─────────────────────────────────────────────────────────────── // ── HTML escape ───────────────────────────────────────────────────────────────
@ -440,10 +461,8 @@ function buildForm() {
const curOpts = (cfg.currencies || []).map((c, i) => const curOpts = (cfg.currencies || []).map((c, i) =>
`<option value="${h(c)}" ${i === 0 ? "selected" : ""}>${h(c)}</option>`).join(""); `<option value="${h(c)}" ${i === 0 ? "selected" : ""}>${h(c)}</option>`).join("");
const pcOpts = (cfg["project-codes"] || []).map(pc => `<option value="${h(pc)}">${h(pc)}</option>`).join(""); const pcOpts = (cfg["project-codes"] || []).map(pc => `<option value="${h(pc)}">${h(pc)}</option>`).join("");
const ctOpts = (cfg["charge-to"] || []).map((ct, i) => `<option value="${i}">${h(ct.display || ct.name)}</option>`).join(""); const ctOpts = (cfg["charge-to"] || []).map((ct, i) => `<option value="${i}">${h(ct.display || ct.name)}</option>`).join("");
const taxOpts = (cfg["tax-rates"] || []).map((tr, i) =>
`<option value="${tr.rate}" ${i === 0 ? "selected" : ""}>${h(tTax(tr))}</option>`).join("");
document.getElementById("form-root").innerHTML = ` document.getElementById("form-root").innerHTML = `
<form id="the-form" novalidate> <form id="the-form" novalidate>
@ -542,22 +561,28 @@ function buildForm() {
<div id="totals-card"> <div id="totals-card">
<table class="tot-tbl"> <table class="tot-tbl">
<tr> <tbody id="tot-pre">
<td class="lbl" id="lbl-sub">${t("subtotal")}</td> <tr>
<td class="val" id="v-sub">0.00</td> <td class="lbl" id="lbl-sub">${t("subtotal")}</td>
</tr> <td class="val" id="v-sub">0.00</td>
<tr> </tr>
<td class="lbl"><select id="tax-sel" class="tax-sel">${taxOpts}</select></td> </tbody>
<td class="val" id="v-tax">0.00</td> <tbody id="tax-tbody"></tbody>
</tr> <tbody id="tot-post">
<tr> <tr>
<td class="lbl" id="lbl-paid">${t("paid")}</td> <td colspan="2" class="tax-add-cell">
<td class="val"><input id="paid-inp" class="paid-inp" type="number" value="0" min="0" step="0.01"></td> <button type="button" class="btn-add-tax" id="btn-add-tax" onclick="addTaxLine()">${t("add-tax")}</button>
</tr> </td>
<tr class="final"> </tr>
<td class="lbl" id="lbl-topay">${t("to-pay")}</td> <tr>
<td class="val" id="v-topay">0.00</td> <td class="lbl" id="lbl-paid">${t("paid")}</td>
</tr> <td class="val"><input id="paid-inp" class="paid-inp" type="number" value="0" min="0" step="0.01"></td>
</tr>
<tr class="final">
<td class="lbl" id="lbl-topay">${t("to-pay")}</td>
<td class="val" id="v-topay">0.00</td>
</tr>
</tbody>
</table> </table>
</div> </div>
@ -568,8 +593,7 @@ function buildForm() {
document.getElementById("pcode-other-wrap").style.display = this.value === "__other__" ? "block" : "none"; 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("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.getElementById("the-form").addEventListener("submit", e => { e.preventDefault(); generateInvoice(); });
document.querySelectorAll("[data-ls]").forEach(el => el.addEventListener("change", saveStorage)); document.querySelectorAll("[data-ls]").forEach(el => el.addEventListener("change", saveStorage));
} }
@ -626,6 +650,47 @@ function currOpts(sel) {
).join(""); ).join("");
} }
function getTaxTypeOpts(sel) {
return (cfg["tax-types"] || []).map(tt => {
const lbl = tt.labels?.[lang] ?? tt.labels?.[cfg["default-code"]] ?? tt.key;
return `<option value="${h(tt.key)}" ${tt.key === sel ? "selected" : ""}>${h(lbl)}</option>`;
}).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 = `
<td class="lbl">
<div class="tax-inputs">
<input type="number" id="tv-${i}" value="" placeholder="0" min="0" step="any"
oninput="calcTotals()" style="width:68px">
<select id="tt-${i}" style="width:auto" onchange="calcTotals()">
<option value="pct">${t("tax-pct")}</option>
<option value="amt">${t("tax-amount")}</option>
</select>
<select id="tk-${i}" style="width:auto" onchange="calcTotals()">
${getTaxTypeOpts(defaultKey)}
</select>
<button type="button" class="btn-rm-tax" onclick="removeTaxLine(${i})">&#x00D7;</button>
</div>
</td>
<td class="val" id="ta-${i}">0.00</td>`;
tbody.appendChild(tr);
calcTotals();
}
function removeTaxLine(i) {
document.getElementById(`tlr-${i}`)?.remove();
delete tLines[i];
calcTotals();
}
// ── Add / remove line ───────────────────────────────────────────────────────── // ── Add / remove line ─────────────────────────────────────────────────────────
function addLine() { function addLine() {
const i = lid++; const i = lid++;
@ -753,13 +818,23 @@ function calcTotals() {
sub += pn(document.getElementById(`qty-${i}`)?.value) * sub += pn(document.getElementById(`qty-${i}`)?.value) *
pn(document.getElementById(`price-${i}`)?.value); pn(document.getElementById(`price-${i}`)?.value);
}); });
const taxRate = pn(document.getElementById("tax-sel")?.value) / 100;
const taxAmt = sub * taxRate; let totalTax = 0;
const paid = pn(document.getElementById("paid-inp")?.value); Object.keys(tLines).forEach(i => {
const toPay = sub + taxAmt - paid; 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); }; 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 ────────────────────────────────────────────────────────────── // ── LocalStorage ──────────────────────────────────────────────────────────────
@ -843,13 +918,19 @@ function relabel() {
const genBtn = document.getElementById("btn-generate"); const genBtn = document.getElementById("btn-generate");
if (genBtn) genBtn.textContent = t("generate-invoice"); if (genBtn) genBtn.textContent = t("generate-invoice");
const taxSel = document.getElementById("tax-sel"); // Rebuild dynamic tax line dropdowns
if (taxSel) { Object.keys(tLines).forEach(i => {
const cur = taxSel.value; const ttEl = document.getElementById(`tt-${i}`);
taxSel.innerHTML = (cfg["tax-rates"] || []).map(tr => if (ttEl) {
`<option value="${tr.rate}" ${String(tr.rate) === cur ? "selected" : ""}>${h(tTax(tr))}</option>` const sv = ttEl.value;
).join(""); ttEl.innerHTML = `<option value="pct">${t("tax-pct")}</option><option value="amt">${t("tax-amount")}</option>`;
} 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"); const ctPick = document.getElementById("ct-pick");
if (ctPick) { if (ctPick) {
@ -935,23 +1016,33 @@ function gatherData(renderLang) {
rows.push({ qty, uomLbl, desc, price, tot, fxNote }); rows.push({ qty, uomLbl, desc, price, tot, fxNote });
}); });
const taxRate = pn(document.getElementById("tax-sel")?.value); let totalTax = 0;
const taxAmt = sub * (taxRate / 100); const taxes = [];
const paid = pn(document.getElementById("paid-inp")?.value); Object.keys(tLines).sort((a,b)=>+a-+b).forEach(i => {
const toPay = sub + taxAmt - paid; const val = pn(document.getElementById(`tv-${i}`)?.value);
const taxRateObj = (cfg["tax-rates"]||[]).find(r => r.rate == taxRate); const type = document.getElementById(`tt-${i}`)?.value || "pct";
const taxLabel = taxRateObj ? tTax(taxRateObj, dl) : `${td("tax")} ${taxRate}%`; 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, return { dl, td, sName, sAddr, sCntry, sPh, sEm, iDate, pCode, iNo, iCur,
ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat, ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat,
rows, sub, taxRate, taxAmt, taxLabel, paid, toPay }; rows, sub, taxes, totalTax, paid, toPay };
} }
// ── Build HTML preview ──────────────────────────────────────────────────────── // ── Build HTML preview ────────────────────────────────────────────────────────
function buildPreviewHTML() { function buildPreviewHTML() {
const { td, sName, sAddr, sCntry, sPh, sEm, iDate, pCode, iNo, iCur, const { td, sName, sAddr, sCntry, sPh, sEm, iDate, pCode, iNo, iCur,
ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat, 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 linesHTML = rows.map(row => {
const fxLine = row.fxNote const fxLine = row.fxNote
@ -1016,8 +1107,8 @@ function buildPreviewHTML() {
<td class="tl">${h(td("subtotal"))}</td> <td class="tl">${h(td("subtotal"))}</td>
<td class="tv">${fmt(sub)}</td> <td class="tv">${fmt(sub)}</td>
</tr> </tr>
${taxRate > 0 ? `<tr><td class="sp"></td><td class="tl">${h(taxLabel)}</td><td class="tv">${fmt(taxAmt)}</td></tr>` : ""} ${taxes.map(tx => `<tr><td class="sp"></td><td class="tl">${h(tx.lineLabel)}</td><td class="tv">${fmt(tx.amt)}</td></tr>`).join("")}
${paid > 0 ? `<tr><td class="sp"></td><td class="tl">${h(td("paid"))}</td><td class="tv">&minus;${fmt(paid)}</td></tr>` : ""} ${paid > 0 ? `<tr><td class="sp"></td><td class="tl">${h(td("paid"))}</td><td class="tv">&minus;${fmt(paid)}</td></tr>` : ""}
<tr class="fin"> <tr class="fin">
<td class="sp"></td> <td class="sp"></td>
<td class="tl">${h(td("to-pay"))}</td> <td class="tl">${h(td("to-pay"))}</td>
@ -1031,10 +1122,12 @@ function buildPDF() {
if (!window.jspdf) { alert("PDF library not loaded — check your internet connection."); return; } if (!window.jspdf) { alert("PDF library not loaded — check your internet connection."); return; }
const { jsPDF } = window.jspdf; 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 // 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 ML = 15, MR = 15, MT = 15;
const CW = PW - ML - MR; // 180mm content width const CW = PW - ML - MR; // 180mm content width
const XR = PW - MR; // 195mm right edge 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, const { dl, td, sName, sAddr, sCntry, sPh, sEm, iDate, pCode, iNo, iCur,
ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat, ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat,
rows, sub, taxRate, taxAmt, taxLabel, paid, toPay } = gatherData(); rows, sub, taxes, paid, toPay } = gatherData();
// ── HEADER ──────────────────────────────────────────────────────────────── // ── HEADER ────────────────────────────────────────────────────────────────
let y = MT; let y = MT;
@ -1209,8 +1302,8 @@ function buildPDF() {
const totRows = [ const totRows = [
[td("subtotal"), fmt(sub)], [td("subtotal"), fmt(sub)],
...(taxRate > 0 ? [[taxLabel, fmt(taxAmt)]] : []), ...taxes.map(tx => [tx.lineLabel, fmt(tx.amt)]),
...(paid > 0 ? [[td("paid"), "" + fmt(paid)]] : []), ...(paid > 0 ? [[td("paid"), "" + fmt(paid)]] : []),
]; ];
totRows.forEach(([lbl, val]) => { totRows.forEach(([lbl, val]) => {