invoice/app/index.html
Claude f39eed979a
Replace browser print with jsPDF Helvetica PDF generation
The browser print-to-PDF approach requires the user to manually toggle
"Background graphics" on and "Headers and footers" off — impossible to
control programmatically. Replace with direct jsPDF generation:

- Loads jsPDF 2.5.1 UMD from CDN
- buildPDF() draws the invoice programmatically using Helvetica (PDF
  built-in font, no embedding needed)
- Layout: sender/title header, navy rule, charge-to block with navy
  accent bar, line-items table (navy header, alternating rows, multi-
  line description wrapping, foreign-currency sub-row), totals with
  navy To-Pay bar — all in mm on A4
- PDF downloads directly as invoice-{no}.pdf with no dialog
- HTML preview overlay retained for review before download
- Replace "print-invoice" config key with "download-pdf"

https://claude.ai/code/session_015iyCBgoTXNNqaErR287U1u
2026-05-19 07:52:40 +00:00

1200 lines
53 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Invoice</title>
<style>
/* ── Variables ──────────────────────────────────────────────────────────── */
:root {
--navy: #1e2d45;
--slate: #64748b;
--border: #d1d5db;
--border-light:#e5e7eb;
--bg: #f3f4f6;
--white: #ffffff;
--accent: #1d4ed8;
--accent-hover:#1e40af;
--text: #111827;
--text-muted: #6b7280;
--danger: #dc2626;
--success: #15803d;
--radius: 4px;
}
/* ── Reset / Base ───────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
font-size: 14px;
color: var(--text);
background: var(--bg);
line-height: 1.5;
}
input, select, textarea, button { font-family: inherit; }
input, select {
font-size: 13px;
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 5px 8px;
background: var(--white);
width: 100%;
outline: none;
transition: border-color .15s;
}
input:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 2px #dbeafe; }
input[type="number"] { text-align: right; }
button { cursor: pointer; border: none; border-radius: var(--radius); font-size: 13px; }
/* ── Layout ─────────────────────────────────────────────────────────────── */
.wrap { max-width: 920px; margin: 0 auto; padding: 20px 16px 48px; }
/* ── Language bar ───────────────────────────────────────────────────────── */
#lang-bar {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding: 7px 12px;
background: var(--white);
border: 1px solid var(--border);
border-radius: var(--radius);
}
#lang-bar label { font-size: 12px; color: var(--text-muted); white-space: nowrap; }
#lang-bar select { width: auto; }
/* ── Invoice banner ─────────────────────────────────────────────────────── */
#inv-banner {
background: var(--navy);
color: var(--white);
padding: 18px 24px;
border-radius: var(--radius);
margin-bottom: 14px;
}
#inv-banner h1 { font-size: 26px; font-weight: 800; letter-spacing: 5px; line-height: 1; }
/* ── Card ───────────────────────────────────────────────────────────────── */
.card {
background: var(--white);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 20px;
margin-bottom: 12px;
}
.card-title {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1.2px;
color: var(--slate);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-light);
}
/* ── Two-column grid ────────────────────────────────────────────────────── */
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
@media (max-width: 640px) { .two-col { grid-template-columns: 1fr; } }
/* ── Form groups ────────────────────────────────────────────────────────── */
.fg { margin-bottom: 8px; }
.fg label { display: block; font-size: 11px; color: var(--text-muted); font-weight: 500; margin-bottom: 3px; }
.fi { display: grid; grid-template-columns: 80px 1fr; gap: 8px; align-items: center; margin-bottom: 8px; }
.fi label { font-size: 12px; color: var(--text-muted); white-space: nowrap; }
/* ── Line items table ───────────────────────────────────────────────────── */
#lines-card {
background: var(--white);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 12px;
overflow: hidden;
}
#lines-card .card-title { padding: 11px 16px; margin: 0; border-bottom: 1px solid var(--border); }
.line-tbl { width: 100%; border-collapse: collapse; }
.line-tbl thead th {
background: #f8f9fb;
padding: 7px 10px;
font-size: 10px; font-weight: 700;
text-transform: uppercase; letter-spacing: .5px;
color: var(--slate);
border-bottom: 1px solid var(--border);
text-align: left;
}
.line-tbl thead th.r { text-align: right; }
.line-tbl .lr td { padding: 8px 10px; border-bottom: 1px solid var(--border-light); vertical-align: top; }
.line-tbl .lr.open td { border-bottom: none; }
.line-tbl .fx td { padding: 4px 10px 10px; border-bottom: 1px solid var(--border-light); background: #f9fafb; }
.line-tbl .al td { padding: 10px; }
.col-qty { width: 72px; } .col-uom { width: 100px; }
.col-price { width: 110px; } .col-tot { width: 110px; } .col-act { width: 36px; }
.line-total-val { font-size: 13px; font-weight: 600; text-align: right; padding-top: 6px; }
.fx-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-bottom: 6px; }
.fx-label { font-size: 11px; color: var(--text-muted); margin-bottom: 3px; }
.fx-note { font-size: 11px; color: var(--text-muted); }
.fx-note strong { color: var(--text); }
.btn-remove { background: none; color: var(--danger); font-size: 18px; line-height: 1; padding: 2px 5px; border-radius: 3px; }
.btn-remove:hover { background: #fef2f2; }
.btn-add-line { background: none; color: var(--accent); font-size: 13px; font-weight: 500; padding: 5px 10px; border: 1px dashed var(--accent); border-radius: var(--radius); }
.btn-add-line:hover { background: #eff6ff; }
/* ── Totals ─────────────────────────────────────────────────────────────── */
#totals-card { background: var(--white); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 14px; }
.tot-tbl { width: 100%; border-collapse: collapse; }
.tot-tbl td { padding: 8px 16px; }
.tot-tbl tr:not(:last-child) td { border-bottom: 1px solid var(--border-light); }
.tot-tbl .lbl { text-align: right; color: var(--text-muted); font-size: 13px; }
.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; }
/* ── Generate button ────────────────────────────────────────────────────── */
#btn-generate {
display: block; width: 100%;
padding: 14px;
background: var(--accent); color: var(--white);
font-size: 15px; font-weight: 700; letter-spacing: 1px;
border-radius: var(--radius);
margin-bottom: 20px;
transition: background .15s;
}
#btn-generate:hover { background: var(--accent-hover); }
/* ── Invoice overlay ────────────────────────────────────────────────────── */
#overlay {
display: none;
position: fixed; inset: 0;
background: rgba(15,23,42,.65);
z-index: 900;
overflow-y: auto;
padding: 32px 16px 48px;
align-items: flex-start;
justify-content: center;
flex-direction: column;
}
#overlay.on { display: flex; }
#overlay-actions {
display: flex; gap: 10px;
margin: 0 auto 16px;
width: 794px; max-width: 100%;
}
.btn-dl {
background: var(--success); color: var(--white);
padding: 10px 20px; font-size: 14px; font-weight: 600;
border-radius: var(--radius);
}
.btn-dl:hover { background: #166534; }
.btn-close {
background: var(--white); color: var(--text);
padding: 10px 20px; font-size: 14px;
border: 1px solid var(--border); border-radius: var(--radius);
}
.btn-close:hover { background: var(--bg); }
/* ── Invoice preview (screen only) ──────────────────────────────────────── */
#inv-doc {
background: var(--white);
width: 794px; max-width: 100%; min-height: 1100px;
padding: 52px 60px 60px;
box-shadow: 0 4px 24px rgba(0,0,0,.25);
margin: 0 auto;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
font-size: 11.5px; color: #111827;
}
.d-hdr {
display: flex; justify-content: space-between; align-items: flex-start;
padding-bottom: 20px; margin-bottom: 24px;
border-bottom: 3px solid var(--navy);
}
.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-title { text-align: right; }
.d-title h1 { font-size: 30px; font-weight: 800; letter-spacing: 5px; color: var(--navy); margin-bottom: 14px; }
.d-meta { border-collapse: collapse; margin-left: auto; }
.d-meta td { font-size: 10.5px; padding: 2px 0 2px 12px; }
.d-meta .ml { color: #6b7280; 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 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.r { text-align: right; }
.d-lines tbody tr { border-bottom: 1px solid #e5e7eb; }
.d-lines tbody tr:nth-child(even) { background: #f9fafb; }
.d-lines tbody td { padding: 7px 10px; font-size: 10.5px; vertical-align: top; }
.d-lines tbody td.r { text-align: right; }
.d-lines tbody td.b { font-weight: 600; }
.d-fx-note { font-size: 9.5px; color: #6b7280; margin-top: 3px; }
.d-tots { width: 100%; border-collapse: collapse; }
.d-tots td { padding: 5px 10px; font-size: 11.5px; }
.d-tots .sp { width: 55%; }
.d-tots .tl { text-align: right; color: #6b7280; padding-right: 20px; }
.d-tots .tv { text-align: right; width: 130px; font-weight: 600; }
.d-tots .sub td { border-top: 1px solid #e5e7eb; }
.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); }
/* ── Error / loading ────────────────────────────────────────────────────── */
#loading { padding: 48px; text-align: center; color: var(--text-muted); font-size: 14px; }
.error-box { background: #fef2f2; border: 1px solid #fca5a5; color: #991b1b; padding: 16px 20px; border-radius: var(--radius); margin: 20px 0; font-size: 13px; }
</style>
</head>
<body>
<div class="wrap">
<!-- Language bar -->
<div id="lang-bar" style="display:none">
<span>&#127760;</span>
<label id="lbl-language" for="lang-sel">Language</label>
<select id="lang-sel"></select>
</div>
<!-- Banner -->
<div id="inv-banner"><h1 id="inv-title">INVOICE</h1></div>
<!-- Loading / error placeholder -->
<div id="loading">Loading configuration&#8230;</div>
<!-- Main form (injected by JS) -->
<div id="form-root"></div>
</div><!-- /.wrap -->
<!-- ── Invoice preview overlay ───────────────────────────────────────────────── -->
<div id="overlay">
<div id="overlay-actions">
<button class="btn-dl" id="btn-dl" onclick="buildPDF()">&#11015; Download PDF</button>
<button class="btn-close" id="btn-close" onclick="closeOverlay()">&#x2715; Close</button>
</div>
<div id="inv-doc"></div>
</div>
<!-- js-yaml -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js"
crossorigin="anonymous"></script>
<!-- jsPDF -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"
crossorigin="anonymous"></script>
<script>
"use strict";
// ── State ─────────────────────────────────────────────────────────────────────
let cfg = null;
let lang = "en";
let lid = 0;
const lines = {};
// ── i18n ──────────────────────────────────────────────────────────────────────
function t(key) {
const e = cfg?.translations?.[key];
if (!e) return 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";
return (+n).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function pn(s) { return parseFloat(String(s ?? 0).replace(/,/g, "")) || 0; }
// ── Date ─────────────────────────────────────────────────────────────────────
function fmtMonth(v) {
if (!v) return "";
const [y, m] = v.split("-");
return new Date(+y, +m - 1, 1).toLocaleDateString("en-US", { year: "numeric", month: "long" });
}
// ── HTML escape ───────────────────────────────────────────────────────────────
function h(s) {
if (s == null) return "";
return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
// ── Countries ─────────────────────────────────────────────────────────────────
const COUNTRIES = [
["","— Select country —"],
["AF","Afghanistan"],["AL","Albania"],["DZ","Algeria"],["AR","Argentina"],
["AU","Australia"],["AT","Austria"],["BE","Belgium"],["BR","Brazil"],
["CA","Canada"],["CL","Chile"],["CN","China"],["CO","Colombia"],
["HR","Croatia"],["CZ","Czech Republic"],["DK","Denmark"],["EG","Egypt"],
["FI","Finland"],["FR","France"],["DE","Germany"],["GH","Ghana"],
["GR","Greece"],["HK","Hong Kong"],["HU","Hungary"],["IN","India"],
["ID","Indonesia"],["IE","Ireland"],["IL","Israel"],["IT","Italy"],
["JP","Japan"],["KE","Kenya"],["KR","South Korea"],["MY","Malaysia"],
["MX","Mexico"],["NL","Netherlands"],["NZ","New Zealand"],["NG","Nigeria"],
["NO","Norway"],["PK","Pakistan"],["PE","Peru"],["PH","Philippines"],
["PL","Poland"],["PT","Portugal"],["RO","Romania"],["RU","Russia"],
["SA","Saudi Arabia"],["ZA","South Africa"],["ES","Spain"],["SE","Sweden"],
["CH","Switzerland"],["TW","Taiwan"],["TH","Thailand"],["TR","Turkey"],
["UA","Ukraine"],["AE","United Arab Emirates"],["GB","United Kingdom"],
["US","United States"],["VN","Vietnam"]
];
const COUNTRY_MAP = Object.fromEntries(COUNTRIES.slice(1));
function countryOpts(sel) {
return COUNTRIES.map(([c, n]) =>
`<option value="${h(c)}" ${c === sel ? "selected" : ""}>${h(n)}</option>`
).join("");
}
// ── Load config ───────────────────────────────────────────────────────────────
async function loadCfg() {
try {
const r = await fetch("config.yml");
if (!r.ok) throw new Error(`HTTP ${r.status}`);
cfg = jsyaml.load(await r.text());
lang = cfg["default-code"] || "en";
boot();
} catch (err) {
document.getElementById("loading").innerHTML =
`<div class="error-box"><strong>Could not load config.yml:</strong> ${h(err.message)}<br>
Serve the <code>app/</code> folder from a web server (e.g. <code>npx serve .</code>).</div>`;
}
}
// ── Boot ──────────────────────────────────────────────────────────────────────
function boot() {
buildLangBar();
buildForm();
restoreStorage();
addLine();
document.getElementById("loading").style.display = "none";
}
// ── Language bar ──────────────────────────────────────────────────────────────
function buildLangBar() {
const langs = cfg.languages || [{ code: cfg["default-code"], name: cfg["default-name"] }];
if (langs.length < 2) return;
const bar = document.getElementById("lang-bar");
bar.style.display = "flex";
document.getElementById("lbl-language").textContent = t("language");
const sel = document.getElementById("lang-sel");
sel.innerHTML = langs.map(l =>
`<option value="${h(l.code)}" ${l.code === lang ? "selected" : ""}>${h(l.name)}</option>`
).join("");
sel.addEventListener("change", e => {
lang = e.target.value;
const ld = langs.find(l => l.code === lang);
document.documentElement.lang = lang;
document.documentElement.dir = ld?.direction || "ltr";
relabel();
});
}
// ── Build form ────────────────────────────────────────────────────────────────
function buildForm() {
document.getElementById("inv-title").textContent = t("invoice");
const today = new Date();
const monthDef = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}`;
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 taxOpts = (cfg["tax-rates"] || []).map((tr, i) =>
`<option value="${tr.rate}" ${i === 0 ? "selected" : ""}>${h(tTax(tr))}</option>`).join("");
document.getElementById("form-root").innerHTML = `
<form id="the-form" novalidate>
<div class="two-col">
<div class="card">
<div class="card-title" id="sec-sender">${t("sender-section")}</div>
<div class="fg"><label id="lbl-sn" for="sn">${t("sender-name")}</label>
<input id="sn" type="text" data-ls="sn" autocomplete="name"></div>
<div class="fg"><label id="lbl-sa1" for="sa1">${t("sender-address1")}</label>
<input id="sa1" type="text" data-ls="sa1" autocomplete="address-line1"></div>
<div class="fg"><label id="lbl-sa2" for="sa2">${t("sender-address2")}</label>
<input id="sa2" type="text" data-ls="sa2" autocomplete="address-line2"></div>
<div class="fg"><label id="lbl-sa3" for="sa3">${t("sender-address3")}</label>
<input id="sa3" type="text" data-ls="sa3"></div>
<div class="fg"><label id="lbl-sa4" for="sa4">${t("sender-address4")}</label>
<input id="sa4" type="text" data-ls="sa4"></div>
<div class="fg"><label id="lbl-sc" for="sc">${t("sender-country")}</label>
<select id="sc" data-ls="sc">${countryOpts("")}</select></div>
<div class="fi"><label id="lbl-sp">${t("sender-phone")}:</label>
<input id="sp" type="tel" data-ls="sp" autocomplete="tel"></div>
<div class="fi"><label id="lbl-se">${t("sender-email")}:</label>
<input id="se" type="email" data-ls="se" autocomplete="email"></div>
</div>
<div class="card">
<div class="card-title" id="sec-invdet">${t("invoice-details-section")}</div>
<div class="fg"><label id="lbl-idate" for="idate">${t("invoice-date")}</label>
<input id="idate" type="month" value="${monthDef}"></div>
<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 class="fg"><label id="lbl-ino" for="ino">${t("invoice-no")}</label>
<input id="ino" type="text" data-ls="ino"></div>
</div>
</div>
<div class="card">
<div class="card-title" style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span id="sec-ct">${t("charge-to")}:</span>
<select id="ct-pick" style="width:auto;font-size:12px">
<option value="">${t("select")}</option>
${ctOpts}
<option value="__other__">${t("other")}</option>
</select>
</div>
<div class="two-col">
<div>
<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>
<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>
</div>
</div>
<div id="lines-card">
<div class="card-title" id="sec-lines">${t("invoice-lines")}</div>
<table class="line-tbl">
<thead>
<tr>
<th class="col-qty" id="th-qty">${t("qty")}</th>
<th class="col-uom" id="th-uom">${t("uom")}</th>
<th class="col-desc" id="th-desc">${t("description")}</th>
<th class="col-price r" id="th-price">${t("price")}</th>
<th class="col-tot r" id="th-tot">${t("line-total")}</th>
<th class="col-act"></th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
<div id="totals-card">
<table class="tot-tbl">
<tr>
<td class="lbl" id="lbl-sub">${t("subtotal")}</td>
<td class="val" id="v-sub">0.00</td>
</tr>
<tr>
<td class="lbl"><select id="tax-sel" class="tax-sel">${taxOpts}</select></td>
<td class="val" id="v-tax">0.00</td>
</tr>
<tr>
<td class="lbl" id="lbl-paid">${t("paid")}</td>
<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>
</table>
</div>
<button type="submit" id="btn-generate">${t("generate-invoice")}</button>
</form>`;
document.getElementById("pcode").addEventListener("change", function () {
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("the-form").addEventListener("submit", e => { e.preventDefault(); generateInvoice(); });
document.querySelectorAll("[data-ls]").forEach(el => el.addEventListener("change", saveStorage));
}
// ── Fill charge-to ────────────────────────────────────────────────────────────
function fillChargeTo(v) {
const f = (id, val) => { const el = document.getElementById(id); if (el) el.value = val ?? ""; };
if (v === "" || v === "__other__") {
["ctn","ca1","ca2","ca3","ca4","cc","cph","cem","cvat"].forEach(id => f(id, ""));
return;
}
const ct = (cfg["charge-to"] || [])[+v];
if (!ct) return;
f("ctn", ct.name); f("ca1", ct.address1);
f("ca2", ct.address2); f("ca3", ct.address3);
f("ca4", ct.address4); f("cc", ct.country);
f("cph", ct.phone); f("cem", ct.email);
f("cvat", ct["vat-id"]);
}
// ── Select option helpers ─────────────────────────────────────────────────────
function uomOpts(sel) {
let o = `<option value="">${t("select")}</option>`;
(cfg.uom || []).forEach(u => {
const lbl = u.labels?.[lang] ?? u.labels?.[cfg["default-code"]] ?? u.code;
o += `<option value="${h(u.code)}" ${u.code === sel ? "selected" : ""}>${h(lbl)} (${h(u.code)})</option>`;
});
return o;
}
function prodOpts(sel) {
let o = `<option value="">${t("select")}</option>`;
(cfg.products || []).forEach((p, i) => {
const desc = p.description?.[lang] ?? p.description?.[cfg["default-code"]] ?? p.code;
o += `<option value="${i}" ${String(i) === String(sel) ? "selected" : ""}>${h(desc)}</option>`;
});
o += `<option value="__other__" ${"__other__" === sel ? "selected" : ""}>${t("other")}</option>`;
return o;
}
function currOpts(sel) {
return (cfg.currencies || ["USD","EUR","GBP"]).map(c =>
`<option value="${c}" ${c === sel ? "selected" : ""}>${c}</option>`
).join("");
}
// ── Add / remove line ─────────────────────────────────────────────────────────
function addLine() {
const i = lid++;
lines[i] = {};
const tbody = document.getElementById("tbody");
const alRow = document.getElementById("al-row");
if (alRow) alRow.remove();
const tr = document.createElement("tr");
tr.className = "lr"; tr.id = `lr-${i}`;
tr.innerHTML = `
<td class="col-qty"><input type="number" id="qty-${i}" value="1" min="0" step="any"
oninput="calcLine(${i})"></td>
<td class="col-uom"><select id="uom-${i}" onchange="calcLine(${i})">${uomOpts("")}</select></td>
<td class="col-desc">
<select id="dsel-${i}" onchange="pickProduct(${i})">${prodOpts("")}</select>
<input type="text" id="dtxt-${i}" placeholder="${h(t("description"))}"
style="margin-top:4px;display:none" oninput="calcLine(${i})">
<div style="display:flex;align-items:center;gap:6px;margin-top:5px">
<label style="font-size:11px;color:var(--text-muted);white-space:nowrap"
id="lbl-fx-${i}">${t("foreign-currency")}:</label>
<select id="fx-${i}" style="width:auto" onchange="toggleFx(${i})">
<option value="no">${t("no-option")}</option>
<option value="yes">${t("yes")}</option>
</select>
</div>
</td>
<td class="col-price"><input type="number" id="price-${i}" value="0" min="0" step="any" oninput="calcLine(${i})"></td>
<td class="col-tot"><div class="line-total-val" id="ltv-${i}">0.00</div></td>
<td class="col-act"><button type="button" class="btn-remove" onclick="removeLine(${i})">&#x00D7;</button></td>`;
tbody.appendChild(tr);
const alNew = document.createElement("tr");
alNew.id = "al-row"; alNew.className = "al";
alNew.innerHTML = `<td colspan="6">
<button type="button" class="btn-add-line" id="btn-al" onclick="addLine()">${t("add-line")}</button>
</td>`;
tbody.appendChild(alNew);
calcTotals();
}
function removeLine(i) {
document.getElementById(`lr-${i}`)?.remove();
document.getElementById(`fx-row-${i}`)?.remove();
delete lines[i];
calcTotals();
}
function pickProduct(i) {
const v = document.getElementById(`dsel-${i}`)?.value;
const txt = document.getElementById(`dtxt-${i}`);
txt.style.display = v === "__other__" ? "block" : "none";
if (v !== "" && v !== "__other__") {
const p = (cfg.products || [])[+v];
if (p) {
const uomEl = document.getElementById(`uom-${i}`);
const priceEl = document.getElementById(`price-${i}`);
if (uomEl && p.uom) uomEl.value = p.uom;
if (priceEl && p.price != null) priceEl.value = p.price;
}
}
calcLine(i);
}
// ── Foreign currency ──────────────────────────────────────────────────────────
function toggleFx(i) {
const on = document.getElementById(`fx-${i}`)?.value === "yes";
const lr = document.getElementById(`lr-${i}`);
document.getElementById(`fx-row-${i}`)?.remove();
if (!on) { lr?.classList.remove("open"); calcTotals(); return; }
lr?.classList.add("open");
const fxTr = document.createElement("tr");
fxTr.className = "fx"; fxTr.id = `fx-row-${i}`;
fxTr.innerHTML = `
<td colspan="6">
<div class="fx-grid">
<div>
<div class="fx-label">${t("currency-code")}</div>
<select id="fcur-${i}" onchange="calcLine(${i})">${currOpts("USD")}</select>
</div>
<div>
<div class="fx-label">${t("exchange-rate")}</div>
<input type="number" id="frate-${i}" value="1" min="0" step="any" oninput="calcLine(${i})">
</div>
<div>
<div class="fx-label">${t("per-item")}</div>
<input type="number" id="fper-${i}" value="0" min="0" step="any" oninput="calcFxFromPer(${i})">
</div>
</div>
<div class="fx-note">${t("total-local")}: <strong id="fltot-${i}">0.00</strong></div>
</td>`;
lr?.insertAdjacentElement("afterend", fxTr);
calcLine(i);
}
function calcFxFromPer(i) {
const per = pn(document.getElementById(`fper-${i}`)?.value);
const rate = pn(document.getElementById(`frate-${i}`)?.value) || 1;
const prEl = document.getElementById(`price-${i}`);
if (prEl) prEl.value = (per / rate).toFixed(6);
calcLine(i);
}
// ── Calculations ──────────────────────────────────────────────────────────────
function calcLine(i) {
const qty = pn(document.getElementById(`qty-${i}`)?.value);
const price = pn(document.getElementById(`price-${i}`)?.value);
const el = document.getElementById(`ltv-${i}`);
if (el) el.textContent = fmt(qty * price);
if (document.getElementById(`fx-${i}`)?.value === "yes") {
const rate = pn(document.getElementById(`frate-${i}`)?.value) || 1;
const per = pn(document.getElementById(`fper-${i}`)?.value);
const ltot = document.getElementById(`fltot-${i}`);
if (ltot) ltot.textContent = fmt((per / rate) * qty);
}
calcTotals();
}
function calcTotals() {
let sub = 0;
Object.keys(lines).forEach(i => {
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;
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);
}
// ── LocalStorage ──────────────────────────────────────────────────────────────
const LS_DATA = "inv_data_v1";
const LS_GEN = "inv_generated_v1";
function saveStorage() {
const d = {};
document.querySelectorAll("[data-ls]").forEach(el => { d[el.dataset.ls] = el.value; });
localStorage.setItem(LS_DATA, JSON.stringify(d));
}
function restoreStorage() {
try {
const raw = localStorage.getItem(LS_DATA);
if (!raw) return;
const d = JSON.parse(raw);
const wasGen = localStorage.getItem(LS_GEN) === "true";
Object.entries(d).forEach(([k, v]) => {
const el = document.querySelector(`[data-ls="${k}"]`);
if (el) el.value = v;
});
if (wasGen && d.ino) {
const bumped = bumpNum(d.ino);
const el = document.getElementById("ino");
if (el) el.value = bumped;
d.ino = bumped;
localStorage.setItem(LS_DATA, JSON.stringify(d));
localStorage.setItem(LS_GEN, "false");
}
} catch (_) {}
}
function bumpNum(s) {
return String(s).replace(/(\d+)$/, m => String(+m + 1).padStart(m.length, "0"));
}
// ── Relabel on language switch ────────────────────────────────────────────────
function relabel() {
const lm = {
"inv-title":"invoice","lbl-language":"language",
"sec-sender":"sender-section","sec-invdet":"invoice-details-section",
"sec-ct":"charge-to","sec-lines":"invoice-lines",
"lbl-sn":"sender-name","lbl-sa1":"sender-address1","lbl-sa2":"sender-address2",
"lbl-sa3":"sender-address3","lbl-sa4":"sender-address4","lbl-sc":"sender-country",
"lbl-sp":"sender-phone","lbl-se":"sender-email",
"lbl-idate":"invoice-date","lbl-pcode":"project-code","lbl-ino":"invoice-no",
"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",
"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",
};
Object.entries(lm).forEach(([id, key]) => {
const el = document.getElementById(id);
if (el) el.textContent = t(key);
});
document.getElementById("inv-banner").querySelector("h1").textContent = t("invoice");
Object.keys(lines).forEach(i => {
const fl = document.getElementById(`lbl-fx-${i}`);
if (fl) fl.textContent = t("foreign-currency") + ":";
const dt = document.getElementById(`dtxt-${i}`);
if (dt) dt.placeholder = t("description");
const ue = document.getElementById(`uom-${i}`);
if (ue) { const s = ue.value; ue.innerHTML = uomOpts(s); }
const de = document.getElementById(`dsel-${i}`);
if (de) { const s = de.value; de.innerHTML = prodOpts(s); }
const fe = document.getElementById(`fx-${i}`);
if (fe) {
const s = fe.value;
fe.innerHTML = `<option value="no">${t("no-option")}</option><option value="yes">${t("yes")}</option>`;
fe.value = s;
}
});
const alBtn = document.getElementById("btn-al");
if (alBtn) alBtn.textContent = t("add-line");
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 =>
`<option value="${tr.rate}" ${String(tr.rate) === cur ? "selected" : ""}>${h(tTax(tr))}</option>`
).join("");
}
const ctPick = document.getElementById("ct-pick");
if (ctPick) {
const cur = ctPick.value;
const ctOpts = (cfg["charge-to"] || []).map((ct, i) =>
`<option value="${i}">${h(ct.display || ct.name)}</option>`).join("");
ctPick.innerHTML = `<option value="">${t("select")}</option>${ctOpts}<option value="__other__">${t("other")}</option>`;
ctPick.value = cur;
}
document.getElementById("btn-dl").textContent = "⬇ " + t("download-pdf");
document.getElementById("btn-close").textContent = "✕ " + t("close");
}
// ── Generate preview ──────────────────────────────────────────────────────────
function generateInvoice() {
saveStorage();
localStorage.setItem(LS_GEN, "true");
document.getElementById("inv-doc").innerHTML = buildPreviewHTML();
const ov = document.getElementById("overlay");
ov.classList.add("on");
ov.scrollTop = 0;
document.getElementById("btn-dl").textContent = "⬇ " + t("download-pdf");
document.getElementById("btn-close").textContent = "✕ " + t("close");
}
function closeOverlay() {
document.getElementById("overlay").classList.remove("on");
}
// ── Shared: gather invoice data ───────────────────────────────────────────────
function gatherData(renderLang) {
const dl = renderLang || cfg["default-code"] || "en";
const td = key => { const e = cfg?.translations?.[key]; return e?.[dl] ?? e?.["en"] ?? key; };
const g = id => (document.getElementById(id)?.value ?? "").trim();
const sName = g("sn");
const sAddr = [g("sa1"),g("sa2"),g("sa3"),g("sa4")].filter(Boolean);
const sCntry = COUNTRY_MAP[g("sc")] || "";
const sPh = g("sp"), sEm = g("se");
const iDate = g("idate");
const pCode = g("pcode") === "__other__" ? g("pcode-other") : g("pcode");
const iNo = g("ino");
const ctName = g("ctn");
const ctAddr = [g("ca1"),g("ca2"),g("ca3"),g("ca4")].filter(Boolean);
const ctCntry= COUNTRY_MAP[g("cc")] || "";
const ctPh = g("cph"), ctEm = g("cem"), ctVat = g("cvat");
let sub = 0;
const rows = [];
Object.keys(lines).sort((a,b)=>+a-+b).forEach(i => {
const qty = pn(document.getElementById(`qty-${i}`)?.value);
const price = pn(document.getElementById(`price-${i}`)?.value);
const tot = qty * price;
if (qty === 0 && price === 0) return;
sub += tot;
const uomCode = document.getElementById(`uom-${i}`)?.value || "";
const uomObj = (cfg.uom||[]).find(u=>u.code===uomCode);
const uomLbl = uomObj?.labels?.[dl] ?? uomObj?.labels?.[cfg["default-code"]] ?? uomCode;
const dv = document.getElementById(`dsel-${i}`)?.value;
let desc = "";
if (dv === "__other__") {
desc = (document.getElementById(`dtxt-${i}`)?.value || "").trim();
} else if (dv !== "" && dv != null) {
const p = (cfg.products||[])[+dv];
desc = p?.description?.[dl] ?? p?.description?.[cfg["default-code"]] ?? "";
}
const isFx = document.getElementById(`fx-${i}`)?.value === "yes";
let fxNote = null;
if (isFx) {
const cur = document.getElementById(`fcur-${i}`)?.value || "";
const rate = pn(document.getElementById(`frate-${i}`)?.value) || 1;
const per = pn(document.getElementById(`fper-${i}`)?.value);
const ltot = (per / rate) * qty;
fxNote = { cur, rate, per, ltot, td };
}
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}%`;
return { dl, td, sName, sAddr, sCntry, sPh, sEm, iDate, pCode, iNo,
ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat,
rows, sub, taxRate, taxAmt, taxLabel, paid, toPay };
}
// ── Build HTML preview ────────────────────────────────────────────────────────
function buildPreviewHTML() {
const { td, sName, sAddr, sCntry, sPh, sEm, iDate, pCode, iNo,
ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat,
rows, sub, taxRate, taxAmt, taxLabel, paid, toPay } = gatherData();
const linesHTML = rows.map(row => {
const fxLine = row.fxNote
? `<div class="d-fx-note">${h(row.fxNote.td("foreign-currency"))}: `
+ `${h(row.fxNote.cur)} ${fmt(row.fxNote.per)} / ${row.fxNote.rate} `
+ `× ${row.qty} = ${fmt(row.fxNote.ltot)}</div>`
: "";
const qStr = row.qty % 1 === 0 ? row.qty : fmt(row.qty);
return `<tr>
<td>${qStr}</td><td>${h(row.uomLbl)}</td>
<td>${h(row.desc)}${fxLine}</td>
<td class="r">${fmt(row.price)}</td>
<td class="r b">${fmt(row.tot)}</td>
</tr>`;
}).join("") || `<tr><td colspan="5" style="text-align:center;color:#9ca3af;padding:20px">—</td></tr>`;
return `
<div class="d-hdr">
<div class="d-sender">
${sName ? `<div class="name">${h(sName)}</div>` : ""}
${sAddr.map(a=>`<p>${h(a)}</p>`).join("")}
${sCntry ? `<p>${h(sCntry)}</p>` : ""}
${sPh ? `<p>${h(td("sender-phone"))}: ${h(sPh)}</p>` : ""}
${sEm ? `<p>${h(td("sender-email"))}: ${h(sEm)}</p>` : ""}
</div>
<div class="d-title">
<h1>${h(td("invoice"))}</h1>
<table class="d-meta">
${iNo ? `<tr><td class="ml">${h(td("invoice-no"))}</td><td class="mv">${h(iNo)}</td></tr>` : ""}
${iDate ? `<tr><td class="ml">${h(td("invoice-date"))}</td><td class="mv">${h(fmtMonth(iDate))}</td></tr>` : ""}
${pCode ? `<tr><td class="ml">${h(td("project-code"))}</td><td class="mv">${h(pCode)}</td></tr>` : ""}
</table>
</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>` : ""}
</div>
</div>` : ""}
<table class="d-lines">
<thead><tr>
<th style="width:50px">${h(td("qty"))}</th>
<th style="width:70px">${h(td("uom"))}</th>
<th>${h(td("description"))}</th>
<th class="r" style="width:100px">${h(td("price"))}</th>
<th class="r" style="width:115px">${h(td("line-total"))}</th>
</tr></thead>
<tbody>${linesHTML}</tbody>
</table>
<table class="d-tots">
<tr class="sub">
<td class="sp"></td>
<td class="tl">${h(td("subtotal"))}</td>
<td class="tv">${fmt(sub)}</td>
</tr>
${taxRate > 0 ? `<tr><td class="sp"></td><td class="tl">${h(taxLabel)}</td><td class="tv">${fmt(taxAmt)}</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">
<td class="sp"></td>
<td class="tl">${h(td("to-pay"))}</td>
<td class="tv">${fmt(toPay)}</td>
</tr>
</table>`;
}
// ── Build PDF with jsPDF + Helvetica ─────────────────────────────────────────
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" });
// Page geometry
const PW = 210, PH = 297;
const ML = 15, MR = 15, MT = 15;
const CW = PW - ML - MR; // 180mm content width
const XR = PW - MR; // 195mm right edge
// Color helpers
const fc = (r,g,b) => doc.setFillColor(r,g,b);
const dc = (r,g,b) => doc.setDrawColor(r,g,b);
const tc = (r,g,b) => doc.setTextColor(r,g,b);
const fb = sz => { doc.setFont("helvetica","bold"); doc.setFontSize(sz); };
const fn = sz => { doc.setFont("helvetica","normal"); doc.setFontSize(sz); };
const tL = (s,x,y) => doc.text(String(s??""), x, y);
const tR = (s,x,y) => doc.text(String(s??""), x, y, {align:"right"});
const sp = (s,w) => doc.splitTextToSize(String(s??""), w);
const { dl, td, sName, sAddr, sCntry, sPh, sEm, iDate, pCode, iNo,
ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat,
rows, sub, taxRate, taxAmt, taxLabel, paid, toPay } = gatherData();
// ── HEADER ────────────────────────────────────────────────────────────────
let y = MT;
let sy = y; // sender column bottom tracker
let ry = y; // right column bottom tracker
// Sender name
if (sName) {
fb(13); tc(30,45,69);
tL(sName, ML, sy); sy += 6;
}
// Sender address
fn(8.5); tc(75,85,99);
[...sAddr, sCntry].filter(Boolean).forEach(line => { tL(line, ML, sy); sy += 4.5; });
// Sender contact
if (sPh || sEm) {
const parts = [];
if (sPh) parts.push(`${td("sender-phone")}: ${sPh}`);
if (sEm) parts.push(`${td("sender-email")}: ${sEm}`);
fn(8); tc(107,114,128);
tL(parts.join(" "), ML, sy); sy += 5;
}
// INVOICE title (right)
fb(26); tc(30,45,69);
tR(td("invoice"), XR, ry); ry += 11;
// Meta table (right-aligned)
const metaRows = [
iNo ? [td("invoice-no"), iNo] : null,
iDate ? [td("invoice-date"), fmtMonth(iDate)] : null,
pCode ? [td("project-code"), pCode] : null,
].filter(Boolean);
metaRows.forEach(([lbl, val]) => {
fn(8.5); tc(107,114,128); tR(lbl + ":", XR - 45, ry);
fb(8.5); tc(17,24,39); tR(val, XR, ry);
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) {
const bLines = [...ctAddr, ctCntry].filter(Boolean);
const ctParts = [];
if (ctPh) ctParts.push(`${td("charge-to-phone")}: ${ctPh}`);
if (ctEm) ctParts.push(`${td("charge-to-email")}: ${ctEm}`);
if (ctVat) ctParts.push(`${td("vat-id")}: ${ctVat}`);
const ctContact = ctParts.join(" ");
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;
}
// ── LINE ITEMS TABLE ──────────────────────────────────────────────────────
// Column widths: QTY=18, UOM=18, DESC=flex, PRICE=28, TOTAL=34
const CQ=18, CU=18, CP=28, CT=34;
const CD = CW - CQ - CU - CP - CT; // ~82mm
const xQ=ML, xU=xQ+CQ, xD=xU+CU, xP=xD+CD, xT=xP+CP;
const TH = 7; // table header height
if (y + TH > PH - 40) { doc.addPage(); y = MT; }
fc(30,45,69); dc(255,255,255); doc.setLineWidth(0);
doc.rect(ML, y, CW, TH, "F");
fb(8); tc(255,255,255);
tL(td("qty"), xQ+2, y+4.8);
tL(td("uom"), xU+2, y+4.8);
tL(td("description"), xD+2, y+4.8);
tR(td("price"), xP+CP-2, y+4.8);
tR(td("line-total"), XR-2, y+4.8);
y += TH;
const ROW_H = 7.5;
const FX_H = 4.5;
rows.forEach((row, idx) => {
// Calculate row height (description may wrap)
const dLines = sp(row.desc, CD - 4);
const descH = Math.max(0, (dLines.length - 1) * 3.8);
const rh = ROW_H + descH + (row.fxNote ? FX_H : 0);
if (y + rh > PH - 30) { doc.addPage(); y = MT; }
// Alternating row background
if (idx % 2 === 1) {
fc(249,250,251); dc(255,255,255); doc.setLineWidth(0);
doc.rect(ML, y, CW, rh, "F");
}
// Bottom border
dc(209,213,219); doc.setLineWidth(0.1);
doc.line(ML, y+rh, XR, y+rh);
const yt = y + 5;
const qStr = row.qty % 1 === 0 ? String(row.qty) : fmt(row.qty);
fn(8.5); tc(17,24,39);
tL(qStr, xQ+2, yt);
tL(row.uomLbl, xU+2, yt);
// Description (possibly multi-line)
dLines.forEach((dline, li) => tL(dline, xD+2, yt + li*3.8));
fn(8.5); tc(17,24,39); tR(fmt(row.price), xP+CP-2, yt);
fb(8.5); tc(17,24,39); tR(fmt(row.tot), XR-2, yt);
if (row.fxNote) {
const fxStr = `${td("foreign-currency")}: ${row.fxNote.cur} ${fmt(row.fxNote.per)}`
+ ` / ${row.fxNote.rate} × ${row.qty} = ${fmt(row.fxNote.ltot)}`;
fn(7); tc(107,114,128);
tL(fxStr, xD+2, y + ROW_H + descH + 3.2);
}
y += rh;
});
y += 4;
// ── TOTALS ────────────────────────────────────────────────────────────────
const TW = 78; // totals block width
const TX = XR - TW; // totals block left edge
const TRH = 6.5; // row height
const TLBX = XR - 40; // label right-align x
const totRows = [
[td("subtotal"), fmt(sub)],
...(taxRate > 0 ? [[taxLabel, fmt(taxAmt)]] : []),
...(paid > 0 ? [[td("paid"), "" + fmt(paid)]] : []),
];
totRows.forEach(([lbl, val]) => {
if (y + TRH > PH - 20) { doc.addPage(); y = MT; }
fn(8.5); tc(107,114,128); tR(lbl, TLBX, y+4.5);
fn(8.5); tc(17,24,39); tR(val, XR-2, y+4.5);
dc(209,213,219); doc.setLineWidth(0.1);
doc.line(TX, y+TRH, XR, y+TRH);
y += TRH;
});
// To Pay (navy bar)
if (y + 9 > PH - 10) { doc.addPage(); y = MT; }
fc(30,45,69); dc(255,255,255); doc.setLineWidth(0);
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);
// ── Save ──────────────────────────────────────────────────────────────────
doc.save(iNo ? `invoice-${iNo}.pdf` : "invoice.pdf");
}
// ── Start ─────────────────────────────────────────────────────────────────────
loadCfg();
</script>
</body>
</html>