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

https://claude.ai/code/session_015iyCBgoTXNNqaErR287U1u
2026-05-19 08:54:41 +00:00

1468 lines
66 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); }
.paid-inp { width: 120px !important; text-align: right; }
.tax-inputs { display: flex; align-items: center; gap: 5px; justify-content: flex-end; }
.tax-inputs input[type="number"] { width: 68px; }
.tax-inputs select { width: auto; }
.btn-rm-tax { background: none; color: var(--danger); font-size: 16px; line-height: 1; padding: 1px 4px; border-radius: 3px; }
.btn-rm-tax:hover { background: #fef2f2; }
.tax-add-cell { text-align: right; padding: 5px 16px !important; }
.btn-add-tax { background: none; color: var(--accent); font-size: 12px; font-weight: 500; padding: 3px 8px; border: 1px dashed var(--accent); border-radius: var(--radius); }
.btn-add-tax:hover { background: #eff6ff; }
/* ── 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-payment { border-top: 1px solid #e5e7eb; margin-top: 20px; padding-top: 14px; }
.d-payment .pay-title { font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 1.2px; color: #6b7280; margin-bottom: 8px; }
.d-payment .pay-terms { font-size: 10.5px; color: #4b5563; margin-bottom: 8px; }
.d-payment .pay-grid { display: grid; grid-template-columns: 140px 1fr; gap: 3px 10px; font-size: 10.5px; margin-bottom: 4px; }
.d-payment .pay-grid .pl { color: #6b7280; }
.d-payment .pay-grid .pv { color: #111827; font-weight: 500; }
.d-payment .pay-ref { margin-top: 8px; font-size: 10.5px; color: #4b5563; }
.d-payment .pay-ref strong { color: #111827; }
.d-tots { width: 100%; border-collapse: collapse; }
.d-tots td { padding: 5px 10px; font-size: 11.5px; }
.d-tots .sp { width: 55%; }
.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); }
/* ── Charge-to / Payment two-column layout ──────────────────────────────── */
.ct-two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: start; }
@media (max-width: 720px) { .ct-two-col { grid-template-columns: 1fr; } }
#payment-panel { border-left: 2px solid var(--border-light); padding-left: 20px; }
@media (max-width: 720px) { #payment-panel { border-left: none; padding-left: 0; border-top: 2px solid var(--border-light); padding-top: 16px; } }
.pay-section-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; color: var(--navy); margin-bottom: 10px; }
.pay-terms-row { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
.pay-terms-row label { font-size: 12px; color: var(--text-muted); white-space: nowrap; }
.pay-terms-row input { width: 64px; }
.pay-terms-row span { font-size: 12px; color: var(--text-muted); }
.pay-by-row { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; }
.pay-by-row label { font-size: 12px; color: var(--text-muted); white-space: nowrap; }
#paybydisp { font-size: 13px; font-weight: 600; color: var(--text); }
/* ── Locked charge-to fields ────────────────────────────────────────────── */
#ct-fields.locked input,
#ct-fields.locked select {
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 = {};
let tlid = 0;
const tLines = {};
// ── i18n ──────────────────────────────────────────────────────────────────────
function t(key) {
const e = cfg?.translations?.[key];
if (!e) return key;
return e[lang] ?? e[cfg["default-code"]] ?? key;
}
// ── 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 ─────────────────────────────────────────────────────────────────────
const MONTHS_FULL = ["January","February","March","April","May","June",
"July","August","September","October","November","December"];
const MONTHS_SHORT = ["Jan","Feb","Mar","Apr","May","Jun",
"Jul","Aug","Sep","Oct","Nov","Dec"];
function fmtDate(v) {
if (!v) return "";
const [yr, mo, dy] = v.split("-").map(Number);
const pattern = cfg?.["date-format"] || "d MMMM YYYY";
return pattern.replace(/YYYY|YY|MMMM|MMM|MM|M|dd|d/g, tok => {
switch (tok) {
case "YYYY": return yr;
case "YY": return String(yr).slice(-2);
case "MMMM": return MONTHS_FULL[mo - 1];
case "MMM": return MONTHS_SHORT[mo - 1];
case "MM": return String(mo).padStart(2, "0");
case "M": return mo;
case "dd": return String(dy).padStart(2, "0");
case "d": return dy;
default: return tok;
}
});
}
// ── 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("");
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 class="ct-two-col">
<div id="ct-fields">
<div class="fg"><label id="lbl-ctn" for="ctn">${t("charge-to-name")}</label>
<input id="ctn" type="text"></div>
<div class="fg"><label id="lbl-ca1" for="ca1">${t("charge-to-address1")}</label>
<input id="ca1" type="text"></div>
<div class="fg"><label id="lbl-ca2" for="ca2">${t("charge-to-address2")}</label>
<input id="ca2" type="text"></div>
<div class="fg"><label id="lbl-ca3" for="ca3">${t("charge-to-address3")}</label>
<input id="ca3" type="text"></div>
<div class="fg"><label id="lbl-ca4" for="ca4">${t("charge-to-address4")}</label>
<input id="ca4" type="text"></div>
<div class="fg"><label id="lbl-cc" for="cc">${t("charge-to-country")}</label>
<select id="cc">${countryOpts("")}</select></div>
<div class="fi"><label id="lbl-cph">${t("charge-to-phone")}:</label>
<input id="cph" type="tel"></div>
<div class="fi"><label id="lbl-cem">${t("charge-to-email")}:</label>
<input id="cem" type="email"></div>
<div class="fi"><label id="lbl-cvat">${t("vat-id")}:</label>
<input id="cvat" type="text"></div>
<div class="fi"><label id="lbl-creg">${t("registration-no")}:</label>
<input id="creg" type="text"></div>
</div>
<div id="payment-panel" style="display:none">
<div class="pay-section-title" id="lbl-pay-sec">${t("payment")}</div>
<div class="pay-terms-row">
<label id="lbl-pterm">${t("payment-terms")}:</label>
<input id="pterm" type="number" min="0" oninput="calcPayBy()">
<span id="lbl-days">${t("payment-days")}</span>
</div>
<div class="pay-by-row">
<label id="lbl-paybyl">${t("pay-by")}:</label>
<span id="paybydisp"></span>
</div>
<div class="fg"><label id="lbl-pacct">${t("account-holder")}</label>
<input id="pacct" type="text"></div>
<div class="fg"><label id="lbl-piban">${t("account-no")}</label>
<input id="piban" type="text"></div>
<div class="fg"><label id="lbl-pbic">${t("bank-bic")}</label>
<input id="pbic" type="text"></div>
<div class="fg"><label id="lbl-pbadr">${t("bank-address")}</label>
<input id="pbadr1" type="text">
<input id="pbadr2" type="text" style="margin-top:6px"></div>
<div class="fg"><label id="lbl-pref">${t("payment-ref")}</label>
<input id="pref" type="text"></div>
</div>
</div>
</div>
<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">
<tbody id="tot-pre">
<tr>
<td class="lbl" id="lbl-sub">${t("subtotal")}</td>
<td class="val" id="v-sub">0.00</td>
</tr>
</tbody>
<tbody id="tax-tbody"></tbody>
<tbody id="tot-post">
<tr>
<td colspan="2" class="tax-add-cell">
<button type="button" class="btn-add-tax" id="btn-add-tax" onclick="addTaxLine()">${t("add-tax")}</button>
</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>
</tbody>
</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("idate").addEventListener("change", calcPayBy);
document.getElementById("paid-inp").addEventListener("input", calcTotals);
document.getElementById("the-form").addEventListener("submit", e => { e.preventDefault(); generateInvoice(); });
document.querySelectorAll("[data-ls]").forEach(el => el.addEventListener("change", saveStorage));
}
// ── 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");
const hidePayment = cfg["hide-payment-info"] === true || cfg["hide-payment-info"] === "yes";
const ppanel = document.getElementById("payment-panel");
if (ppanel) ppanel.style.display = (!hidePayment && v === "__other__") ? "" : "none";
if (v === "" || v === "__other__") {
if (v === "") ["ctn","ca1","ca2","ca3","ca4","cc","cph","cem","cvat","creg"].forEach(id => f(id, ""));
fields?.classList.remove("locked");
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"]); f("creg", ct["reg-no"]);
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("");
}
function getTaxTypeOpts(sel) {
return (cfg["tax-types"] || []).map(tt => {
const lbl = tt.labels?.[lang] ?? tt.labels?.[cfg["default-code"]] ?? tt.key;
return `<option value="${h(tt.key)}" ${tt.key === sel ? "selected" : ""}>${h(lbl)}</option>`;
}).join("");
}
function addTaxLine() {
const i = tlid++;
tLines[i] = {};
const tbody = document.getElementById("tax-tbody");
const defaultKey = (cfg["tax-types"]||[])[0]?.key || "";
const tr = document.createElement("tr");
tr.className = "tax-row"; tr.id = `tlr-${i}`;
tr.innerHTML = `
<td class="lbl">
<div class="tax-inputs">
<input type="number" id="tv-${i}" value="" placeholder="0" min="0" step="any"
oninput="calcTotals()" style="width:68px">
<select id="tt-${i}" style="width:auto" onchange="calcTotals()">
<option value="pct">${t("tax-pct")}</option>
<option value="amt">${t("tax-amount")}</option>
</select>
<select id="tk-${i}" style="width:auto" onchange="calcTotals()">
${getTaxTypeOpts(defaultKey)}
</select>
<button type="button" class="btn-rm-tax" onclick="removeTaxLine(${i})">&#x00D7;</button>
</div>
</td>
<td class="val" id="ta-${i}">0.00</td>`;
tbody.appendChild(tr);
calcTotals();
}
function removeTaxLine(i) {
document.getElementById(`tlr-${i}`)?.remove();
delete tLines[i];
calcTotals();
}
// ── 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 calcPayBy() {
const idate = document.getElementById("idate")?.value;
const terms = parseInt(document.getElementById("pterm")?.value) || 0;
const el = document.getElementById("paybydisp");
if (!el) return;
if (idate && terms > 0) {
const d = new Date(idate + "T00:00:00");
d.setDate(d.getDate() + terms);
const iso = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
el.textContent = fmtDate(iso);
} else {
el.textContent = "";
}
}
function calcTotals() {
let sub = 0;
Object.keys(lines).forEach(i => {
sub += pn(document.getElementById(`qty-${i}`)?.value) *
pn(document.getElementById(`price-${i}`)?.value);
});
let totalTax = 0;
Object.keys(tLines).forEach(i => {
const val = pn(document.getElementById(`tv-${i}`)?.value);
const type = document.getElementById(`tt-${i}`)?.value || "pct";
const amt = type === "pct" ? sub * (val / 100) : val;
totalTax += amt;
const el = document.getElementById(`ta-${i}`);
if (el) el.textContent = fmt(amt);
});
const paid = pn(document.getElementById("paid-inp")?.value);
const toPay = sub + totalTax - paid;
const set = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = fmt(v); };
set("v-sub", sub);
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","lbl-creg":"registration-no",
"lbl-pay-sec":"payment","lbl-pterm":"payment-terms","lbl-days":"payment-days","lbl-paybyl":"pay-by",
"lbl-pacct":"account-holder","lbl-piban":"account-no","lbl-pbic":"bank-bic","lbl-pbadr":"bank-address","lbl-pref":"payment-ref",
"th-qty":"qty","th-uom":"uom","th-desc":"description","th-price":"price","th-tot":"line-total",
"lbl-sub":"subtotal","lbl-paid":"paid","lbl-topay":"to-pay",
};
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");
// Rebuild dynamic tax line dropdowns
Object.keys(tLines).forEach(i => {
const ttEl = document.getElementById(`tt-${i}`);
if (ttEl) {
const sv = ttEl.value;
ttEl.innerHTML = `<option value="pct">${t("tax-pct")}</option><option value="amt">${t("tax-amount")}</option>`;
ttEl.value = sv;
}
const tkEl = document.getElementById(`tk-${i}`);
if (tkEl) { const sv = tkEl.value; tkEl.innerHTML = getTaxTypeOpts(sv); }
});
const atBtn = document.getElementById("btn-add-tax");
if (atBtn) atBtn.textContent = t("add-tax");
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"), ctReg = g("creg");
const ppanel = document.getElementById("payment-panel");
const payVisible = ppanel && ppanel.style.display !== "none";
const pTerm = payVisible ? (parseInt(document.getElementById("pterm")?.value) || 0) : 0;
const pPayBy = payVisible ? (document.getElementById("paybydisp")?.textContent || "") : "";
const pAcct = payVisible ? g("pacct") : "";
const pIban = payVisible ? g("piban") : "";
const pBic = payVisible ? g("pbic") : "";
const pBadr1 = payVisible ? g("pbadr1"): "";
const pBadr2 = payVisible ? g("pbadr2"): "";
const pRef = payVisible ? g("pref") : "";
const hasPayment = payVisible && !!(pAcct || pIban || pBic || pBadr1 || pRef || pTerm > 0);
let sub = 0;
const rows = [];
Object.keys(lines).sort((a,b)=>+a-+b).forEach(i => {
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 });
});
let totalTax = 0;
const taxes = [];
Object.keys(tLines).sort((a,b)=>+a-+b).forEach(i => {
const val = pn(document.getElementById(`tv-${i}`)?.value);
const type = document.getElementById(`tt-${i}`)?.value || "pct";
const key = document.getElementById(`tk-${i}`)?.value || "";
const ttObj = (cfg["tax-types"]||[]).find(x => x.key === key);
const label = ttObj?.labels?.[dl] ?? ttObj?.labels?.[cfg["default-code"]] ?? key;
const amt = type === "pct" ? sub * (val / 100) : val;
totalTax += amt;
const lineLabel = type === "pct" ? `${label} ${val}%` : `${label} (${fmt(val)})`;
taxes.push({ val, type, key, label, lineLabel, amt });
});
const paid = pn(document.getElementById("paid-inp")?.value);
const toPay = sub + totalTax - paid;
return { dl, td, sName, sAddr, sCntry, sPh, sEm, iDate, pCode, iNo, iCur,
ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat, ctReg,
pTerm, pPayBy, pAcct, pIban, pBic, pBadr1, pBadr2, pRef, hasPayment,
rows, sub, taxes, totalTax, paid, toPay };
}
// ── Build HTML preview ────────────────────────────────────────────────────────
function buildPreviewHTML() {
const { td, sName, sAddr, sCntry, sPh, sEm, iDate, pCode, iNo, iCur,
ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat, ctReg,
pTerm, pPayBy, pAcct, pIban, pBic, pBadr1, pBadr2, pRef, hasPayment,
rows, sub, taxes, paid, toPay } = gatherData();
const linesHTML = rows.map(row => {
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>` : ""}
${ctReg ? `<span><strong>${h(td("registration-no"))}:</strong> ${h(ctReg)}</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>
${taxes.map(tx => `<tr><td class="sp"></td><td class="tl">${h(tx.lineLabel)}</td><td class="tv">${fmt(tx.amt)}</td></tr>`).join("")}
${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>
${hasPayment ? `
<div class="d-payment">
<div class="pay-title">${h(td("payment"))}</div>
${pTerm > 0 ? `<div class="pay-terms">${h(td("payment-terms"))}: <strong>${pTerm}</strong> ${h(td("payment-days"))}${pPayBy ? ` &mdash; ${h(td("pay-by"))}: <strong>${h(pPayBy)}</strong>` : ""}</div>` : ""}
<div class="pay-grid">
${pAcct ? `<span class="pl">${h(td("account-holder"))}</span><span class="pv">${h(pAcct)}</span>` : ""}
${pIban ? `<span class="pl">${h(td("account-no"))}</span><span class="pv">${h(pIban)}</span>` : ""}
${pBic ? `<span class="pl">${h(td("bank-bic"))}</span><span class="pv">${h(pBic)}</span>` : ""}
${pBadr1 || pBadr2 ? `<span class="pl">${h(td("bank-address"))}</span><span class="pv">${[pBadr1,pBadr2].filter(Boolean).map(l=>h(l)).join("<br>")}</span>` : ""}
</div>
${pRef ? `<div class="pay-ref">${h(td("payment-ref"))}: <strong>${h(pRef)}</strong></div>` : ""}
</div>` : ""}`;
}
// ── Build PDF with jsPDF + Helvetica ─────────────────────────────────────────
function buildPDF() {
if (!window.jspdf) { alert("PDF library not loaded — check your internet connection."); return; }
const { jsPDF } = window.jspdf;
const paperFmt = (cfg["paper-format"] || "a4").toLowerCase();
const doc = new jsPDF({ unit: "mm", format: paperFmt });
// Page geometry
const PW = paperFmt === "letter" ? 215.9 : 210;
const PH = paperFmt === "letter" ? 279.4 : 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, ctReg,
pTerm, pPayBy, pAcct, pIban, pBic, pBadr1, pBadr2, pRef, hasPayment,
rows, sub, taxes, 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}`);
if (ctReg) ctParts.push(`${td("registration-no")}: ${ctReg}`);
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)],
...taxes.map(tx => [tx.lineLabel, fmt(tx.amt)]),
...(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);
y += 9;
// ── PAYMENT DETAILS ───────────────────────────────────────────────────────
if (hasPayment) {
y += 8;
if (y + 8 > PH - 15) { doc.addPage(); y = MT; }
dc(209,213,219); doc.setLineWidth(0.4);
doc.line(ML, y, XR, y); y += 5;
fb(7); tc(107,114,128); tL(td("payment").toUpperCase(), ML, y); y += 5;
if (pTerm > 0) {
const termsStr = `${td("payment-terms")}: ${pTerm} ${td("payment-days")}${pPayBy ? ` ${td("pay-by")}: ${pPayBy}` : ""}`;
fn(8.5); tc(17,24,39); tL(termsStr, ML, y); y += 5;
}
const LBL = 50;
const payRows = [
pAcct ? [td("account-holder"), pAcct] : null,
pIban ? [td("account-no"), pIban] : null,
pBic ? [td("bank-bic"), pBic] : null,
(pBadr1 || pBadr2) ? [td("bank-address"), [pBadr1,pBadr2].filter(Boolean).join(", ")] : null,
].filter(Boolean);
payRows.forEach(([lbl, val]) => {
if (y + 5 > PH - 10) { doc.addPage(); y = MT; }
fn(8); tc(107,114,128); tL(lbl + ":", ML, y);
fn(8.5); tc(17,24,39); tL(val, ML + LBL, y);
y += 4.5;
});
if (pRef) {
y += 1;
if (y + 6 > PH - 10) { doc.addPage(); y = MT; }
fn(8); tc(107,114,128); tL(td("payment-ref") + ":", ML, y);
fb(9); tc(17,24,39); tL(pRef, ML + LBL, y);
}
}
// ── Save ──────────────────────────────────────────────────────────────────
doc.save(iNo ? `invoice-${iNo}.pdf` : "invoice.pdf");
}
// ── Start ─────────────────────────────────────────────────────────────────────
loadCfg();
</script>
</body>
</html>