Restructure layout to 2×2 grid: Sender|Payment / Charge-to|Invoice details

Form and invoice output now follow the same four-panel layout:
  Sender          | Payment details
  Charge to       | Invoice details (INVOICE title + meta)
  Invoice lines (full width)

- Payment card is standalone (top-right), bank fields always visible
- Bank fields saved to localStorage so sender enters them once
- hide-payment-info suppresses bank details from invoice output only
- fillChargeTo() no longer touches payment/bank fields
- gatherData() always collects bank fields; hidePaymentOut passed to renderers
- buildPreviewHTML() uses .d-4hdr grid with four .d-tl/.d-tr/.d-bl/.d-br cells
- buildPDF() rewritten: row-1 (sender|payment), light divider, row-2 (charge-to|INVOICE+meta), navy rule, then lines
- Removed bank config fields from Example NGO (those belong to the sender)

https://claude.ai/code/session_015iyCBgoTXNNqaErR287U1u
This commit is contained in:
Claude 2026-05-19 09:25:09 +00:00
parent e4cff52909
commit 275d3a3b71
No known key found for this signature in database
2 changed files with 189 additions and 261 deletions

View file

@ -131,12 +131,6 @@ charge-to:
vat-id: "GB123456789" vat-id: "GB123456789"
reg-no: "01234567" reg-no: "01234567"
currency: GBP currency: GBP
pay-account-holder: Example Non-Profit Organisation
pay-account-no: "GB29 NWBK 6016 1331 9268 19"
pay-bic: "NWBKGB2L"
pay-bank-address1: "NatWest, 1 Princes Street"
pay-bank-address2: "London EC2R 8PB"
pay-ref: ""
# ── Project codes ────────────────────────────────────────────────────────────── # ── Project codes ──────────────────────────────────────────────────────────────
project-codes: project-codes:

View file

@ -226,33 +226,43 @@
font-size: 11.5px; color: #111827; font-size: 11.5px; color: #111827;
} }
.d-hdr { /* 2×2 invoice header grid */
display: flex; justify-content: space-between; align-items: flex-start; .d-4hdr {
padding-bottom: 20px; margin-bottom: 24px; display: grid; grid-template-columns: 1fr 1fr;
border-bottom: 3px solid var(--navy); border-bottom: 3px solid var(--navy);
margin-bottom: 22px;
} }
.d-4hdr > div { padding: 0; }
.d-4hdr > .d-tl { padding: 0 20px 16px 0; border-bottom: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb; }
.d-4hdr > .d-tr { padding: 0 0 16px 20px; border-bottom: 1px solid #e5e7eb; }
.d-4hdr > .d-bl { padding: 16px 20px 16px 0; border-right: 1px solid #e5e7eb; }
.d-4hdr > .d-br { padding: 16px 0 16px 20px; }
.d-sender .name { font-size: 17px; font-weight: 700; color: var(--navy); margin-bottom: 6px; } .d-sender .name { font-size: 17px; font-weight: 700; color: var(--navy); margin-bottom: 6px; }
.d-sender p { font-size: 10.5px; color: #4b5563; margin-bottom: 2px; } .d-sender p { font-size: 10.5px; color: #4b5563; margin-bottom: 2px; }
.d-title { text-align: right; }
.d-title h1 { font-size: 30px; font-weight: 800; letter-spacing: 5px; color: var(--navy); margin-bottom: 14px; } .d-pay-hdr .ph-lbl { font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 1.2px; color: #6b7280; margin-bottom: 6px; }
.d-pay-hdr .ph-terms { font-size: 10.5px; color: #4b5563; margin-bottom: 6px; }
.d-pay-hdr .ph-grid { display: grid; grid-template-columns: 130px 1fr; gap: 3px 8px; font-size: 10.5px; margin-top: 6px; }
.d-pay-hdr .ph-grid .pl { color: #6b7280; }
.d-pay-hdr .ph-grid .pv { font-weight: 500; word-break: break-all; }
.d-pay-hdr .ph-ref { margin-top: 6px; font-size: 10.5px; color: #4b5563; }
.d-pay-hdr .ph-ref strong { color: #111827; }
.d-bill .bt-lbl { font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 1.2px; color: #6b7280; margin-bottom: 5px; }
.d-bill .bt-name { font-size: 13px; font-weight: 700; color: var(--navy); margin-bottom: 3px; }
.d-bill p { font-size: 10.5px; color: #4b5563; margin-bottom: 2px; }
.d-bill .bt-meta { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 6px; }
.d-bill .bt-meta span { font-size: 10px; color: #4b5563; }
.d-bill .bt-meta strong { color: #111827; }
.d-inv-meta { text-align: right; }
.d-inv-meta h1 { font-size: 28px; font-weight: 800; letter-spacing: 5px; color: var(--navy); margin-bottom: 14px; }
.d-meta { border-collapse: collapse; margin-left: auto; } .d-meta { border-collapse: collapse; margin-left: auto; }
.d-meta td { font-size: 10.5px; padding: 2px 0 2px 12px; } .d-meta td { font-size: 10.5px; padding: 2px 0 2px 12px; }
.d-meta .ml { color: #6b7280; text-align: right; } .d-meta .ml { color: #6b7280; text-align: right; }
.d-meta .mv { font-weight: 600; text-align: right; } .d-meta .mv { font-weight: 600; text-align: right; }
.d-bill {
padding: 12px 16px; margin-bottom: 22px;
background: #f8f9fa;
border-left: 4px solid var(--navy);
border-radius: 0 var(--radius) var(--radius) 0;
}
.d-bill .bt-lbl { font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 1.2px; color: #6b7280; margin-bottom: 5px; }
.d-bill .bt-name { font-size: 13px; font-weight: 700; color: var(--navy); margin-bottom: 3px; }
.d-bill p { font-size: 10.5px; color: #4b5563; margin-bottom: 2px; }
.d-bill .bt-meta { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 6px; }
.d-bill .bt-meta span { font-size: 10px; color: #4b5563; }
.d-bill .bt-meta strong { color: #111827; }
.d-lines { width: 100%; border-collapse: collapse; margin-bottom: 20px; } .d-lines { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
.d-lines thead tr { background: var(--navy); color: white; } .d-lines thead tr { background: var(--navy); color: white; }
.d-lines thead th { padding: 7px 10px; font-size: 9.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; text-align: left; } .d-lines thead th { padding: 7px 10px; font-size: 9.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; text-align: left; }
@ -264,15 +274,6 @@
.d-lines tbody td.b { font-weight: 600; } .d-lines tbody td.b { font-weight: 600; }
.d-fx-note { font-size: 9.5px; color: #6b7280; margin-top: 3px; } .d-fx-note { font-size: 9.5px; color: #6b7280; margin-top: 3px; }
.d-payment { border-top: 1px solid #e5e7eb; margin-top: 20px; padding-top: 14px; }
.d-payment .pay-title { font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 1.2px; color: #6b7280; margin-bottom: 8px; }
.d-payment .pay-terms { font-size: 10.5px; color: #4b5563; margin-bottom: 8px; }
.d-payment .pay-grid { display: grid; grid-template-columns: 140px 1fr; gap: 3px 10px; font-size: 10.5px; margin-bottom: 4px; }
.d-payment .pay-grid .pl { color: #6b7280; }
.d-payment .pay-grid .pv { color: #111827; font-weight: 500; }
.d-payment .pay-ref { margin-top: 8px; font-size: 10.5px; color: #4b5563; }
.d-payment .pay-ref strong { color: #111827; }
.d-tots { width: 100%; border-collapse: collapse; } .d-tots { width: 100%; border-collapse: collapse; }
.d-tots td { padding: 5px 10px; font-size: 11.5px; } .d-tots td { padding: 5px 10px; font-size: 11.5px; }
.d-tots .sp { width: 55%; } .d-tots .sp { width: 55%; }
@ -282,24 +283,19 @@
.d-tots .fin td { background: var(--navy); color: white; font-size: 14px; font-weight: 700; border-top: 2px solid var(--navy); } .d-tots .fin td { background: var(--navy); color: white; font-size: 14px; font-weight: 700; border-top: 2px solid var(--navy); }
.d-tots .fin .tl { color: rgba(255,255,255,.75); } .d-tots .fin .tl { color: rgba(255,255,255,.75); }
/* ── Charge-to / Payment two-column layout ──────────────────────────────── */ /* ── Payment card form fields ────────────────────────────────────────────── */
.ct-two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: start; }
@media (max-width: 720px) { .ct-two-col { grid-template-columns: 1fr; } }
#payment-panel { border-left: 2px solid var(--border-light); padding-left: 20px; }
@media (max-width: 720px) { #payment-panel { border-left: none; padding-left: 0; border-top: 2px solid var(--border-light); padding-top: 16px; } }
.pay-section-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; color: var(--navy); margin-bottom: 10px; } .pay-section-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; color: var(--navy); margin-bottom: 10px; }
.pay-terms-row { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; } .pay-terms-row { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
.pay-terms-row label { font-size: 12px; color: var(--text-muted); white-space: nowrap; } .pay-terms-row label { font-size: 12px; color: var(--text-muted); white-space: nowrap; }
.pay-terms-row input { width: 64px; } .pay-terms-row input { width: 64px; }
.pay-terms-row span { font-size: 12px; color: var(--text-muted); } .pay-terms-row span { font-size: 12px; color: var(--text-muted); }
.pay-by-row { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; } .pay-by-row { display: flex; align-items: center; gap: 6px; margin-bottom: 12px; }
.pay-by-row label { font-size: 12px; color: var(--text-muted); white-space: nowrap; } .pay-by-row label { font-size: 12px; color: var(--text-muted); white-space: nowrap; }
#paybydisp { font-size: 13px; font-weight: 600; color: var(--text); } #paybydisp { font-size: 13px; font-weight: 600; color: var(--text); }
/* ── Locked charge-to / bank fields ─────────────────────────────────────── */ /* ── Locked charge-to fields ─────────────────────────────────────────────── */
#ct-fields.locked input, #ct-fields.locked input,
#ct-fields.locked select, #ct-fields.locked select {
#bank-section.locked input {
background: #f8f9fa; background: #f8f9fa;
color: var(--text-muted); color: var(--text-muted);
border-color: var(--border-light); border-color: var(--border-light);
@ -511,36 +507,37 @@ function buildForm() {
<input id="se" type="email" data-ls="se" autocomplete="email"></div> <input id="se" type="email" data-ls="se" autocomplete="email"></div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title" id="sec-invdet">${t("invoice-details-section")}</div> <div class="card-title" id="lbl-pay-sec">${t("payment")}</div>
<div class="fg"><label id="lbl-idate" for="idate">${t("invoice-date")}</label> <div class="pay-terms-row">
<input id="idate" type="date" value="${dateDef}"></div> <label id="lbl-pterm">${t("payment-terms")}:</label>
<div class="fg"><label id="lbl-icur" for="icur">${t("invoice-currency")}</label> <input id="pterm" type="number" min="0" value="7" oninput="calcPayBy()" data-ls="pterm">
<select id="icur" data-ls="icur">${curOpts}</select></div> <span id="lbl-days">${t("payment-days")}</span>
<div class="fg"><label id="lbl-pcode" for="pcode">${t("project-code")}</label>
<select id="pcode">
<option value="">${t("select")}</option>
${pcOpts}
<option value="__other__">${t("other")}</option>
</select></div>
<div class="fg" id="pcode-other-wrap" style="display:none">
<label id="lbl-pcode-other" for="pcode-other">${t("project-code")} (${t("other")})</label>
<input id="pcode-other" type="text">
</div> </div>
<div class="fg"><label id="lbl-ino" for="ino">${t("invoice-no")}</label> <div class="pay-by-row">
<input id="ino" type="text" data-ls="ino"></div> <label id="lbl-paybyl">${t("pay-by")}:</label>
<span id="paybydisp"></span>
</div>
<div class="fg"><label id="lbl-pacct">${t("account-holder")}</label>
<input id="pacct" type="text" data-ls="pacct"></div>
<div class="fg"><label id="lbl-piban">${t("account-no")}</label>
<input id="piban" type="text" data-ls="piban"></div>
<div class="fg"><label id="lbl-pbic">${t("bank-bic")}</label>
<input id="pbic" type="text" data-ls="pbic"></div>
<div class="fg"><label id="lbl-pbadr">${t("bank-address")}</label>
<input id="pbadr1" type="text" data-ls="pbadr1">
<input id="pbadr2" type="text" style="margin-top:6px" data-ls="pbadr2"></div>
<div class="fg"><label id="lbl-pref">${t("payment-ref")}</label>
<input id="pref" type="text" data-ls="pref"></div>
</div> </div>
</div> <div class="card">
<div class="card-title" style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<div class="card"> <span id="sec-ct">${t("charge-to")}:</span>
<div class="card-title" style="display:flex;align-items:center;gap:8px;flex-wrap:wrap"> <select id="ct-pick" style="width:auto;font-size:12px">
<span id="sec-ct">${t("charge-to")}:</span> <option value="">${t("select")}</option>
<select id="ct-pick" style="width:auto;font-size:12px"> ${ctOpts}
<option value="">${t("select")}</option> <option value="__other__">${t("other")}</option>
${ctOpts} </select>
<option value="__other__">${t("other")}</option> </div>
</select>
</div>
<div class="ct-two-col">
<div id="ct-fields"> <div id="ct-fields">
<div class="fg"><label id="lbl-ctn" for="ctn">${t("charge-to-name")}</label> <div class="fg"><label id="lbl-ctn" for="ctn">${t("charge-to-name")}</label>
<input id="ctn" type="text"></div> <input id="ctn" type="text"></div>
@ -563,31 +560,25 @@ function buildForm() {
<div class="fi"><label id="lbl-creg">${t("registration-no")}:</label> <div class="fi"><label id="lbl-creg">${t("registration-no")}:</label>
<input id="creg" type="text"></div> <input id="creg" type="text"></div>
</div> </div>
<div id="payment-panel"> </div>
<div class="pay-section-title" id="lbl-pay-sec">${t("payment")}</div> <div class="card">
<div class="pay-terms-row"> <div class="card-title" id="sec-invdet">${t("invoice-details-section")}</div>
<label id="lbl-pterm">${t("payment-terms")}:</label> <div class="fg"><label id="lbl-idate" for="idate">${t("invoice-date")}</label>
<input id="pterm" type="number" min="0" value="7" oninput="calcPayBy()"> <input id="idate" type="date" value="${dateDef}"></div>
<span id="lbl-days">${t("payment-days")}</span> <div class="fg"><label id="lbl-icur" for="icur">${t("invoice-currency")}</label>
</div> <select id="icur" data-ls="icur">${curOpts}</select></div>
<div class="pay-by-row"> <div class="fg"><label id="lbl-pcode" for="pcode">${t("project-code")}</label>
<label id="lbl-paybyl">${t("pay-by")}:</label> <select id="pcode">
<span id="paybydisp"></span> <option value="">${t("select")}</option>
</div> ${pcOpts}
<div id="bank-section" style="display:none"> <option value="__other__">${t("other")}</option>
<div class="fg"><label id="lbl-pacct">${t("account-holder")}</label> </select></div>
<input id="pacct" type="text"></div> <div class="fg" id="pcode-other-wrap" style="display:none">
<div class="fg"><label id="lbl-piban">${t("account-no")}</label> <label id="lbl-pcode-other" for="pcode-other">${t("project-code")} (${t("other")})</label>
<input id="piban" type="text"></div> <input id="pcode-other" type="text">
<div class="fg"><label id="lbl-pbic">${t("bank-bic")}</label>
<input id="pbic" type="text"></div>
<div class="fg"><label id="lbl-pbadr">${t("bank-address")}</label>
<input id="pbadr1" type="text">
<input id="pbadr2" type="text" style="margin-top:6px"></div>
<div class="fg"><label id="lbl-pref">${t("payment-ref")}</label>
<input id="pref" type="text"></div>
</div>
</div> </div>
<div class="fg"><label id="lbl-ino" for="ino">${t("invoice-no")}</label>
<input id="ino" type="text" data-ls="ino"></div>
</div> </div>
</div> </div>
@ -654,21 +645,9 @@ function fillChargeTo(v) {
const f = (id, val) => { const el = document.getElementById(id); if (el) el.value = val ?? ""; }; const f = (id, val) => { const el = document.getElementById(id); if (el) el.value = val ?? ""; };
const fields = document.getElementById("ct-fields"); const fields = document.getElementById("ct-fields");
const hidePayment = cfg["hide-payment-info"] === true || cfg["hide-payment-info"] === "yes";
const bsec = document.getElementById("bank-section");
if (v === "" || v === "__other__") { 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.remove("locked"); fields?.classList.remove("locked");
if (bsec) {
bsec.classList.remove("locked");
if (!hidePayment && v === "__other__") {
bsec.style.display = "";
} else {
bsec.style.display = "none";
["pacct","piban","pbic","pbadr1","pbadr2","pref"].forEach(id => f(id, ""));
}
}
return; return;
} }
const ct = (cfg["charge-to"] || [])[+v]; const ct = (cfg["charge-to"] || [])[+v];
@ -680,29 +659,6 @@ function fillChargeTo(v) {
f("cvat", ct["vat-id"]); f("creg", ct["reg-no"]); f("cvat", ct["vat-id"]); f("creg", ct["reg-no"]);
fields?.classList.add("locked"); fields?.classList.add("locked");
// Fill bank section from config if provided
if (bsec && !hidePayment) {
const hasBank = ct["pay-account-holder"] || ct["pay-account-no"] || ct["pay-bic"];
if (hasBank) {
f("pacct", ct["pay-account-holder"]);
f("piban", ct["pay-account-no"]);
f("pbic", ct["pay-bic"]);
f("pbadr1", ct["pay-bank-address1"]);
f("pbadr2", ct["pay-bank-address2"]);
f("pref", ct["pay-ref"]);
bsec.style.display = "";
bsec.classList.add("locked");
} else {
bsec.style.display = "none";
["pacct","piban","pbic","pbadr1","pbadr2","pref"].forEach(id => f(id, ""));
bsec.classList.remove("locked");
}
} else if (bsec) {
bsec.style.display = "none";
["pacct","piban","pbic","pbadr1","pbadr2","pref"].forEach(id => f(id, ""));
bsec.classList.remove("locked");
}
// Auto-set invoice currency from recipient config // Auto-set invoice currency from recipient config
if (ct.currency) { if (ct.currency) {
const icurEl = document.getElementById("icur"); const icurEl = document.getElementById("icur");
@ -1087,15 +1043,14 @@ function gatherData(renderLang) {
const pTerm = parseInt(document.getElementById("pterm")?.value) || 0; const pTerm = parseInt(document.getElementById("pterm")?.value) || 0;
const pPayBy = document.getElementById("paybydisp")?.textContent || ""; const pPayBy = document.getElementById("paybydisp")?.textContent || "";
const bsec = document.getElementById("bank-section"); const pAcct = g("pacct");
const bankVisible = bsec && bsec.style.display !== "none"; const pIban = g("piban");
const pAcct = bankVisible ? g("pacct") : ""; const pBic = g("pbic");
const pIban = bankVisible ? g("piban") : ""; const pBadr1 = g("pbadr1");
const pBic = bankVisible ? g("pbic") : ""; const pBadr2 = g("pbadr2");
const pBadr1 = bankVisible ? g("pbadr1"): ""; const pRef = g("pref");
const pBadr2 = bankVisible ? g("pbadr2"): "";
const pRef = bankVisible ? g("pref") : "";
const hasBank = !!(pAcct || pIban || pBic || pBadr1 || pRef); const hasBank = !!(pAcct || pIban || pBic || pBadr1 || pRef);
const hidePaymentOut = cfg["hide-payment-info"] === true || cfg["hide-payment-info"] === "yes";
let sub = 0; let sub = 0;
const rows = []; const rows = [];
@ -1150,7 +1105,7 @@ function gatherData(renderLang) {
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, ctReg, ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat, ctReg,
pTerm, pPayBy, pAcct, pIban, pBic, pBadr1, pBadr2, pRef, hasBank, pTerm, pPayBy, pAcct, pIban, pBic, pBadr1, pBadr2, pRef, hasBank, hidePaymentOut,
rows, sub, taxes, totalTax, paid, toPay }; rows, sub, taxes, totalTax, paid, toPay };
} }
@ -1158,7 +1113,7 @@ function gatherData(renderLang) {
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, ctReg, ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat, ctReg,
pTerm, pPayBy, pAcct, pIban, pBic, pBadr1, pBadr2, pRef, hasPayment, pTerm, pPayBy, pAcct, pIban, pBic, pBadr1, pBadr2, pRef, hasBank, hidePaymentOut,
rows, sub, taxes, paid, toPay } = gatherData(); rows, sub, taxes, paid, toPay } = gatherData();
const linesHTML = rows.map(row => { const linesHTML = rows.map(row => {
@ -1177,16 +1132,40 @@ function buildPreviewHTML() {
</tr>`; </tr>`;
}).join("") || `<tr><td colspan="5" style="text-align:center;color:#9ca3af;padding:20px"></td></tr>`; }).join("") || `<tr><td colspan="5" style="text-align:center;color:#9ca3af;padding:20px"></td></tr>`;
const showBank = hasBank && !hidePaymentOut;
return ` return `
<div class="d-hdr"> <div class="d-4hdr">
<div class="d-sender"> <div class="d-tl d-sender">
${sName ? `<div class="name">${h(sName)}</div>` : ""} ${sName ? `<div class="name">${h(sName)}</div>` : ""}
${sAddr.map(a=>`<p>${h(a)}</p>`).join("")} ${sAddr.map(a=>`<p>${h(a)}</p>`).join("")}
${sCntry ? `<p>${h(sCntry)}</p>` : ""} ${sCntry ? `<p>${h(sCntry)}</p>` : ""}
${sPh ? `<p>${h(td("sender-phone"))}: ${h(sPh)}</p>` : ""} ${sPh ? `<p>${h(td("sender-phone"))}: ${h(sPh)}</p>` : ""}
${sEm ? `<p>${h(td("sender-email"))}: ${h(sEm)}</p>` : ""} ${sEm ? `<p>${h(td("sender-email"))}: ${h(sEm)}</p>` : ""}
</div> </div>
<div class="d-title"> <div class="d-tr d-pay-hdr">
<div class="ph-lbl">${h(td("payment"))}</div>
${pTerm > 0 ? `<div class="ph-terms">${h(td("payment-terms"))}: <strong>${pTerm}</strong> ${h(td("payment-days"))}${pPayBy ? ` &mdash; ${h(td("pay-by"))}: <strong>${h(pPayBy)}</strong>` : ""}</div>` : ""}
${showBank ? `<div class="ph-grid">
${pAcct ? `<span class="pl">${h(td("account-holder"))}</span><span class="pv">${h(pAcct)}</span>` : ""}
${pIban ? `<span class="pl">${h(td("account-no"))}</span><span class="pv">${h(pIban)}</span>` : ""}
${pBic ? `<span class="pl">${h(td("bank-bic"))}</span><span class="pv">${h(pBic)}</span>` : ""}
${pBadr1 || pBadr2 ? `<span class="pl">${h(td("bank-address"))}</span><span class="pv">${[pBadr1,pBadr2].filter(Boolean).map(l=>h(l)).join("<br>")}</span>` : ""}
</div>
${pRef ? `<div class="ph-ref">${h(td("payment-ref"))}: <strong>${h(pRef)}</strong></div>` : ""}` : ""}
</div>
<div class="d-bl d-bill">
<div class="bt-lbl">${h(td("charge-to"))}</div>
${ctName ? `<div class="bt-name">${h(ctName)}</div>` : ""}
${ctAddr.map(a=>`<p>${h(a)}</p>`).join("")}
${ctCntry ? `<p>${h(ctCntry)}</p>` : ""}
<div class="bt-meta">
${ctPh ? `<span><strong>${h(td("charge-to-phone"))}:</strong> ${h(ctPh)}</span>` : ""}
${ctEm ? `<span><strong>${h(td("charge-to-email"))}:</strong> ${h(ctEm)}</span>` : ""}
${ctVat ? `<span><strong>${h(td("vat-id"))}:</strong> ${h(ctVat)}</span>` : ""}
${ctReg ? `<span><strong>${h(td("registration-no"))}:</strong> ${h(ctReg)}</span>` : ""}
</div>
</div>
<div class="d-br d-inv-meta">
<h1>${h(td("invoice"))}</h1> <h1>${h(td("invoice"))}</h1>
<table class="d-meta"> <table class="d-meta">
${iNo ? `<tr><td class="ml">${h(td("invoice-no"))}</td><td class="mv">${h(iNo)}</td></tr>` : ""} ${iNo ? `<tr><td class="ml">${h(td("invoice-no"))}</td><td class="mv">${h(iNo)}</td></tr>` : ""}
@ -1196,19 +1175,6 @@ function buildPreviewHTML() {
</table> </table>
</div> </div>
</div> </div>
${ctName ? `
<div class="d-bill">
<div class="bt-lbl">${h(td("charge-to"))}</div>
<div class="bt-name">${h(ctName)}</div>
${ctAddr.map(a=>`<p>${h(a)}</p>`).join("")}
${ctCntry ? `<p>${h(ctCntry)}</p>` : ""}
<div class="bt-meta">
${ctPh ? `<span><strong>${h(td("charge-to-phone"))}:</strong> ${h(ctPh)}</span>` : ""}
${ctEm ? `<span><strong>${h(td("charge-to-email"))}:</strong> ${h(ctEm)}</span>` : ""}
${ctVat ? `<span><strong>${h(td("vat-id"))}:</strong> ${h(ctVat)}</span>` : ""}
${ctReg ? `<span><strong>${h(td("registration-no"))}:</strong> ${h(ctReg)}</span>` : ""}
</div>
</div>` : ""}
<table class="d-lines"> <table class="d-lines">
<thead><tr> <thead><tr>
<th style="width:50px">${h(td("qty"))}</th> <th style="width:50px">${h(td("qty"))}</th>
@ -1233,18 +1199,7 @@ function buildPreviewHTML() {
<td class="tv">${fmt(toPay)}</td> <td class="tv">${fmt(toPay)}</td>
</tr> </tr>
</table> </table>
${(pTerm > 0 || hasBank) ? ` `;
<div class="d-payment">
<div class="pay-title">${h(td("payment"))}</div>
${pTerm > 0 ? `<div class="pay-terms">${h(td("payment-terms"))}: <strong>${pTerm}</strong> ${h(td("payment-days"))}${pPayBy ? ` &mdash; ${h(td("pay-by"))}: <strong>${h(pPayBy)}</strong>` : ""}</div>` : ""}
${hasBank ? `<div class="pay-grid">
${pAcct ? `<span class="pl">${h(td("account-holder"))}</span><span class="pv">${h(pAcct)}</span>` : ""}
${pIban ? `<span class="pl">${h(td("account-no"))}</span><span class="pv">${h(pIban)}</span>` : ""}
${pBic ? `<span class="pl">${h(td("bank-bic"))}</span><span class="pv">${h(pBic)}</span>` : ""}
${pBadr1 || pBadr2 ? `<span class="pl">${h(td("bank-address"))}</span><span class="pv">${[pBadr1,pBadr2].filter(Boolean).map(l=>h(l)).join("<br>")}</span>` : ""}
</div>
${pRef ? `<div class="pay-ref">${h(td("payment-ref"))}: <strong>${h(pRef)}</strong></div>` : ""}` : ""}
</div>` : ""}`;
} }
// ── Build PDF with jsPDF + Helvetica ───────────────────────────────────────── // ── Build PDF with jsPDF + Helvetica ─────────────────────────────────────────
@ -1274,81 +1229,99 @@ 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, ctReg, ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat, ctReg,
pTerm, pPayBy, pAcct, pIban, pBic, pBadr1, pBadr2, pRef, hasPayment, pTerm, pPayBy, pAcct, pIban, pBic, pBadr1, pBadr2, pRef, hasBank, hidePaymentOut,
rows, sub, taxes, paid, toPay } = gatherData(); rows, sub, taxes, paid, toPay } = gatherData();
// ── HEADER ──────────────────────────────────────────────────────────────── const showBank = hasBank && !hidePaymentOut;
let y = MT;
let sy = y; // sender column bottom tracker
let ry = y; // right column bottom tracker
// Sender name // ── 2×2 HEADER ───────────────────────────────────────────────────────────
if (sName) { const GAP = 8;
fb(13); tc(30,45,69); const LW = (CW - GAP) / 2; // ~86mm each column
tL(sName, ML, sy); sy += 6; const XM_L = ML + LW + GAP; // left edge of right column
}
// Sender address let y = MT;
let ly = y, ry = y; // row-1 bottom trackers (left / right)
// ── Row 1 left: Sender ──
if (sName) { fb(13); tc(30,45,69); tL(sName, ML, ly); ly += 6; }
fn(8.5); tc(75,85,99); fn(8.5); tc(75,85,99);
[...sAddr, sCntry].filter(Boolean).forEach(line => { tL(line, ML, sy); sy += 4.5; }); [...sAddr, sCntry].filter(Boolean).forEach(l => { tL(l, ML, ly); ly += 4.5; });
// Sender contact
if (sPh || sEm) { if (sPh || sEm) {
const parts = []; const parts = [];
if (sPh) parts.push(`${td("sender-phone")}: ${sPh}`); if (sPh) parts.push(`${td("sender-phone")}: ${sPh}`);
if (sEm) parts.push(`${td("sender-email")}: ${sEm}`); if (sEm) parts.push(`${td("sender-email")}: ${sEm}`);
fn(8); tc(107,114,128); fn(8); tc(107,114,128); tL(parts.join(" "), ML, ly); ly += 5;
tL(parts.join(" "), ML, sy); sy += 5;
} }
// INVOICE title (right) // ── Row 1 right: Payment ──
fb(26); tc(30,45,69); if (pTerm > 0 || showBank) {
tR(td("invoice"), XR, ry); ry += 11; fb(7); tc(107,114,128); tL(td("payment").toUpperCase(), XM_L, ry); ry += 5;
if (pTerm > 0) {
const ts = `${td("payment-terms")}: ${pTerm} ${td("payment-days")}${pPayBy ? ` ${td("pay-by")}: ${pPayBy}` : ""}`;
fn(8.5); tc(17,24,39); tL(ts, XM_L, ry); ry += 5;
}
if (showBank) {
const LLBL = 46;
const payRows = [
pAcct ? [td("account-holder"), pAcct] : null,
pIban ? [td("account-no"), pIban] : null,
pBic ? [td("bank-bic"), pBic] : null,
(pBadr1||pBadr2) ? [td("bank-address"), [pBadr1,pBadr2].filter(Boolean).join(", ")] : null,
].filter(Boolean);
payRows.forEach(([lbl, val]) => {
fn(8); tc(107,114,128); tL(lbl + ":", XM_L, ry);
fn(8.5); tc(17,24,39);
const wrapped = sp(val, LW - LLBL - 2);
wrapped.forEach((line, i) => tL(line, XM_L + LLBL, ry + i * 4));
ry += Math.max(4.5, wrapped.length * 4);
});
if (pRef) {
fn(8); tc(107,114,128); tL(td("payment-ref") + ":", XM_L, ry);
fb(8.5); tc(17,24,39); tL(pRef, XM_L + LLBL, ry); ry += 5;
}
}
}
// Meta table (right-aligned) // Row 1 divider
const metaRows = [ const row1Y = Math.max(ly, ry) + 4;
iNo ? [td("invoice-no"), iNo] : null, dc(209,213,219); doc.setLineWidth(0.3);
iDate ? [td("invoice-date"), fmtDate(iDate)]: null, doc.line(ML, row1Y, XR, row1Y);
pCode ? [td("project-code"), pCode] : null,
iCur ? [td("invoice-currency"), iCur] : null,
].filter(Boolean);
metaRows.forEach(([lbl, val]) => { // ── Row 2 left: Charge-to ──
fn(8.5); tc(107,114,128); tR(lbl + ":", XR - 45, ry); let ly2 = row1Y + 5, ry2 = row1Y + 5;
fb(8.5); tc(17,24,39); tR(val, XR, ry); fb(7); tc(107,114,128); tL(td("charge-to").toUpperCase(), ML, ly2); ly2 += 5;
ry += 5;
});
y = Math.max(sy, ry) + 5;
// Navy rule
dc(30,45,69); doc.setLineWidth(0.6);
doc.line(ML, y, XR, y); y += 6;
// ── BILL-TO ──────────────────────────────────────────────────────────────
if (ctName) { if (ctName) {
const bLines = [...ctAddr, ctCntry].filter(Boolean); fb(10); tc(30,45,69); tL(ctName, ML, ly2); ly2 += 5.5;
fn(8.5); tc(17,24,39);
[...ctAddr, ctCntry].filter(Boolean).forEach(l => { tL(l, ML, ly2); ly2 += 4.5; });
const ctParts = []; const ctParts = [];
if (ctPh) ctParts.push(`${td("charge-to-phone")}: ${ctPh}`); if (ctPh) ctParts.push(`${td("charge-to-phone")}: ${ctPh}`);
if (ctEm) ctParts.push(`${td("charge-to-email")}: ${ctEm}`); if (ctEm) ctParts.push(`${td("charge-to-email")}: ${ctEm}`);
if (ctVat) ctParts.push(`${td("vat-id")}: ${ctVat}`); if (ctVat) ctParts.push(`${td("vat-id")}: ${ctVat}`);
if (ctReg) ctParts.push(`${td("registration-no")}: ${ctReg}`); if (ctReg) ctParts.push(`${td("registration-no")}: ${ctReg}`);
const ctContact = ctParts.join(" "); if (ctParts.length) { fn(8); tc(107,114,128); tL(ctParts.join(" "), ML, ly2); ly2 += 5; }
const boxH = 4.5 + 5 + 5.5 + bLines.length * 4.5 + (ctContact ? 4.5 : 0) + 4;
fc(248,249,250); dc(255,255,255); doc.setLineWidth(0);
doc.rect(ML, y, CW, boxH, "F");
fc(30,45,69);
doc.rect(ML, y, 1.5, boxH, "F");
let by = y + 4.5;
fb(7); tc(107,114,128); tL(td("charge-to").toUpperCase(), ML+4, by); by += 5;
fb(10.5); tc(30,45,69); tL(ctName, ML+4, by); by += 5.5;
fn(8.5); tc(17,24,39);
bLines.forEach(line => { tL(line, ML+4, by); by += 4.5; });
if (ctContact) { fn(8); tc(107,114,128); tL(ctContact, ML+4, by); }
y += boxH + 6;
} }
// ── Row 2 right: INVOICE + meta ──
fb(24); tc(30,45,69); tR(td("invoice"), XR, ry2); ry2 += 10;
const metaRows = [
iNo ? [td("invoice-no"), iNo] : null,
iDate ? [td("invoice-date"), fmtDate(iDate)] : null,
pCode ? [td("project-code"), pCode] : null,
iCur ? [td("invoice-currency"), iCur] : null,
].filter(Boolean);
metaRows.forEach(([lbl, val]) => {
fn(8.5); tc(107,114,128); tR(lbl + ":", XR - 42, ry2);
fb(8.5); tc(17,24,39); tR(val, XR, ry2);
ry2 += 5;
});
y = Math.max(ly2, ry2) + 5;
// Navy rule
dc(30,45,69); doc.setLineWidth(0.6);
doc.line(ML, y, XR, y); y += 6;
// ── LINE ITEMS TABLE ────────────────────────────────────────────────────── // ── LINE ITEMS TABLE ──────────────────────────────────────────────────────
// Column widths: QTY=18, UOM=18, DESC=flex, PRICE=28, TOTAL=34 // Column widths: QTY=18, UOM=18, DESC=flex, PRICE=28, TOTAL=34
const CQ=18, CU=18, CP=28, CT=34; const CQ=18, CU=18, CP=28, CT=34;
@ -1455,45 +1428,6 @@ function buildPDF() {
fb(11); tc(255,255,255); tR(fmt(toPay), XR-2, y+5.8); fb(11); tc(255,255,255); tR(fmt(toPay), XR-2, y+5.8);
y += 9; y += 9;
// ── PAYMENT DETAILS ───────────────────────────────────────────────────────
if (pTerm > 0 || hasBank) {
y += 8;
if (y + 8 > PH - 15) { doc.addPage(); y = MT; }
dc(209,213,219); doc.setLineWidth(0.4);
doc.line(ML, y, XR, y); y += 5;
fb(7); tc(107,114,128); tL(td("payment").toUpperCase(), ML, y); y += 5;
if (pTerm > 0) {
const termsStr = `${td("payment-terms")}: ${pTerm} ${td("payment-days")}${pPayBy ? ` ${td("pay-by")}: ${pPayBy}` : ""}`;
fn(8.5); tc(17,24,39); tL(termsStr, ML, y); y += 5;
}
if (hasBank) {
const LBL = 50;
const payRows = [
pAcct ? [td("account-holder"), pAcct] : null,
pIban ? [td("account-no"), pIban] : null,
pBic ? [td("bank-bic"), pBic] : null,
(pBadr1 || pBadr2) ? [td("bank-address"), [pBadr1,pBadr2].filter(Boolean).join(", ")] : null,
].filter(Boolean);
payRows.forEach(([lbl, val]) => {
if (y + 5 > PH - 10) { doc.addPage(); y = MT; }
fn(8); tc(107,114,128); tL(lbl + ":", ML, y);
fn(8.5); tc(17,24,39); tL(val, ML + LBL, y);
y += 4.5;
});
if (pRef) {
y += 1;
if (y + 6 > PH - 10) { doc.addPage(); y = MT; }
fn(8); tc(107,114,128); tL(td("payment-ref") + ":", ML, y);
fb(9); tc(17,24,39); tL(pRef, ML + LBL, y);
}
}
}
// ── Save ────────────────────────────────────────────────────────────────── // ── Save ──────────────────────────────────────────────────────────────────
doc.save(iNo ? `invoice-${iNo}.pdf` : "invoice.pdf"); doc.save(iNo ? `invoice-${iNo}.pdf` : "invoice.pdf");
} }