mirror of
https://github.com/kbenestad/invoice.git
synced 2026-06-18 16:14:33 +00:00
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
1522 lines
68 KiB
HTML
1522 lines
68 KiB
HTML
<!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">🌐</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…</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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||
}
|
||
|
||
// ── 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})">×</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})">×</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>
|