Add Payment panel to charge-to section

- New right-hand Payment column appears only when "Other" is selected for charge-to
- config.yml: hide-payment-info key (true/yes) suppresses the panel entirely
- Fields: payment terms (days), computed pay-by date, account holder, account no.
  (BBAN/IBAN), bank/BIC, bank address (2 lines), please use reference
- calcPayBy() derives the pay-by date from invoice date + terms days using fmtDate()
- relabel() keeps all payment labels in sync on language switch
- gatherData() captures payment fields only when panel is visible
- buildPreviewHTML() and buildPDF() render a payment section after totals when present

https://claude.ai/code/session_015iyCBgoTXNNqaErR287U1u
This commit is contained in:
Claude 2026-05-19 08:54:41 +00:00
parent bdb2cb28a3
commit 72c2c9e637
No known key found for this signature in database
2 changed files with 207 additions and 22 deletions

View file

@ -21,6 +21,11 @@ languages:
name: Norsk name: Norsk
direction: ltr direction: ltr
# ── Payment info visibility ───────────────────────────────────────────────────
# Set to true to hide the payment info panel entirely (useful if payment info
# should not appear on invoices, e.g. per company policy).
hide-payment-info: false
# ── Date and paper format ───────────────────────────────────────────────────── # ── Date and paper format ─────────────────────────────────────────────────────
# Date tokens: d=day, dd=day(0-padded), M=month number, MM=month(0-padded), # 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 # MMM=short month name, MMMM=full month name, YY=2-digit year, YYYY=4-digit year
@ -460,3 +465,48 @@ translations:
de: Rechnungsdetails de: Rechnungsdetails
fr: Détails de la facture fr: Détails de la facture
"no": Fakturaopplysninger "no": Fakturaopplysninger
payment:
en: Payment
de: Zahlung
fr: Paiement
"no": Betaling
payment-terms:
en: Payment terms
de: Zahlungsbedingungen
fr: Conditions de paiement
"no": Betalingsbetingelser
payment-days:
en: days
de: Tage
fr: jours
"no": dager
pay-by:
en: Pay by
de: Zahlbar bis
fr: "À payer avant le"
"no": Betal innen
account-holder:
en: Account holder
de: Kontoinhaber
fr: Titulaire du compte
"no": Kontohaver
account-no:
en: "Account no. (BBAN/IBAN)"
de: "Kontonummer (BBAN/IBAN)"
fr: "N° de compte (BBAN/IBAN)"
"no": "Kontonr. (BBAN/IBAN)"
bank-bic:
en: "Bank / BIC (Swift code)"
de: "Bank / BIC (Swift)"
fr: "Banque / BIC (code Swift)"
"no": "Bank / BIC (Swift-kode)"
bank-address:
en: Bank address
de: Bankadresse
fr: Adresse de la banque
"no": Bankadresse
payment-ref:
en: Please use reference
de: Bitte Referenz angeben
fr: "Merci d'indiquer la référence"
"no": Vennligst oppgi referanse

View file

@ -264,6 +264,15 @@
.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%; }
@ -273,6 +282,20 @@
.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 ──────────────────────────────── */
.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-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 input { width: 64px; }
.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 label { font-size: 12px; color: var(--text-muted); white-space: nowrap; }
#paybydisp { font-size: 13px; font-weight: 600; color: var(--text); }
/* ── Locked charge-to fields ────────────────────────────────────────────── */ /* ── Locked charge-to fields ────────────────────────────────────────────── */
#ct-fields.locked input, #ct-fields.locked input,
#ct-fields.locked select { #ct-fields.locked select {
@ -516,27 +539,52 @@ function buildForm() {
<option value="__other__">${t("other")}</option> <option value="__other__">${t("other")}</option>
</select> </select>
</div> </div>
<div id="ct-fields"> <div class="ct-two-col">
<div class="fg"><label id="lbl-ctn" for="ctn">${t("charge-to-name")}</label> <div id="ct-fields">
<input id="ctn" type="text"></div> <div class="fg"><label id="lbl-ctn" for="ctn">${t("charge-to-name")}</label>
<div class="fg"><label id="lbl-ca1" for="ca1">${t("charge-to-address1")}</label> <input id="ctn" type="text"></div>
<input id="ca1" type="text"></div> <div class="fg"><label id="lbl-ca1" for="ca1">${t("charge-to-address1")}</label>
<div class="fg"><label id="lbl-ca2" for="ca2">${t("charge-to-address2")}</label> <input id="ca1" type="text"></div>
<input id="ca2" type="text"></div> <div class="fg"><label id="lbl-ca2" for="ca2">${t("charge-to-address2")}</label>
<div class="fg"><label id="lbl-ca3" for="ca3">${t("charge-to-address3")}</label> <input id="ca2" type="text"></div>
<input id="ca3" type="text"></div> <div class="fg"><label id="lbl-ca3" for="ca3">${t("charge-to-address3")}</label>
<div class="fg"><label id="lbl-ca4" for="ca4">${t("charge-to-address4")}</label> <input id="ca3" type="text"></div>
<input id="ca4" type="text"></div> <div class="fg"><label id="lbl-ca4" for="ca4">${t("charge-to-address4")}</label>
<div class="fg"><label id="lbl-cc" for="cc">${t("charge-to-country")}</label> <input id="ca4" type="text"></div>
<select id="cc">${countryOpts("")}</select></div> <div class="fg"><label id="lbl-cc" for="cc">${t("charge-to-country")}</label>
<div class="fi"><label id="lbl-cph">${t("charge-to-phone")}:</label> <select id="cc">${countryOpts("")}</select></div>
<input id="cph" type="tel"></div> <div class="fi"><label id="lbl-cph">${t("charge-to-phone")}:</label>
<div class="fi"><label id="lbl-cem">${t("charge-to-email")}:</label> <input id="cph" type="tel"></div>
<input id="cem" type="email"></div> <div class="fi"><label id="lbl-cem">${t("charge-to-email")}:</label>
<div class="fi"><label id="lbl-cvat">${t("vat-id")}:</label> <input id="cem" type="email"></div>
<input id="cvat" type="text"></div> <div class="fi"><label id="lbl-cvat">${t("vat-id")}:</label>
<div class="fi"><label id="lbl-creg">${t("registration-no")}:</label> <input id="cvat" type="text"></div>
<input id="creg" type="text"></div> <div class="fi"><label id="lbl-creg">${t("registration-no")}:</label>
<input id="creg" type="text"></div>
</div>
<div id="payment-panel" style="display:none">
<div class="pay-section-title" id="lbl-pay-sec">${t("payment")}</div>
<div class="pay-terms-row">
<label id="lbl-pterm">${t("payment-terms")}:</label>
<input id="pterm" type="number" min="0" oninput="calcPayBy()">
<span id="lbl-days">${t("payment-days")}</span>
</div>
<div class="pay-by-row">
<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"></div>
<div class="fg"><label id="lbl-piban">${t("account-no")}</label>
<input id="piban" type="text"></div>
<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> </div>
@ -591,6 +639,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("idate").addEventListener("change", calcPayBy);
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));
@ -601,6 +650,10 @@ 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 ppanel = document.getElementById("payment-panel");
if (ppanel) ppanel.style.display = (!hidePayment && v === "__other__") ? "" : "none";
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");
@ -810,6 +863,21 @@ function calcLine(i) {
calcTotals(); calcTotals();
} }
function calcPayBy() {
const idate = document.getElementById("idate")?.value;
const terms = parseInt(document.getElementById("pterm")?.value) || 0;
const el = document.getElementById("paybydisp");
if (!el) return;
if (idate && terms > 0) {
const d = new Date(idate + "T00:00:00");
d.setDate(d.getDate() + terms);
const iso = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
el.textContent = fmtDate(iso);
} else {
el.textContent = "";
}
}
function calcTotals() { function calcTotals() {
let sub = 0; let sub = 0;
Object.keys(lines).forEach(i => { Object.keys(lines).forEach(i => {
@ -885,6 +953,8 @@ function relabel() {
"lbl-ctn":"charge-to-name","lbl-ca1":"charge-to-address1","lbl-ca2":"charge-to-address2", "lbl-ctn":"charge-to-name","lbl-ca1":"charge-to-address1","lbl-ca2":"charge-to-address2",
"lbl-ca3":"charge-to-address3","lbl-ca4":"charge-to-address4","lbl-cc":"charge-to-country", "lbl-ca3":"charge-to-address3","lbl-ca4":"charge-to-address4","lbl-cc":"charge-to-country",
"lbl-cph":"charge-to-phone","lbl-cem":"charge-to-email","lbl-cvat":"vat-id","lbl-creg":"registration-no", "lbl-cph":"charge-to-phone","lbl-cem":"charge-to-email","lbl-cvat":"vat-id","lbl-creg":"registration-no",
"lbl-pay-sec":"payment","lbl-pterm":"payment-terms","lbl-days":"payment-days","lbl-paybyl":"pay-by",
"lbl-pacct":"account-holder","lbl-piban":"account-no","lbl-pbic":"bank-bic","lbl-pbadr":"bank-address","lbl-pref":"payment-ref",
"th-qty":"qty","th-uom":"uom","th-desc":"description","th-price":"price","th-tot":"line-total", "th-qty":"qty","th-uom":"uom","th-desc":"description","th-price":"price","th-tot":"line-total",
"lbl-sub":"subtotal","lbl-paid":"paid","lbl-topay":"to-pay", "lbl-sub":"subtotal","lbl-paid":"paid","lbl-topay":"to-pay",
}; };
@ -980,6 +1050,18 @@ function gatherData(renderLang) {
const ctCntry= COUNTRY_MAP[g("cc")] || ""; const ctCntry= COUNTRY_MAP[g("cc")] || "";
const ctPh = g("cph"), ctEm = g("cem"), ctVat = g("cvat"), ctReg = g("creg"); const ctPh = g("cph"), ctEm = g("cem"), ctVat = g("cvat"), ctReg = g("creg");
const ppanel = document.getElementById("payment-panel");
const payVisible = ppanel && ppanel.style.display !== "none";
const pTerm = payVisible ? (parseInt(document.getElementById("pterm")?.value) || 0) : 0;
const pPayBy = payVisible ? (document.getElementById("paybydisp")?.textContent || "") : "";
const pAcct = payVisible ? g("pacct") : "";
const pIban = payVisible ? g("piban") : "";
const pBic = payVisible ? g("pbic") : "";
const pBadr1 = payVisible ? g("pbadr1"): "";
const pBadr2 = payVisible ? g("pbadr2"): "";
const pRef = payVisible ? g("pref") : "";
const hasPayment = payVisible && !!(pAcct || pIban || pBic || pBadr1 || pRef || pTerm > 0);
let sub = 0; let sub = 0;
const rows = []; const rows = [];
Object.keys(lines).sort((a,b)=>+a-+b).forEach(i => { Object.keys(lines).sort((a,b)=>+a-+b).forEach(i => {
@ -1033,6 +1115,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, hasPayment,
rows, sub, taxes, totalTax, paid, toPay }; rows, sub, taxes, totalTax, paid, toPay };
} }
@ -1040,6 +1123,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,
rows, sub, taxes, paid, toPay } = gatherData(); rows, sub, taxes, paid, toPay } = gatherData();
const linesHTML = rows.map(row => { const linesHTML = rows.map(row => {
@ -1113,7 +1197,19 @@ function buildPreviewHTML() {
<td class="tl">${h(td("to-pay"))}</td> <td class="tl">${h(td("to-pay"))}</td>
<td class="tv">${fmt(toPay)}</td> <td class="tv">${fmt(toPay)}</td>
</tr> </tr>
</table>`; </table>
${hasPayment ? `
<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>` : ""}
<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 ─────────────────────────────────────────
@ -1143,6 +1239,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, ctReg, ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat, ctReg,
pTerm, pPayBy, pAcct, pIban, pBic, pBadr1, pBadr2, pRef, hasPayment,
rows, sub, taxes, paid, toPay } = gatherData(); rows, sub, taxes, paid, toPay } = gatherData();
// ── HEADER ──────────────────────────────────────────────────────────────── // ── HEADER ────────────────────────────────────────────────────────────────
@ -1321,6 +1418,44 @@ function buildPDF() {
doc.rect(TX, y, TW, 9, "F"); doc.rect(TX, y, TW, 9, "F");
fn(9); tc(180,195,215); tR(td("to-pay"), TLBX, y+5.8); fn(9); tc(180,195,215); tR(td("to-pay"), TLBX, y+5.8);
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;
// ── PAYMENT DETAILS ───────────────────────────────────────────────────────
if (hasPayment) {
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;
}
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");