invoice/app/index.html
Claude 8cdb4bd72b
Fix FX rate convention to match market-standard quoting
Users were confused by the "X foreign = 1 local" convention, which is the
inverse of how exchange rates are normally quoted (e.g. "1 USD = 35 THB").
Flip to the market-standard "1 foreign = X local" direction, updating the
rate label, the price calculation (per * rate instead of per / rate), and
the PDF note.

Note: existing localStorage data using the old convention will produce
incorrect prices until users re-enter their exchange rates.

https://claude.ai/code/session_0151QtsUhzXmgzEhSvXG2SDt
2026-06-01 17:51:52 +00:00

1522 lines
68 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; }
input:disabled, select:disabled {
background: #f8f9fa; color: var(--text-muted);
border-color: var(--border-light); cursor: not-allowed;
}
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; }
#lang-bar .lang-part { display: flex; align-items: center; gap: 8px; }
.sz-btn {
background: var(--white); border: 1px solid var(--border); border-radius: 4px;
color: var(--text); font-size: 13px; font-weight: 600; padding: 2px 8px;
line-height: 1.4; cursor: pointer;
}
.sz-btn:hover { background: var(--surface); }
#sz-label { font-size: 11px; color: var(--text-muted); min-width: 28px; text-align: center; }
/* ── 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: 96px; } .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; }
#btn-reset-lines { background: none; border: 1px solid var(--border); color: var(--text-muted); font-size: 11px; padding: 3px 10px; border-radius: 3px; }
#btn-reset-lines:hover { background: #fee2e2; border-color: var(--danger); color: var(--danger); }
/* ── 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); }
/* ── Payment card form fields ────────────────────────────────────────────── */
.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: 12px; }
.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 / accessibility bar -->
<div id="lang-bar">
<button class="sz-btn" id="sz-down" onclick="bumpZoom(-1)" title="Decrease text size">A</button>
<span id="sz-label">100%</span>
<button class="sz-btn" id="sz-up" onclick="bumpZoom(1)" title="Increase text size">A+</button>
<div id="lang-part" class="lang-part" style="display:none">
<span style="margin-left:6px">&#127760;</span>
<label id="lbl-language" for="lang-sel">Language</label>
<select id="lang-sel"></select>
</div>
</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 -->
<!-- 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 = {};
let _loading = false;
// ── 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"],["AX","Åland Islands"],["AL","Albania"],["DZ","Algeria"],
["AS","American Samoa"],["AD","Andorra"],["AO","Angola"],["AI","Anguilla"],
["AQ","Antarctica"],["AG","Antigua and Barbuda"],["AR","Argentina"],
["AM","Armenia"],["AW","Aruba"],["AU","Australia"],["AT","Austria"],
["AZ","Azerbaijan"],["BS","Bahamas"],["BH","Bahrain"],["BD","Bangladesh"],
["BB","Barbados"],["BY","Belarus"],["BE","Belgium"],["BZ","Belize"],
["BJ","Benin"],["BM","Bermuda"],["BT","Bhutan"],["BO","Bolivia"],
["BQ","Bonaire, Sint Eustatius and Saba"],["BA","Bosnia and Herzegovina"],
["BW","Botswana"],["BV","Bouvet Island"],["BR","Brazil"],
["IO","British Indian Ocean Territory"],["BN","Brunei"],["BG","Bulgaria"],
["BF","Burkina Faso"],["BI","Burundi"],["CV","Cabo Verde"],["KH","Cambodia"],
["CM","Cameroon"],["CA","Canada"],["KY","Cayman Islands"],
["CF","Central African Republic"],["TD","Chad"],["CL","Chile"],["CN","China"],
["CX","Christmas Island"],["CC","Cocos (Keeling) Islands"],["CO","Colombia"],
["KM","Comoros"],["CG","Congo"],["CD","Congo, Democratic Republic"],
["CK","Cook Islands"],["CR","Costa Rica"],["CI","Côte d'Ivoire"],
["HR","Croatia"],["CU","Cuba"],["CW","Curaçao"],["CY","Cyprus"],
["CZ","Czech Republic"],["DK","Denmark"],["DJ","Djibouti"],["DM","Dominica"],
["DO","Dominican Republic"],["EC","Ecuador"],["EG","Egypt"],
["SV","El Salvador"],["GQ","Equatorial Guinea"],["ER","Eritrea"],
["EE","Estonia"],["SZ","Eswatini"],["ET","Ethiopia"],
["FK","Falkland Islands"],["FO","Faroe Islands"],["FJ","Fiji"],
["FI","Finland"],["FR","France"],["GF","French Guiana"],
["PF","French Polynesia"],["TF","French Southern Territories"],["GA","Gabon"],
["GM","Gambia"],["GE","Georgia"],["DE","Germany"],["GH","Ghana"],
["GI","Gibraltar"],["GR","Greece"],["GL","Greenland"],["GD","Grenada"],
["GP","Guadeloupe"],["GU","Guam"],["GT","Guatemala"],["GG","Guernsey"],
["GN","Guinea"],["GW","Guinea-Bissau"],["GY","Guyana"],["HT","Haiti"],
["HM","Heard Island and McDonald Islands"],["VA","Holy See"],["HN","Honduras"],
["HK","Hong Kong"],["HU","Hungary"],["IS","Iceland"],["IN","India"],
["ID","Indonesia"],["IR","Iran"],["IQ","Iraq"],["IE","Ireland"],
["IM","Isle of Man"],["IL","Israel"],["IT","Italy"],["JM","Jamaica"],
["JP","Japan"],["JE","Jersey"],["JO","Jordan"],["KZ","Kazakhstan"],
["KE","Kenya"],["KI","Kiribati"],["KP","Korea, North"],["KR","Korea, South"],
["KW","Kuwait"],["KG","Kyrgyzstan"],["LA","Laos"],["LV","Latvia"],
["LB","Lebanon"],["LS","Lesotho"],["LR","Liberia"],["LY","Libya"],
["LI","Liechtenstein"],["LT","Lithuania"],["LU","Luxembourg"],["MO","Macao"],
["MG","Madagascar"],["MW","Malawi"],["MY","Malaysia"],["MV","Maldives"],
["ML","Mali"],["MT","Malta"],["MH","Marshall Islands"],["MQ","Martinique"],
["MR","Mauritania"],["MU","Mauritius"],["YT","Mayotte"],["MX","Mexico"],
["FM","Micronesia"],["MD","Moldova"],["MC","Monaco"],["MN","Mongolia"],
["ME","Montenegro"],["MS","Montserrat"],["MA","Morocco"],["MZ","Mozambique"],
["MM","Myanmar"],["NA","Namibia"],["NR","Nauru"],["NP","Nepal"],
["NL","Netherlands"],["NC","New Caledonia"],["NZ","New Zealand"],
["NI","Nicaragua"],["NE","Niger"],["NG","Nigeria"],["NU","Niue"],
["NF","Norfolk Island"],["MK","North Macedonia"],
["MP","Northern Mariana Islands"],["NO","Norway"],["OM","Oman"],
["PK","Pakistan"],["PW","Palau"],["PS","Palestine"],["PA","Panama"],
["PG","Papua New Guinea"],["PY","Paraguay"],["PE","Peru"],["PH","Philippines"],
["PN","Pitcairn"],["PL","Poland"],["PT","Portugal"],["PR","Puerto Rico"],
["QA","Qatar"],["RE","Réunion"],["RO","Romania"],["RU","Russia"],
["RW","Rwanda"],["BL","Saint Barthélemy"],["SH","Saint Helena"],
["KN","Saint Kitts and Nevis"],["LC","Saint Lucia"],
["MF","Saint Martin (French)"],["PM","Saint Pierre and Miquelon"],
["VC","Saint Vincent and the Grenadines"],["WS","Samoa"],["SM","San Marino"],
["ST","Sao Tome and Principe"],["SA","Saudi Arabia"],["SN","Senegal"],
["RS","Serbia"],["SC","Seychelles"],["SL","Sierra Leone"],["SG","Singapore"],
["SX","Sint Maarten"],["SK","Slovakia"],["SI","Slovenia"],
["SB","Solomon Islands"],["SO","Somalia"],["ZA","South Africa"],
["GS","South Georgia and South Sandwich Islands"],["SS","South Sudan"],
["ES","Spain"],["LK","Sri Lanka"],["SD","Sudan"],["SR","Suriname"],
["SJ","Svalbard and Jan Mayen"],["SE","Sweden"],["CH","Switzerland"],
["SY","Syria"],["TW","Taiwan"],["TJ","Tajikistan"],["TZ","Tanzania"],
["TH","Thailand"],["TL","Timor-Leste"],["TG","Togo"],["TK","Tokelau"],
["TO","Tonga"],["TT","Trinidad and Tobago"],["TN","Tunisia"],["TR","Turkey"],
["TM","Turkmenistan"],["TC","Turks and Caicos Islands"],["TV","Tuvalu"],
["UG","Uganda"],["UA","Ukraine"],["AE","United Arab Emirates"],
["GB","United Kingdom"],["UM","United States Minor Outlying Islands"],
["US","United States"],["UY","Uruguay"],["UZ","Uzbekistan"],["VU","Vanuatu"],
["VE","Venezuela"],["VN","Vietnam"],["VG","Virgin Islands, British"],
["VI","Virgin Islands, U.S."],["WF","Wallis and Futuna"],
["EH","Western Sahara"],["YE","Yemen"],["ZM","Zambia"],["ZW","Zimbabwe"]
];
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();
if (!loadLines()) addLine();
document.getElementById("loading").style.display = "none";
}
// ── Font-size accessibility ────────────────────────────────────────────────────
const ZOOMS = [0.8, 0.85, 0.9, 0.95, 1.0, 1.1, 1.2, 1.3];
const ZOOM_LABELS = ["80%", "85%", "90%", "95%", "100%", "110%", "120%", "130%"];
let zoomIdx = +(localStorage.getItem("zoomIdx") ?? 4);
function applyZoom() {
const fr = document.getElementById("form-root");
if (fr) fr.style.zoom = ZOOMS[zoomIdx];
const lbl = document.getElementById("sz-label");
if (lbl) lbl.textContent = ZOOM_LABELS[zoomIdx];
document.getElementById("sz-down").disabled = zoomIdx === 0;
document.getElementById("sz-up").disabled = zoomIdx === ZOOMS.length - 1;
}
function bumpZoom(dir) {
zoomIdx = Math.max(0, Math.min(ZOOMS.length - 1, zoomIdx + dir));
localStorage.setItem("zoomIdx", zoomIdx);
applyZoom();
}
// ── Language bar ──────────────────────────────────────────────────────────────
function buildLangBar() {
applyZoom();
const langs = cfg.languages || [{ code: cfg["default-code"], name: cfg["default-name"] }];
if (langs.length < 2) return;
const part = document.getElementById("lang-part");
if (part) part.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 class="fi"><label id="lbl-stax">${t("vat-id")}:</label>
<input id="stax" type="text" data-ls="stax"></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" data-ls="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" data-ls="pcode-other">
</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 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="locked">
<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>
<div class="card">
<div class="card-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" value="7" oninput="calcPayBy()" data-ls="pterm">
<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 id="bank-section" style="display:none">
<div class="fg"><label id="lbl-pacct">${t("account-holder")}</label>
<input id="pacct" type="text" data-ls="pacct"></div>
<div class="fg"><label id="lbl-piban">${t("account-no")}</label>
<input id="piban" type="text" data-ls="piban"></div>
<div class="fg"><label id="lbl-pbic">${t("bank-bic")}</label>
<input id="pbic" type="text" data-ls="pbic"></div>
<div class="fg"><label id="lbl-pbadr">${t("bank-address")}</label>
<input id="pbadr1" type="text" data-ls="pbadr1">
<input id="pbadr2" type="text" style="margin-top:6px" data-ls="pbadr2"></div>
<div class="fg"><label id="lbl-pref">${t("payment-ref")}</label>
<input id="pref" type="text" data-ls="pref"></div>
</div>
</div>
</div>
<div id="lines-card">
<div class="card-title" style="display:flex;align-items:center;justify-content:space-between">
<span id="sec-lines">${t("invoice-lines")}</span>
<button type="button" id="btn-reset-lines" onclick="resetLines()">${t("reset-lines")}</button>
</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));
document.getElementById("icur").addEventListener("change", () => {
Object.keys(lines).forEach(k => {
if (document.getElementById(`frate-lbl-${k}`)) updateFxLabels(+k);
});
});
calcPayBy(); // compute initial pay-by from default 7-day term
}
// ── Project-code dropdown ─────────────────────────────────────────────────────
function updateProjectCodes(codes) {
const arr = (codes && codes.length) ? codes : (cfg["project-codes"] || []);
const sel = document.getElementById("pcode");
if (!sel) return;
const prev = sel.value;
sel.innerHTML = `<option value="">${t("select")}</option>`
+ arr.map(pc => `<option value="${h(pc)}">${h(pc)}</option>`).join("")
+ `<option value="__other__">${t("other")}</option>`;
sel.value = arr.includes(prev) ? prev : "";
const wrap = document.getElementById("pcode-other-wrap");
if (wrap) wrap.style.display = sel.value === "__other__" ? "block" : "none";
}
// ── 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 bsec = document.getElementById("bank-section");
if (bsec) bsec.style.display = v === "__other__" ? "" : "none";
if (v === "") {
["ctn","ca1","ca2","ca3","ca4","cc","cph","cem","cvat","creg"].forEach(id => f(id, ""));
fields?.classList.add("locked");
updateProjectCodes(null); // restore global project codes
return;
}
if (v === "__other__") {
fields?.classList.remove("locked");
updateProjectCodes(null); // restore global project codes
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");
// Swap project codes to this recipient's list (falls back to global if none defined)
updateProjectCodes(ct["project-codes"] || null);
// 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="" min="0" step="0.01"
placeholder="0.00" oninput="calcLine(${i});saveLines()" disabled></td>
<td class="col-uom"><select id="uom-${i}" onchange="calcLine(${i});saveLines()" disabled>${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});saveLines()">
<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="" min="0" step="any" oninput="calcLine(${i});saveLines()" disabled></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();
return i;
}
function removeLine(i) {
document.getElementById(`lr-${i}`)?.remove();
document.getElementById(`fx-row-${i}`)?.remove();
delete lines[i];
calcTotals();
saveLines();
}
function pickProduct(i) {
const v = document.getElementById(`dsel-${i}`)?.value;
const txt = document.getElementById(`dtxt-${i}`);
const qtyEl = document.getElementById(`qty-${i}`);
const uomEl = document.getElementById(`uom-${i}`);
const priceEl= document.getElementById(`price-${i}`);
const fxOn = document.getElementById(`fx-${i}`)?.value === "yes";
txt.style.display = v === "__other__" ? "block" : "none";
if (v === "") {
// Nothing selected — lock all three
if (qtyEl) { qtyEl.disabled = true; qtyEl.value = ""; }
if (uomEl) { uomEl.disabled = true; uomEl.value = ""; }
if (priceEl) { priceEl.disabled = true; priceEl.value = ""; }
} else if (v === "__other__") {
// Free text — unlock all three (price stays locked if FX is on)
if (qtyEl) qtyEl.disabled = false;
if (uomEl) uomEl.disabled = false;
if (!fxOn && priceEl) priceEl.disabled = false;
} else {
// Predefined — fill UOM + price (+ optional qty) from config; lock UOM, unlock qty + price
const p = (cfg.products || [])[+v];
if (p) {
if (uomEl && p.uom) uomEl.value = p.uom;
if (priceEl && p.price != null) priceEl.value = p.price;
if (qtyEl && p.qty != null) qtyEl.value = p.qty;
}
if (qtyEl) qtyEl.disabled = false;
if (uomEl) uomEl.disabled = true; // UOM locked for predefined items
if (!fxOn && priceEl) priceEl.disabled = false;
}
calcLine(i);
saveLines();
}
// ── Foreign currency ──────────────────────────────────────────────────────────
function toggleFx(i) {
const on = document.getElementById(`fx-${i}`)?.value === "yes";
const lr = document.getElementById(`lr-${i}`);
const priceEl = document.getElementById(`price-${i}`);
document.getElementById(`fx-row-${i}`)?.remove();
if (!on) {
lr?.classList.remove("open");
// Re-enable price if a description is selected
const dv = document.getElementById(`dsel-${i}`)?.value;
if (dv && dv !== "" && priceEl) priceEl.disabled = false;
calcTotals();
saveLines();
return;
}
// Lock unit price — it will only be set by calcFxFromPer
if (priceEl) { priceEl.disabled = true; priceEl.value = ""; }
lr?.classList.add("open");
const lcy = document.getElementById("icur")?.value || "";
const defaultFcy = (cfg.currencies || ["USD"])[0] || "USD";
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="updateFxLabels(${i});calcLine(${i});saveLines()">${currOpts(defaultFcy)}</select>
</div>
<div>
<div class="fx-label" id="frate-lbl-${i}">Exchange rate (1 ${h(defaultFcy)} = X ${h(lcy)})</div>
<input type="number" id="frate-${i}" value="" min="0" step="any" oninput="calcFxFromPer(${i})">
</div>
<div>
<div class="fx-label" id="fper-lbl-${i}">Price per item in ${h(defaultFcy)}</div>
<input type="number" id="fper-${i}" value="" min="0" step="any" oninput="calcFxFromPer(${i})">
</div>
</div>
<div class="fx-note">${t("total-foreign")}: <strong id="fltot-${i}">0.00</strong></div>
</td>`;
lr?.insertAdjacentElement("afterend", fxTr);
calcLine(i);
saveLines();
}
function calcFxFromPer(i) {
const per = pn(document.getElementById(`fper-${i}`)?.value);
const rate = pn(document.getElementById(`frate-${i}`)?.value) || 1;
const prEl = document.getElementById(`price-${i}`);
if (prEl) prEl.value = (per * rate).toFixed(6);
calcLine(i);
saveLines();
}
// ── 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;
});
// Restore "Other" project-code visibility
if (d.pcode === "__other__") {
const w = document.getElementById("pcode-other-wrap");
if (w) w.style.display = "block";
}
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-stax":"vat-id",
"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 rstBtn = document.getElementById("btn-reset-lines");
if (rstBtn) rstBtn.textContent = t("reset-lines");
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;
}
}
// ── Generate invoice ──────────────────────────────────────────────────────────
function generateInvoice() {
saveStorage();
localStorage.setItem(LS_GEN, "true");
buildPDF();
}
// ── 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"), sTax = g("stax");
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 pTerm = parseInt(document.getElementById("pterm")?.value) || 0;
const pPayBy = document.getElementById("paybydisp")?.textContent || "";
const bsecEl = document.getElementById("bank-section");
const bankVis = bsecEl && bsecEl.style.display !== "none";
const pAcct = bankVis ? g("pacct") : "";
const pIban = bankVis ? g("piban") : "";
const pBic = bankVis ? g("pbic") : "";
const pBadr1 = bankVis ? g("pbadr1"): "";
const pBadr2 = bankVis ? g("pbadr2"): "";
const pRef = bankVis ? g("pref") : "";
const hasBank = !!(pAcct || pIban || pBic || pBadr1 || pRef);
const hidePaymentOut = cfg["hide-payment-info"] === true || cfg["hide-payment-info"] === "yes";
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, sTax, iDate, pCode, iNo, iCur,
ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat, ctReg,
pTerm, pPayBy, pAcct, pIban, pBic, pBadr1, pBadr2, pRef, hasBank, hidePaymentOut,
rows, sub, taxes, totalTax, paid, toPay };
}
// ── Build HTML preview ────────────────────────────────────────────────────────
// ── 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, sTax, iDate, pCode, iNo, iCur,
ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat, ctReg,
pTerm, pPayBy, pAcct, pIban, pBic, pBadr1, pBadr2, pRef, hasBank, hidePaymentOut,
rows, sub, taxes, paid, toPay } = gatherData();
const showBank = hasBank && !hidePaymentOut;
// ── 2×2 HEADER ───────────────────────────────────────────────────────────
const GAP = 8;
const LW = (CW - GAP) / 2; // ~86mm each column
const XM_L = ML + LW + GAP; // left edge of right column
let y = MT;
let ly = y, ry = y; // row-1 bottom trackers (left / right)
// ── Row 1 left: Sender ──
if (sName) { fb(13); tc(30,45,69); tL(sName, ML, ly); ly += 6; }
fn(8.5); tc(75,85,99);
[...sAddr, sCntry].filter(Boolean).forEach(l => { tL(l, ML, ly); ly += 4.5; });
if (sPh || sEm || sTax) {
const parts = [];
if (sPh) parts.push(`${td("sender-phone")}: ${sPh}`);
if (sEm) parts.push(`${td("sender-email")}: ${sEm}`);
if (sTax) parts.push(`${td("vat-id")}: ${sTax}`);
fn(8); tc(107,114,128); tL(parts.join(" "), ML, ly); ly += 5;
}
// ── Row 1 right: INVOICE + meta ──
fb(24); tc(30,45,69); tR(td("invoice"), XR, ry); ry += 10;
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 - 42, ry);
fb(8.5); tc(17,24,39); tR(val, XR, ry);
ry += 5;
});
// Row 1 divider
const row1Y = Math.max(ly, ry) + 4;
dc(209,213,219); doc.setLineWidth(0.3);
doc.line(ML, row1Y, XR, row1Y);
// ── Row 2 left: Charge-to ──
let ly2 = row1Y + 5, ry2 = row1Y + 5;
fb(7); tc(107,114,128); tL(td("charge-to").toUpperCase(), ML, ly2); ly2 += 5;
if (ctName) {
fb(10); tc(30,45,69); tL(ctName, ML, ly2); ly2 += 5.5;
fn(8.5); tc(17,24,39);
[...ctAddr, ctCntry].filter(Boolean).forEach(l => { tL(l, ML, ly2); ly2 += 4.5; });
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}`);
if (ctParts.length) { fn(8); tc(107,114,128); tL(ctParts.join(" "), ML, ly2); ly2 += 5; }
}
// ── Row 2 right: Payment ──
if (pTerm > 0 || showBank) {
fb(7); tc(107,114,128); tL(td("payment").toUpperCase(), XM_L, ry2); ry2 += 5;
if (pTerm > 0) {
const ts = `${td("payment-terms")}: ${pTerm} ${td("payment-days")}${pPayBy ? ` ${td("pay-by")}: ${pPayBy}` : ""}`;
fn(8.5); tc(17,24,39); tL(ts, XM_L, ry2); ry2 += 5;
}
if (showBank) {
const LLBL = 46;
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]) => {
fn(8); tc(107,114,128); tL(lbl + ":", XM_L, ry2);
fn(8.5); tc(17,24,39);
const wrapped = sp(val, LW - LLBL - 2);
wrapped.forEach((line, i) => tL(line, XM_L + LLBL, ry2 + i * 4));
ry2 += Math.max(4.5, wrapped.length * 4);
});
if (pRef) {
fn(8); tc(107,114,128); tL(td("payment-ref") + ":", XM_L, ry2);
fb(8.5); tc(17,24,39); tL(pRef, XM_L + LLBL, ry2); ry2 += 5;
}
}
}
y = Math.max(ly2, ry2) + 5;
// Navy rule
dc(30,45,69); doc.setLineWidth(0.6);
doc.line(ML, y, XR, y); y += 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("per-item")}: ${row.fxNote.cur} ${fmt(row.fxNote.per)} `
+ `(1 ${row.fxNote.cur} = ${(+row.fxNote.rate).toFixed(5)} ${iCur})`;
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("per-item")}: ${row.fxNote.cur} ${fmt(row.fxNote.per)} `
+ `(1 ${row.fxNote.cur} = ${(+row.fxNote.rate).toFixed(5)} ${iCur})`;
fn(7); tc(107,114,128);
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;
// ── Save ──────────────────────────────────────────────────────────────────
doc.save(iNo ? `invoice-${iNo}.pdf` : "invoice.pdf");
}
// ── Update FX labels when currency or invoice currency changes ────────────────
function updateFxLabels(i) {
const fcy = document.getElementById(`fcur-${i}`)?.value || "FCY";
const lcy = document.getElementById("icur")?.value || "";
const rl = document.getElementById(`frate-lbl-${i}`);
const pl = document.getElementById(`fper-lbl-${i}`);
if (rl) rl.textContent = `Exchange rate (1 ${fcy} = X ${lcy})`;
if (pl) pl.textContent = `Price per item in ${fcy}`;
}
// ── Save / load invoice lines ──────────────────────────────────────────────────
const LS_LINES = "inv_lines_v1";
function saveLines() {
if (_loading) return;
const data = [];
Object.keys(lines).sort((a, b) => +a - +b).forEach(k => {
const i = +k;
data.push({
dsel: document.getElementById(`dsel-${i}`)?.value || "",
dtxt: document.getElementById(`dtxt-${i}`)?.value || "",
qty: document.getElementById(`qty-${i}`)?.value || "",
uom: document.getElementById(`uom-${i}`)?.value || "",
price: document.getElementById(`price-${i}`)?.value || "",
fx: document.getElementById(`fx-${i}`)?.value || "no",
fcur: document.getElementById(`fcur-${i}`)?.value || "",
frate: document.getElementById(`frate-${i}`)?.value || "",
fper: document.getElementById(`fper-${i}`)?.value || "",
});
});
localStorage.setItem(LS_LINES, JSON.stringify(data));
}
function loadLines() {
try {
const raw = localStorage.getItem(LS_LINES);
if (!raw) return false;
const data = JSON.parse(raw);
if (!Array.isArray(data) || data.length === 0) return false;
// Remove the default first empty line added by boot()
const firstKey = Object.keys(lines)[0];
if (firstKey !== undefined) {
document.getElementById(`lr-${firstKey}`)?.remove();
document.getElementById(`fx-row-${firstKey}`)?.remove();
delete lines[+firstKey];
}
_loading = true;
data.forEach(ld => {
const i = addLine();
// Restore description selection
if (ld.dsel) {
const dsel = document.getElementById(`dsel-${i}`);
if (dsel) {
dsel.value = ld.dsel;
pickProduct(i); // enables/disables fields, fills UOM+price from config
if (ld.dsel === "__other__" && ld.dtxt) {
const dtxt = document.getElementById(`dtxt-${i}`);
if (dtxt) dtxt.value = ld.dtxt;
}
}
}
// Restore UOM (covers "Other" lines where user picked their own)
if (ld.uom) { const el = document.getElementById(`uom-${i}`); if (el) el.value = ld.uom; }
// Restore qty
if (ld.qty) { const el = document.getElementById(`qty-${i}`); if (el) el.value = ld.qty; }
if (ld.fx === "yes") {
const fxEl = document.getElementById(`fx-${i}`);
if (fxEl) {
fxEl.value = "yes";
toggleFx(i); // creates FX row and locks price
if (ld.fcur) {
const el = document.getElementById(`fcur-${i}`);
if (el) { el.value = ld.fcur; updateFxLabels(i); }
}
if (ld.frate) { const el = document.getElementById(`frate-${i}`); if (el) el.value = ld.frate; }
if (ld.fper) { const el = document.getElementById(`fper-${i}`); if (el) el.value = ld.fper; }
calcFxFromPer(i);
}
} else {
if (ld.price) { const el = document.getElementById(`price-${i}`); if (el) el.value = ld.price; }
calcLine(i);
}
});
_loading = false;
saveLines();
calcTotals();
return true;
} catch (e) {
_loading = false;
return false;
}
}
function resetLines() {
if (!confirm(t("reset-lines") + "?")) return;
Object.keys(lines).forEach(k => {
document.getElementById(`lr-${+k}`)?.remove();
document.getElementById(`fx-row-${+k}`)?.remove();
delete lines[+k];
});
localStorage.removeItem(LS_LINES);
addLine();
calcTotals();
}
// ── Start ─────────────────────────────────────────────────────────────────────
loadCfg();
</script>
</body>
</html>