invoice/app/index.html
Claude f90718ba34
Five fixes to invoice form and PDF
- Date picker: change invoice date from month picker to full date picker
  (type=date, formatted as "19 May 2026" in output)

- Invoice currency: add currency selector under invoice date, populated
  from config.currencies list, saved to localStorage; shown in invoice
  meta block on both preview and PDF

- Recipient currency: add currency field to each charge-to entry in
  config.yml; selecting a predefined recipient auto-sets invoice currency

- Lock predefined recipients: selecting a predefined charge-to entry
  locks all its fields (pointer-events off + muted style via #ct-fields
  .locked); switching to Other or clearing unlocks them

- Fix foreign-currency exchange rate calculation: the formula was
  inverted (per / rate instead of per * rate). If 1 USD = 32 local and
  per-item is USD 100, local price is now correctly 100 × 32 = 3200,
  not 100 / 32 = 3.125. Fix applied in calcFxFromPer, calcLine display,
  and gatherData (foreignTot = per × qty, the foreign-currency total).
  Updated fx note text to the specified format:
  "Converted from USD: 1 USD = 32.00000 THB. Per item: USD 100.00,
  line total: USD 500.00"

https://claude.ai/code/session_015iyCBgoTXNNqaErR287U1u
2026-05-19 08:09:39 +00:00

1240 lines
55 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); }
/* ── Locked charge-to fields ────────────────────────────────────────────── */
#ct-fields.locked input,
#ct-fields.locked select {
background: #f8f9fa;
color: var(--text-muted);
border-color: var(--border-light);
pointer-events: none;
cursor: not-allowed;
}
/* ── 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 fmtDate(v) {
if (!v) return "";
const [y, m, d] = v.split("-");
return new Date(+y, +m - 1, +d).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
}
// ── 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 dateDef = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
const curOpts = (cfg.currencies || []).map((c, i) =>
`<option value="${h(c)}" ${i === 0 ? "selected" : ""}>${h(c)}</option>`).join("");
const pcOpts = (cfg["project-codes"] || []).map(pc => `<option value="${h(pc)}">${h(pc)}</option>`).join("");
const 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="date" value="${dateDef}"></div>
<div class="fg"><label id="lbl-icur" for="icur">${t("invoice-currency")}</label>
<select id="icur" data-ls="icur">${curOpts}</select></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 id="ct-fields" 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 ?? ""; };
const fields = document.getElementById("ct-fields");
if (v === "" || v === "__other__") {
if (v === "") ["ctn","ca1","ca2","ca3","ca4","cc","cph","cem","cvat"].forEach(id => f(id, ""));
fields?.classList.remove("locked");
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"]);
fields?.classList.add("locked");
// Auto-set invoice currency from recipient config
if (ct.currency) {
const icurEl = document.getElementById("icur");
if (icurEl) { icurEl.value = ct.currency; saveStorage(); }
}
}
// ── 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}`);
// rate = "1 foreign = rate local", so local price = per * rate
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 per = pn(document.getElementById(`fper-${i}`)?.value);
const ltot = document.getElementById(`fltot-${i}`);
// Show foreign-currency line total (per * qty, still in foreign currency)
if (ltot) ltot.textContent = fmt(per * 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-icur":"invoice-currency","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 iCur = g("icur");
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 foreignTot = per * qty; // line total in foreign currency
fxNote = { cur, rate, per, foreignTot, 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, iCur,
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, iCur,
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("converted-from"))} ${h(row.fxNote.cur)}: `
+ `1 ${h(row.fxNote.cur)} = ${(+row.fxNote.rate).toFixed(5)} ${h(iCur)}. `
+ `${h(row.fxNote.td("per-item"))}: ${h(row.fxNote.cur)} ${fmt(row.fxNote.per)}, `
+ `${h(row.fxNote.td("line-total")).toLowerCase()}: ${h(row.fxNote.cur)} ${fmt(row.fxNote.foreignTot)}</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(fmtDate(iDate))}</td></tr>` : ""}
${pCode ? `<tr><td class="ml">${h(td("project-code"))}</td><td class="mv">${h(pCode)}</td></tr>` : ""}
${iCur ? `<tr><td class="ml">${h(td("invoice-currency"))}</td><td class="mv">${h(iCur)}</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, iCur,
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"), 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 - 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;
rows.forEach((row, idx) => {
// Calculate row height (description and fx note may wrap)
const dLines = sp(row.desc, CD - 4);
const descH = Math.max(0, (dLines.length - 1) * 3.8);
let fxH = 0;
if (row.fxNote) {
const fxStr = `${td("converted-from")} ${row.fxNote.cur}: 1 ${row.fxNote.cur} = ${(+row.fxNote.rate).toFixed(5)} ${iCur}. `
+ `${td("per-item")}: ${row.fxNote.cur} ${fmt(row.fxNote.per)}, `
+ `${td("line-total").toLowerCase()}: ${row.fxNote.cur} ${fmt(row.fxNote.foreignTot)}`;
const fxLines = sp(fxStr, CD + CP - 4);
fxH = fxLines.length * 3.5 + 1;
}
const rh = ROW_H + descH + fxH;
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("converted-from")} ${row.fxNote.cur}: `
+ `1 ${row.fxNote.cur} = ${(+row.fxNote.rate).toFixed(5)} ${iCur}. `
+ `${td("per-item")}: ${row.fxNote.cur} ${fmt(row.fxNote.per)}, `
+ `${td("line-total").toLowerCase()}: ${row.fxNote.cur} ${fmt(row.fxNote.foreignTot)}`;
fn(7); tc(107,114,128);
// Split if too long for the description column
const fxLines = sp(fxStr, CD + CP - 4);
fxLines.forEach((fl, fi) => tL(fl, xD+2, y + ROW_H + descH + 3.2 + fi * 3.5));
}
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>