mirror of
https://github.com/kbenestad/invoice.git
synced 2026-06-18 08:04:32 +00:00
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:
parent
bdb2cb28a3
commit
72c2c9e637
2 changed files with 207 additions and 22 deletions
|
|
@ -21,6 +21,11 @@ languages:
|
|||
name: Norsk
|
||||
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 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
|
||||
|
|
@ -460,3 +465,48 @@ translations:
|
|||
de: Rechnungsdetails
|
||||
fr: Détails de la facture
|
||||
"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
|
||||
|
|
|
|||
179
app/index.html
179
app/index.html
|
|
@ -264,6 +264,15 @@
|
|||
.d-lines tbody td.b { font-weight: 600; }
|
||||
.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 td { padding: 5px 10px; font-size: 11.5px; }
|
||||
.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 .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 ────────────────────────────────────────────── */
|
||||
#ct-fields.locked input,
|
||||
#ct-fields.locked select {
|
||||
|
|
@ -516,27 +539,52 @@ function buildForm() {
|
|||
<option value="__other__">${t("other")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="ct-fields">
|
||||
<div class="fg"><label id="lbl-ctn" for="ctn">${t("charge-to-name")}</label>
|
||||
<input id="ctn" type="text"></div>
|
||||
<div class="fg"><label id="lbl-ca1" for="ca1">${t("charge-to-address1")}</label>
|
||||
<input id="ca1" type="text"></div>
|
||||
<div class="fg"><label id="lbl-ca2" for="ca2">${t("charge-to-address2")}</label>
|
||||
<input id="ca2" type="text"></div>
|
||||
<div class="fg"><label id="lbl-ca3" for="ca3">${t("charge-to-address3")}</label>
|
||||
<input id="ca3" type="text"></div>
|
||||
<div class="fg"><label id="lbl-ca4" for="ca4">${t("charge-to-address4")}</label>
|
||||
<input id="ca4" type="text"></div>
|
||||
<div class="fg"><label id="lbl-cc" for="cc">${t("charge-to-country")}</label>
|
||||
<select id="cc">${countryOpts("")}</select></div>
|
||||
<div class="fi"><label id="lbl-cph">${t("charge-to-phone")}:</label>
|
||||
<input id="cph" type="tel"></div>
|
||||
<div class="fi"><label id="lbl-cem">${t("charge-to-email")}:</label>
|
||||
<input id="cem" type="email"></div>
|
||||
<div class="fi"><label id="lbl-cvat">${t("vat-id")}:</label>
|
||||
<input id="cvat" type="text"></div>
|
||||
<div class="fi"><label id="lbl-creg">${t("registration-no")}:</label>
|
||||
<input id="creg" type="text"></div>
|
||||
<div class="ct-two-col">
|
||||
<div id="ct-fields">
|
||||
<div class="fg"><label id="lbl-ctn" for="ctn">${t("charge-to-name")}</label>
|
||||
<input id="ctn" type="text"></div>
|
||||
<div class="fg"><label id="lbl-ca1" for="ca1">${t("charge-to-address1")}</label>
|
||||
<input id="ca1" type="text"></div>
|
||||
<div class="fg"><label id="lbl-ca2" for="ca2">${t("charge-to-address2")}</label>
|
||||
<input id="ca2" type="text"></div>
|
||||
<div class="fg"><label id="lbl-ca3" for="ca3">${t("charge-to-address3")}</label>
|
||||
<input id="ca3" type="text"></div>
|
||||
<div class="fg"><label id="lbl-ca4" for="ca4">${t("charge-to-address4")}</label>
|
||||
<input id="ca4" type="text"></div>
|
||||
<div class="fg"><label id="lbl-cc" for="cc">${t("charge-to-country")}</label>
|
||||
<select id="cc">${countryOpts("")}</select></div>
|
||||
<div class="fi"><label id="lbl-cph">${t("charge-to-phone")}:</label>
|
||||
<input id="cph" type="tel"></div>
|
||||
<div class="fi"><label id="lbl-cem">${t("charge-to-email")}:</label>
|
||||
<input id="cem" type="email"></div>
|
||||
<div class="fi"><label id="lbl-cvat">${t("vat-id")}:</label>
|
||||
<input id="cvat" 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>
|
||||
|
||||
|
|
@ -591,6 +639,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("idate").addEventListener("change", calcPayBy);
|
||||
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));
|
||||
|
|
@ -601,6 +650,10 @@ function fillChargeTo(v) {
|
|||
const f = (id, val) => { const el = document.getElementById(id); if (el) el.value = val ?? ""; };
|
||||
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 === "") ["ctn","ca1","ca2","ca3","ca4","cc","cph","cem","cvat","creg"].forEach(id => f(id, ""));
|
||||
fields?.classList.remove("locked");
|
||||
|
|
@ -810,6 +863,21 @@ function calcLine(i) {
|
|||
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() {
|
||||
let sub = 0;
|
||||
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-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-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",
|
||||
"lbl-sub":"subtotal","lbl-paid":"paid","lbl-topay":"to-pay",
|
||||
};
|
||||
|
|
@ -980,6 +1050,18 @@ function gatherData(renderLang) {
|
|||
const ctCntry= COUNTRY_MAP[g("cc")] || "";
|
||||
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;
|
||||
const rows = [];
|
||||
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,
|
||||
ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat, ctReg,
|
||||
pTerm, pPayBy, pAcct, pIban, pBic, pBadr1, pBadr2, pRef, hasPayment,
|
||||
rows, sub, taxes, totalTax, paid, toPay };
|
||||
}
|
||||
|
||||
|
|
@ -1040,6 +1123,7 @@ function gatherData(renderLang) {
|
|||
function buildPreviewHTML() {
|
||||
const { td, sName, sAddr, sCntry, sPh, sEm, iDate, pCode, iNo, iCur,
|
||||
ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat, ctReg,
|
||||
pTerm, pPayBy, pAcct, pIban, pBic, pBadr1, pBadr2, pRef, hasPayment,
|
||||
rows, sub, taxes, paid, toPay } = gatherData();
|
||||
|
||||
const linesHTML = rows.map(row => {
|
||||
|
|
@ -1113,7 +1197,19 @@ function buildPreviewHTML() {
|
|||
<td class="tl">${h(td("to-pay"))}</td>
|
||||
<td class="tv">${fmt(toPay)}</td>
|
||||
</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 ? ` — ${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 ─────────────────────────────────────────
|
||||
|
|
@ -1143,6 +1239,7 @@ function buildPDF() {
|
|||
|
||||
const { dl, td, sName, sAddr, sCntry, sPh, sEm, iDate, pCode, iNo, iCur,
|
||||
ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat, ctReg,
|
||||
pTerm, pPayBy, pAcct, pIban, pBic, pBadr1, pBadr2, pRef, hasPayment,
|
||||
rows, sub, taxes, paid, toPay } = gatherData();
|
||||
|
||||
// ── HEADER ────────────────────────────────────────────────────────────────
|
||||
|
|
@ -1321,6 +1418,44 @@ function buildPDF() {
|
|||
doc.rect(TX, y, TW, 9, "F");
|
||||
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);
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
doc.save(iNo ? `invoice-${iNo}.pdf` : "invoice.pdf");
|
||||
|
|
|
|||
Loading…
Reference in a new issue